From 42541fa45d688e52f450f306b60caaf842571e1d Mon Sep 17 00:00:00 2001 From: HynLcc Date: Wed, 26 Aug 2026 07:38:51 +0800 Subject: [PATCH 01/22] Count a day number from the day the week starts T1972. The instruction that weeks start on Monday was ignored and every day came back numbered from Sunday, so anything grouped or sorted by the column is off by one day - plausible, self-consistent numbers. --- ...-number-when-weeks-start-on-monday.case.ts | 31 +++ ...a-day-number-when-weeks-start-on-monday.md | 33 ++++ framework/runner-registry.ts | 2 + framework/runners/weekday-start-day.runner.ts | 183 ++++++++++++++++++ framework/types.ts | 13 ++ registry.ts | 2 + 6 files changed, 264 insertions(+) create mode 100644 cases/formula/a-day-number-when-weeks-start-on-monday.case.ts create mode 100644 cases/formula/a-day-number-when-weeks-start-on-monday.md create mode 100644 framework/runners/weekday-start-day.runner.ts diff --git a/cases/formula/a-day-number-when-weeks-start-on-monday.case.ts b/cases/formula/a-day-number-when-weeks-start-on-monday.case.ts new file mode 100644 index 0000000..a053788 --- /dev/null +++ b/cases/formula/a-day-number-when-weeks-start-on-monday.case.ts @@ -0,0 +1,31 @@ +import { defineBugCase } from "../../framework/types"; + +// T1972: where the week starts is not a preference about wording. Most of the +// world works Monday to Sunday, and a column that numbers the days is used to +// sort and group by weekday - a rota, a delivery schedule, a weekly report. +// The instruction was ignored and every day came back numbered from Sunday, so +// everything built on the column is off by one day: the numbers are plausible, +// consistent with each other, and only wrong if someone checks a date they +// know the answer for. +export default defineBugCase({ + id: "formula/a-day-number-when-weeks-start-on-monday", + title: "A day number counts from the day the week starts", + runner: "weekday-start-day", + timeoutMs: 240_000, + bug: { + issue: "T1972", + status: "fixed", + sourceCommits: ["3b4d18d81"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-weekday-start", + // A Tuesday: the second day of a week that starts on Monday, the third of + // one that starts on Sunday. + date: "2025-04-15T10:20:30.000Z", + fromMonday: 1, + fromSunday: 2, + settleAttempts: 60, + settleIntervalMs: 500, + }, +}); diff --git a/cases/formula/a-day-number-when-weeks-start-on-monday.md b/cases/formula/a-day-number-when-weeks-start-on-monday.md new file mode 100644 index 0000000..e9db22c --- /dev/null +++ b/cases/formula/a-day-number-when-weeks-start-on-monday.md @@ -0,0 +1,33 @@ +# formula/a-day-number-when-weeks-start-on-monday + +**T1972** — fixed. + +## What the user sees + +Numbers that are plausible, consistent with each other, and one day out. + +Where the week starts is not a preference about wording. Most of the world +works Monday to Sunday, and a column that numbers the days is used to sort and +group by weekday: a rota, a delivery schedule, a weekly report. + +The instruction was ignored and every day came back numbered from Sunday. +Everything built on the column is then off by one day, and nothing says so — +the error only shows if someone checks a date they already know the answer for. + +## What the checkpoint asserts + +Told that weeks start on Monday, the column answers the Monday-based number for +a date whose weekday is known. + +The same date is also asked with no instruction and with Sunday. Both answer +the same way on either side of the fix, and they are what makes the Monday +answer readable rather than a number on its own. + +## What the fixture has to hold + +The date landed on the row. A blank date would make all three columns blank and +say nothing about where the week starts. + +The two expected answers differ — for a Tuesday they are 1 and 2 — or ignoring +the instruction would give the right number anyway. The runner refuses a date +where they agree. diff --git a/framework/runner-registry.ts b/framework/runner-registry.ts index 3e7ed23..56da359 100644 --- a/framework/runner-registry.ts +++ b/framework/runner-registry.ts @@ -95,6 +95,7 @@ import { runBooleanFormulaFilterCase } from "./runners/boolean-formula-filter.ru import { runDuplicateBaseRecentListCase } from "./runners/duplicate-base-recent-list.runner"; import { runLongtextMarkdownConvertCase } from "./runners/longtext-markdown-convert.runner"; import { runConditionalRollupUserMatchCase } from "./runners/conditional-rollup-user-match.runner"; +import { runWeekdayStartDayCase } from "./runners/weekday-start-day.runner"; import { runFormulaOverSystemColumnsCase } from "./runners/formula-over-system-columns.runner"; import { runNestedFilterConjunctionCase } from "./runners/nested-filter-conjunction.runner"; import { runStaleViewColumnMetaCase } from "./runners/stale-view-column-meta.runner"; @@ -230,6 +231,7 @@ const runners: { [K in BugRunnerKind]: RunnerFn } = { "stale-view-column-meta": runStaleViewColumnMetaCase, "nested-filter-conjunction": runNestedFilterConjunctionCase, "conditional-rollup-user-match": runConditionalRollupUserMatchCase, + "weekday-start-day": runWeekdayStartDayCase, "formula-over-system-columns": runFormulaOverSystemColumnsCase, "tracked-modified-sort": runTrackedModifiedSortCase, "lookup-of-link-contains": runLookupOfLinkContainsCase, diff --git a/framework/runners/weekday-start-day.runner.ts b/framework/runners/weekday-start-day.runner.ts new file mode 100644 index 0000000..a53a412 --- /dev/null +++ b/framework/runners/weekday-start-day.runner.ts @@ -0,0 +1,183 @@ +import { + DateFormattingPreset, + FieldKeyType, + FieldType, + TimeFormatting, +} from "@teable/core"; +import { getRecords as apiGetRecords } from "@teable/openapi"; +import { + createField, + createTable, + permanentDeleteTable, +} from "../../../utils/init-app"; +import { bugCheckpoint } from "../checkpoint"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { WeekdayStartDayCaseConfig } from "../types"; + +// A column working out which day of the week a date falls on, told that weeks +// start on Monday -> checkpoint: it counts from Monday. +// +// Where the week starts is not a preference about wording. Most of the world +// works Monday to Sunday, and a column that numbers the days is used to sort +// and group by weekday - a rota, a delivery schedule, a weekly report. +// +// The instruction was ignored and every day came back numbered from Sunday. +// Everything built on the column is then off by one day, and nothing says so: +// the numbers are plausible, they are consistent with each other, and the +// error only shows if someone checks a date they know the answer for. +// +// The same date is also asked about with no instruction and with Sunday, which +// both answer the same way on either side of the fix. They are what makes the +// Monday answer readable rather than a number on its own. + +const NAME_FIELD = "Name"; +const DATE_FIELD = "When"; +const DEFAULT_FIELD = "Day number"; +const MONDAY_FIELD = "Day number, weeks from Monday"; +const SUNDAY_FIELD = "Day number, weeks from Sunday"; + +export const runWeekdayStartDayCase = async ( + bugCase: BugCaseFor<"weekday-start-day">, + context: BugRunContext, +): Promise => { + const config: WeekdayStartDayCaseConfig = bugCase.config; + const baseId = globalThis.testConfig.baseId; + const tableName = `${config.tableNamePrefix}-${context.runId}`; + let tableId = ""; + + if (config.fromMonday === config.fromSunday) { + throw new Error( + "the two answers have to differ, or ignoring where the week starts would give the right number anyway", + ); + } + + try { + const table = await createTable(baseId, { + name: tableName, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + { + name: DATE_FIELD, + type: FieldType.Date, + options: { + formatting: { + date: DateFormattingPreset.ISO, + time: TimeFormatting.None, + timeZone: "UTC", + }, + }, + }, + ], + records: [ + { fields: { [NAME_FIELD]: "a-row", [DATE_FIELD]: config.date } }, + ], + }); + tableId = table.id; + const dateFieldId = table.fields.find( + (field: { name: string }) => field.name === DATE_FIELD, + )?.id; + const rowId = table.records?.[0]?.id; + if (!dateFieldId || !rowId) { + throw new Error(`Table ${tableId} is not in place`); + } + + const asDefault = await createField(tableId, { + name: DEFAULT_FIELD, + type: FieldType.Formula, + options: { expression: `WEEKDAY({${dateFieldId}})` }, + }); + const fromMonday = await createField(tableId, { + name: MONDAY_FIELD, + type: FieldType.Formula, + options: { expression: `WEEKDAY({${dateFieldId}}, "Monday")` }, + }); + const fromSunday = await createField(tableId, { + name: SUNDAY_FIELD, + type: FieldType.Formula, + options: { expression: `WEEKDAY({${dateFieldId}}, "Sunday")` }, + }); + + const readRow = async () => { + const read = await apiGetRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + take: 5, + }); + return read.data.records.find( + (record: { id: string }) => record.id === rowId, + )?.fields; + }; + + // Fixture verification, outside the checkpoint: the date landed. A blank + // date would make every one of the three columns blank and say nothing + // about where the week starts. + let fields = await readRow(); + for ( + let attempt = 0; + attempt < config.settleAttempts && fields?.[asDefault.id] == null; + attempt += 1 + ) { + await new Promise((resolve) => + setTimeout(resolve, config.settleIntervalMs), + ); + fields = await readRow(); + } + if (fields?.[dateFieldId] == null) { + throw new Error( + `the row holds no date: ${JSON.stringify(fields)} - the fixture is not in place`, + ); + } + + const probe = await bugCheckpoint( + "a-day-number-counts-from-the-day-the-week-starts", + async () => { + const answers = { + [DEFAULT_FIELD]: Number(fields?.[asDefault.id]), + [MONDAY_FIELD]: Number(fields?.[fromMonday.id]), + [SUNDAY_FIELD]: Number(fields?.[fromSunday.id]), + }; + if (answers[MONDAY_FIELD] !== config.fromMonday) { + throw new Error( + `told that weeks start on Monday, the column answers ${answers[MONDAY_FIELD]} for ${config.date}, expected ${config.fromMonday}` + + (answers[MONDAY_FIELD] === config.fromSunday + ? " - it counted from Sunday, so everything built on this column is off by one day and nothing says so" + : ""), + ); + } + // The two that answer the same either way, kept so the Monday answer + // is read against something rather than on its own. + if (answers[SUNDAY_FIELD] !== config.fromSunday) { + throw new Error( + `told that weeks start on Sunday, the column answers ${answers[SUNDAY_FIELD]}, expected ${config.fromSunday}`, + ); + } + if (answers[DEFAULT_FIELD] !== config.fromSunday) { + throw new Error( + `asked with no instruction, the column answers ${answers[DEFAULT_FIELD]}, expected ${config.fromSunday}`, + ); + } + return { answers }; + }, + ); + + return { + details: { + tableId, + date: config.date, + answers: probe.answers, + }, + }; + } finally { + if (tableId) { + try { + await permanentDeleteTable(baseId, tableId); + } catch (error) { + // Cleanup is the case's own housekeeping - the product did not fail. + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/types.ts b/framework/types.ts index e9b7a6e..8cf6509 100644 --- a/framework/types.ts +++ b/framework/types.ts @@ -112,6 +112,7 @@ export interface BugCaseConfigByRunner { "stale-view-column-meta": StaleViewColumnMetaCaseConfig; "nested-filter-conjunction": NestedFilterConjunctionCaseConfig; "conditional-rollup-user-match": ConditionalRollupUserMatchCaseConfig; + "weekday-start-day": WeekdayStartDayCaseConfig; "formula-over-system-columns": FormulaOverSystemColumnsCaseConfig; "tracked-modified-sort": TrackedModifiedSortCaseConfig; "lookup-of-link-contains": LookupOfLinkContainsCaseConfig; @@ -1737,6 +1738,18 @@ export interface FormulaOverSystemColumnsCaseConfig { rowTitle: string; } +export interface WeekdayStartDayCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + date: string; + // What the day number is when weeks start on each day. They have to differ, + // or ignoring the instruction would give the right number anyway. + fromMonday: number; + fromSunday: number; + settleAttempts: number; + settleIntervalMs: number; +} + export interface ConditionalRollupUserMatchCaseConfig { baseId: "seed-base"; tableNamePrefix: string; diff --git a/registry.ts b/registry.ts index 5c92900..4e849cc 100644 --- a/registry.ts +++ b/registry.ts @@ -18,6 +18,7 @@ import longtextMarkdownConvertCase from "./cases/field/edit-a-column-that-render import staleViewColumnMetaCase from "./cases/view/a-view-that-still-describes-a-deleted-column.case"; import nestedFilterConjunctionCase from "./cases/filter/a-group-inside-a-group.case"; import conditionalRollupUserMatchCase from "./cases/lookup/hours-owned-by-anyone-on-this-row.case"; +import weekdayStartDayCase from "./cases/formula/a-day-number-when-weeks-start-on-monday.case"; import formulaOverSystemColumnsCase from "./cases/formula/columns-worked-out-from-a-new-row.case"; import trackedModifiedSortCase from "./cases/view/sort-by-a-narrowed-last-changed-column.case"; import lookupOfLinkContainsCase from "./cases/filter/search-a-borrowed-link-column.case"; @@ -151,6 +152,7 @@ const cases = [ staleViewColumnMetaCase, nestedFilterConjunctionCase, conditionalRollupUserMatchCase, + weekdayStartDayCase, formulaOverSystemColumnsCase, trackedModifiedSortCase, lookupOfLinkContainsCase, From f7afc4aa6736e7e492e00717e09dfd789bdf8732 Mon Sep 17 00:00:00 2001 From: HynLcc Date: Wed, 26 Aug 2026 07:57:33 +0800 Subject: [PATCH 02/22] Answer how long ago in the unit it was asked for T1970. The unit named in the formula was ignored and every answer came back in seconds, so a person reads a six-figure number where they expected a small one. Hours are checked against days, which holds whatever today's date is. --- cases/formula/how-long-ago-in-days.case.ts | 31 ++++ cases/formula/how-long-ago-in-days.md | 38 +++++ framework/runner-registry.ts | 2 + framework/runners/fromnow-unit.runner.ts | 188 +++++++++++++++++++++ framework/types.ts | 14 ++ registry.ts | 2 + 6 files changed, 275 insertions(+) create mode 100644 cases/formula/how-long-ago-in-days.case.ts create mode 100644 cases/formula/how-long-ago-in-days.md create mode 100644 framework/runners/fromnow-unit.runner.ts diff --git a/cases/formula/how-long-ago-in-days.case.ts b/cases/formula/how-long-ago-in-days.case.ts new file mode 100644 index 0000000..99cf20c --- /dev/null +++ b/cases/formula/how-long-ago-in-days.case.ts @@ -0,0 +1,31 @@ +import { defineBugCase } from "../../framework/types"; + +// T1970: "how many days since we heard from them", "how old is this ticket" - +// the unit is the question. Nobody asks how long ago something was and means +// seconds; naming a unit is how you get a number a person can read at a glance +// and compare against a policy. The unit was ignored and every answer came +// back in seconds: a six-figure number where a small one was expected, and any +// rule written against the column fires on everything or nothing. +export default defineBugCase({ + id: "formula/how-long-ago-in-days", + title: "How long ago answers in the unit it was asked for", + runner: "fromnow-unit", + timeoutMs: 240_000, + bug: { + issue: "T1970", + status: "fixed", + sourceCommits: ["c2c072873"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-how-long-ago", + date: "2020-01-15T00:00:00.000Z", + minimumDaysAgo: 365, + // The answer moves with today's date, so both are compared loosely - the + // failure this guards is off by a factor of tens of thousands. + dayTolerance: 2, + hourTolerance: 48, + settleAttempts: 60, + settleIntervalMs: 500, + }, +}); diff --git a/cases/formula/how-long-ago-in-days.md b/cases/formula/how-long-ago-in-days.md new file mode 100644 index 0000000..7ae16ca --- /dev/null +++ b/cases/formula/how-long-ago-in-days.md @@ -0,0 +1,38 @@ +# formula/how-long-ago-in-days + +**T1970** — fixed. + +## What the user sees + +A six-figure number where they expected a small one. + +"How many days since we heard from them", "how old is this ticket" — the unit +_is_ the question. Nobody asks how long ago something was and means seconds. +Naming a unit is how a person gets a number they can read at a glance and +compare against a policy: chase after 30 days, escalate after 90. + +The unit was ignored and every answer came back in seconds. It does not look +like a unit mistake; it looks like a column that has stopped making sense, and +any rule written against it fires on everything or nothing. + +## What the checkpoint asserts + +Asked in days, the column answers the number of days since the date — and the +same date asked in hours answers twenty-four times that. + +The second is what tells "the unit was applied" from "the number happens to +look plausible", and it holds whatever today's date is. + +## What the fixture has to hold + +The date landed and both columns answered something. A blank answer says +nothing about units. + +The date is well in the past — too close to today and days cannot be told from +hours. The runner refuses a date nearer than the configured minimum. + +## Why the comparisons are loose + +The answer moves with today's date, so both are compared with a tolerance. The +failure this guards is off by a factor of tens of thousands, which no tolerance +of a day or two can hide. diff --git a/framework/runner-registry.ts b/framework/runner-registry.ts index 56da359..6e4f8e4 100644 --- a/framework/runner-registry.ts +++ b/framework/runner-registry.ts @@ -95,6 +95,7 @@ import { runBooleanFormulaFilterCase } from "./runners/boolean-formula-filter.ru import { runDuplicateBaseRecentListCase } from "./runners/duplicate-base-recent-list.runner"; import { runLongtextMarkdownConvertCase } from "./runners/longtext-markdown-convert.runner"; import { runConditionalRollupUserMatchCase } from "./runners/conditional-rollup-user-match.runner"; +import { runFromnowUnitCase } from "./runners/fromnow-unit.runner"; import { runWeekdayStartDayCase } from "./runners/weekday-start-day.runner"; import { runFormulaOverSystemColumnsCase } from "./runners/formula-over-system-columns.runner"; import { runNestedFilterConjunctionCase } from "./runners/nested-filter-conjunction.runner"; @@ -232,6 +233,7 @@ const runners: { [K in BugRunnerKind]: RunnerFn } = { "nested-filter-conjunction": runNestedFilterConjunctionCase, "conditional-rollup-user-match": runConditionalRollupUserMatchCase, "weekday-start-day": runWeekdayStartDayCase, + "fromnow-unit": runFromnowUnitCase, "formula-over-system-columns": runFormulaOverSystemColumnsCase, "tracked-modified-sort": runTrackedModifiedSortCase, "lookup-of-link-contains": runLookupOfLinkContainsCase, diff --git a/framework/runners/fromnow-unit.runner.ts b/framework/runners/fromnow-unit.runner.ts new file mode 100644 index 0000000..17cbbbe --- /dev/null +++ b/framework/runners/fromnow-unit.runner.ts @@ -0,0 +1,188 @@ +import { + DateFormattingPreset, + FieldKeyType, + FieldType, + TimeFormatting, +} from "@teable/core"; +import { getRecords as apiGetRecords } from "@teable/openapi"; +import { + createField, + createTable, + permanentDeleteTable, +} from "../../../utils/init-app"; +import { bugCheckpoint } from "../checkpoint"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { FromnowUnitCaseConfig } from "../types"; + +// A column saying how long ago a date was, asked for in days -> checkpoint: it +// answers in days. +// +// "How many days since we heard from them", "how old is this ticket" - the +// unit is the question. Nobody asks how long ago something was and means +// seconds; the whole point of naming a unit is to get a number a person can +// read at a glance and compare against a policy: chase after 30 days, escalate +// after 90. +// +// The unit was ignored and every answer came back in seconds. What a person +// sees is a six-figure number where they expected a small one - not obviously +// a unit mistake, just a column that has stopped making sense, and any rule +// written against it fires on everything or nothing. +// +// The case also asks the same date in hours and requires that answer to be +// twenty-four times the day one. That holds whatever today's date is, and it +// is what tells "the unit was applied" from "the number happens to look +// plausible". + +const NAME_FIELD = "Name"; +const DATE_FIELD = "Last heard from"; +const DAYS_FIELD = "Days since"; +const HOURS_FIELD = "Hours since"; + +export const runFromnowUnitCase = async ( + bugCase: BugCaseFor<"fromnow-unit">, + context: BugRunContext, +): Promise => { + const config: FromnowUnitCaseConfig = bugCase.config; + const baseId = globalThis.testConfig.baseId; + const tableName = `${config.tableNamePrefix}-${context.runId}`; + let tableId = ""; + + const daysAgo = Math.floor( + (Date.now() - Date.parse(config.date)) / (24 * 60 * 60 * 1000), + ); + if (!Number.isFinite(daysAgo) || daysAgo < config.minimumDaysAgo) { + throw new Error( + `the fixture date is ${daysAgo} days ago, and the case needs at least ${config.minimumDaysAgo} - ` + + "a date too close to today cannot tell days from hours", + ); + } + + try { + const table = await createTable(baseId, { + name: tableName, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + { + name: DATE_FIELD, + type: FieldType.Date, + options: { + formatting: { + date: DateFormattingPreset.ISO, + time: TimeFormatting.None, + timeZone: "UTC", + }, + }, + }, + ], + records: [ + { fields: { [NAME_FIELD]: "a-row", [DATE_FIELD]: config.date } }, + ], + }); + tableId = table.id; + const dateFieldId = table.fields.find( + (field: { name: string }) => field.name === DATE_FIELD, + )?.id; + const rowId = table.records?.[0]?.id; + if (!dateFieldId || !rowId) { + throw new Error(`Table ${tableId} is not in place`); + } + + const inDays = await createField(tableId, { + name: DAYS_FIELD, + type: FieldType.Formula, + options: { expression: `FROMNOW({${dateFieldId}}, "day")` }, + }); + const inHours = await createField(tableId, { + name: HOURS_FIELD, + type: FieldType.Formula, + options: { expression: `FROMNOW({${dateFieldId}}, "hour")` }, + }); + + const readRow = async () => { + const read = await apiGetRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + take: 5, + }); + return read.data.records.find( + (record: { id: string }) => record.id === rowId, + )?.fields; + }; + + // Fixture verification, outside the checkpoint: the date landed and both + // columns answered something. A blank answer says nothing about units. + let fields = await readRow(); + for ( + let attempt = 0; + attempt < config.settleAttempts && + (fields?.[inDays.id] == null || fields?.[inHours.id] == null); + attempt += 1 + ) { + await new Promise((resolve) => + setTimeout(resolve, config.settleIntervalMs), + ); + fields = await readRow(); + } + if (fields?.[dateFieldId] == null) { + throw new Error( + `the row holds no date: ${JSON.stringify(fields)} - the fixture is not in place`, + ); + } + if (fields?.[inDays.id] == null || fields?.[inHours.id] == null) { + throw new Error( + `one of the columns answered nothing: ${JSON.stringify({ + days: fields?.[inDays.id], + hours: fields?.[inHours.id], + })}`, + ); + } + + const probe = await bugCheckpoint( + "how-long-ago-answers-in-the-unit-it-was-asked-for", + async () => { + const days = Number(fields?.[inDays.id]); + const hours = Number(fields?.[inHours.id]); + + if (Math.abs(days - daysAgo) > config.dayTolerance) { + throw new Error( + `asked how long ago in days, the column answers ${days} for a date ${daysAgo} days ago - ` + + (days > daysAgo * 100 + ? "the unit was ignored, so a person reads a six-figure number where they expected a small one and any rule written against it fires on everything" + : "the number is not the number of days"), + ); + } + // Whatever today's date is, hours are twenty-four times days. This is + // what tells "the unit was applied" from "the number happens to look + // plausible". + if (Math.abs(hours - days * 24) > config.hourTolerance) { + throw new Error( + `the same date reads ${days} days and ${hours} hours - one of the two units was not applied`, + ); + } + return { days, hours }; + }, + ); + + return { + details: { + tableId, + date: config.date, + daysAgo, + answeredDays: probe.days, + answeredHours: probe.hours, + }, + }; + } finally { + if (tableId) { + try { + await permanentDeleteTable(baseId, tableId); + } catch (error) { + // Cleanup is the case's own housekeeping - the product did not fail. + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/types.ts b/framework/types.ts index 8cf6509..2da9d4a 100644 --- a/framework/types.ts +++ b/framework/types.ts @@ -113,6 +113,7 @@ export interface BugCaseConfigByRunner { "nested-filter-conjunction": NestedFilterConjunctionCaseConfig; "conditional-rollup-user-match": ConditionalRollupUserMatchCaseConfig; "weekday-start-day": WeekdayStartDayCaseConfig; + "fromnow-unit": FromnowUnitCaseConfig; "formula-over-system-columns": FormulaOverSystemColumnsCaseConfig; "tracked-modified-sort": TrackedModifiedSortCaseConfig; "lookup-of-link-contains": LookupOfLinkContainsCaseConfig; @@ -1738,6 +1739,19 @@ export interface FormulaOverSystemColumnsCaseConfig { rowTitle: string; } +export interface FromnowUnitCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + // A date well in the past: too close to today and days cannot be told from + // hours. + date: string; + minimumDaysAgo: number; + dayTolerance: number; + hourTolerance: number; + settleAttempts: number; + settleIntervalMs: number; +} + export interface WeekdayStartDayCaseConfig { baseId: "seed-base"; tableNamePrefix: string; diff --git a/registry.ts b/registry.ts index 4e849cc..f27c569 100644 --- a/registry.ts +++ b/registry.ts @@ -19,6 +19,7 @@ import staleViewColumnMetaCase from "./cases/view/a-view-that-still-describes-a- import nestedFilterConjunctionCase from "./cases/filter/a-group-inside-a-group.case"; import conditionalRollupUserMatchCase from "./cases/lookup/hours-owned-by-anyone-on-this-row.case"; import weekdayStartDayCase from "./cases/formula/a-day-number-when-weeks-start-on-monday.case"; +import fromnowUnitCase from "./cases/formula/how-long-ago-in-days.case"; import formulaOverSystemColumnsCase from "./cases/formula/columns-worked-out-from-a-new-row.case"; import trackedModifiedSortCase from "./cases/view/sort-by-a-narrowed-last-changed-column.case"; import lookupOfLinkContainsCase from "./cases/filter/search-a-borrowed-link-column.case"; @@ -153,6 +154,7 @@ const cases = [ nestedFilterConjunctionCase, conditionalRollupUserMatchCase, weekdayStartDayCase, + fromnowUnitCase, formulaOverSystemColumnsCase, trackedModifiedSortCase, lookupOfLinkContainsCase, From 2670bdb4a84826c0b69e6a7d8c8101b78cfa4f75 Mon Sep 17 00:00:00 2001 From: HynLcc Date: Wed, 26 Aug 2026 08:15:15 +0800 Subject: [PATCH 03/22] Put a deleted row in the trash T1980. The trash is the promise that a delete is not final; rows were not being written to it, so there is nothing to notice until someone goes looking and the row is gone for good. --- .../record/a-deleted-row-in-the-trash.case.ts | 26 ++++ cases/record/a-deleted-row-in-the-trash.md | 34 ++++ framework/runner-registry.ts | 2 + .../deleted-row-in-the-trash.runner.ts | 146 ++++++++++++++++++ framework/types.ts | 12 ++ registry.ts | 2 + 6 files changed, 222 insertions(+) create mode 100644 cases/record/a-deleted-row-in-the-trash.case.ts create mode 100644 cases/record/a-deleted-row-in-the-trash.md create mode 100644 framework/runners/deleted-row-in-the-trash.runner.ts diff --git a/cases/record/a-deleted-row-in-the-trash.case.ts b/cases/record/a-deleted-row-in-the-trash.case.ts new file mode 100644 index 0000000..451852a --- /dev/null +++ b/cases/record/a-deleted-row-in-the-trash.case.ts @@ -0,0 +1,26 @@ +import { defineBugCase } from "../../framework/types"; + +// T1980: the trash is the promise that a delete is not final - it is what +// makes deleting a row an ordinary thing to do rather than a decision. Rows +// were not being written to it. The delete works and the row is gone, so there +// is nothing to notice until the day someone goes looking, and by then the row +// is not recoverable and nobody can say when it went. +export default defineBugCase({ + id: "record/a-deleted-row-in-the-trash", + title: "A deleted row is in the trash", + runner: "deleted-row-in-the-trash", + timeoutMs: 180_000, + bug: { + issue: "T1980", + status: "fixed", + sourceCommits: ["4e1be01f7"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-deleted-row-trash", + deletedRowName: "the-deleted-row", + keptRowName: "the-kept-row", + settleAttempts: 60, + settleIntervalMs: 500, + }, +}); diff --git a/cases/record/a-deleted-row-in-the-trash.md b/cases/record/a-deleted-row-in-the-trash.md new file mode 100644 index 0000000..a360e73 --- /dev/null +++ b/cases/record/a-deleted-row-in-the-trash.md @@ -0,0 +1,34 @@ +# record/a-deleted-row-in-the-trash + +**T1980** — fixed. + +## What the user sees + +Nothing, until the day they go looking. + +The trash is the promise that a delete is not final. It is what makes deleting +a row an ordinary thing to do rather than a decision: someone clears out what +looks like a duplicate, and if they were wrong it is there to be put back. + +The rows were not being written to it. The delete works and the row is gone, so +there is nothing to notice at the time. By the time anyone looks, the row is +not recoverable and nobody can say when it went — and an empty trash is not +read as "this is broken", it is read as "I must have deleted it somewhere +else". + +## What the checkpoint asserts + +The row really left the table, and the table's trash holds an entry naming it. + +The first half matters: with no delete, "nothing is in the trash" would be the +correct answer and a different report. + +## What the fixture has to hold + +The table's trash is empty to begin with, so anything found afterwards came +from this delete. + +## Why the case waits + +What goes into the trash is written after the delete answers, so the case polls +until the entry appears or the attempts run out. diff --git a/framework/runner-registry.ts b/framework/runner-registry.ts index 6e4f8e4..3c8e188 100644 --- a/framework/runner-registry.ts +++ b/framework/runner-registry.ts @@ -95,6 +95,7 @@ import { runBooleanFormulaFilterCase } from "./runners/boolean-formula-filter.ru import { runDuplicateBaseRecentListCase } from "./runners/duplicate-base-recent-list.runner"; import { runLongtextMarkdownConvertCase } from "./runners/longtext-markdown-convert.runner"; import { runConditionalRollupUserMatchCase } from "./runners/conditional-rollup-user-match.runner"; +import { runDeletedRowInTheTrashCase } from "./runners/deleted-row-in-the-trash.runner"; import { runFromnowUnitCase } from "./runners/fromnow-unit.runner"; import { runWeekdayStartDayCase } from "./runners/weekday-start-day.runner"; import { runFormulaOverSystemColumnsCase } from "./runners/formula-over-system-columns.runner"; @@ -234,6 +235,7 @@ const runners: { [K in BugRunnerKind]: RunnerFn } = { "conditional-rollup-user-match": runConditionalRollupUserMatchCase, "weekday-start-day": runWeekdayStartDayCase, "fromnow-unit": runFromnowUnitCase, + "deleted-row-in-the-trash": runDeletedRowInTheTrashCase, "formula-over-system-columns": runFormulaOverSystemColumnsCase, "tracked-modified-sort": runTrackedModifiedSortCase, "lookup-of-link-contains": runLookupOfLinkContainsCase, diff --git a/framework/runners/deleted-row-in-the-trash.runner.ts b/framework/runners/deleted-row-in-the-trash.runner.ts new file mode 100644 index 0000000..601f3d9 --- /dev/null +++ b/framework/runners/deleted-row-in-the-trash.runner.ts @@ -0,0 +1,146 @@ +import { FieldKeyType, FieldType } from "@teable/core"; +import { + deleteRecords as apiDeleteRecords, + getRecords as apiGetRecords, + getTrashItems as apiGetTrashItems, + ResourceType, +} from "@teable/openapi"; +import { createTable, permanentDeleteTable } from "../../../utils/init-app"; +import { bugCheckpoint } from "../checkpoint"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { DeletedRowInTheTrashCaseConfig } from "../types"; + +// Delete a row -> checkpoint: it is in the table's trash. +// +// The trash is the promise that a delete is not final. It is what makes +// deleting a row an ordinary thing to do rather than a decision: someone +// clears out what looks like a duplicate, and if they were wrong it is there +// to be put back. +// +// The rows were not being written to it. The delete works and the row is gone, +// so there is nothing to notice until the day someone goes looking - and by +// then the row is not recoverable and nobody can say when it went. The trash +// being empty is not read as "this is broken", it is read as "I must have +// deleted it somewhere else". +// +// The case waits for the entry rather than reading once: what goes into the +// trash is written after the delete answers. + +const NAME_FIELD = "Name"; + +export const runDeletedRowInTheTrashCase = async ( + bugCase: BugCaseFor<"deleted-row-in-the-trash">, + context: BugRunContext, +): Promise => { + const config: DeletedRowInTheTrashCaseConfig = bugCase.config; + const baseId = globalThis.testConfig.baseId; + const tableName = `${config.tableNamePrefix}-${context.runId}`; + let tableId = ""; + + try { + const table = await createTable(baseId, { + name: tableName, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + ], + records: [ + { fields: { [NAME_FIELD]: config.deletedRowName } }, + { fields: { [NAME_FIELD]: config.keptRowName } }, + ], + }); + tableId = table.id; + const deletedRowId = table.records?.[0]?.id; + if (!deletedRowId || !table.records?.[1]?.id) { + throw new Error(`Table ${tableId} is not in place`); + } + + // Fixture verification, outside the checkpoint: this table's trash is + // empty to begin with, so anything found afterwards came from the delete. + const before = await apiGetTrashItems({ + resourceId: tableId, + resourceType: ResourceType.Table, + }); + if ((before.data.trashItems ?? []).length !== 0) { + throw new Error( + `the table's trash already holds ${before.data.trashItems.length} entries - the fixture is not in place`, + ); + } + + const probe = await bugCheckpoint( + "a-deleted-row-is-in-the-trash", + async () => { + await apiDeleteRecords(tableId, [deletedRowId]); + + // The delete has to have happened, or "nothing is in the trash" would + // be the correct answer and a different report. + const after = await apiGetRecords(tableId, { + fieldKeyType: FieldKeyType.Name, + take: 5, + }); + const names = after.data.records.map( + (record: { fields: Record }) => + String(record.fields[NAME_FIELD]), + ); + if (names.includes(config.deletedRowName)) { + throw new Error( + `the row is still in the table after being deleted: [${names.join(", ")}]`, + ); + } + + // What goes into the trash is written after the delete answers. + let entries: { resourceIds?: string[] }[] = []; + for (let attempt = 0; attempt < config.settleAttempts; attempt += 1) { + const trash = await apiGetTrashItems({ + resourceId: tableId, + resourceType: ResourceType.Table, + }); + entries = (trash.data.trashItems ?? []) as { + resourceIds?: string[]; + }[]; + if (entries.length > 0) { + break; + } + await new Promise((resolve) => + setTimeout(resolve, config.settleIntervalMs), + ); + } + if (entries.length === 0) { + throw new Error( + `the row was deleted and the table's trash is empty after ${config.settleAttempts} tries - ` + + "the row is not recoverable and nobody can say when it went", + ); + } + const holdsRow = entries.some((entry) => + (entry.resourceIds ?? []).includes(deletedRowId), + ); + if (!holdsRow) { + throw new Error( + `the trash holds ${entries.length} entries and none of them is the deleted row: ${JSON.stringify(entries)}`, + ); + } + return { entries: entries.length }; + }, + ); + + return { + details: { + tableId, + deletedRowId, + trashEntries: probe.entries, + }, + }; + } finally { + if (tableId) { + try { + await permanentDeleteTable(baseId, tableId); + } catch (error) { + // Cleanup is the case's own housekeeping - the product did not fail. + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/types.ts b/framework/types.ts index 2da9d4a..28dcea9 100644 --- a/framework/types.ts +++ b/framework/types.ts @@ -114,6 +114,7 @@ export interface BugCaseConfigByRunner { "conditional-rollup-user-match": ConditionalRollupUserMatchCaseConfig; "weekday-start-day": WeekdayStartDayCaseConfig; "fromnow-unit": FromnowUnitCaseConfig; + "deleted-row-in-the-trash": DeletedRowInTheTrashCaseConfig; "formula-over-system-columns": FormulaOverSystemColumnsCaseConfig; "tracked-modified-sort": TrackedModifiedSortCaseConfig; "lookup-of-link-contains": LookupOfLinkContainsCaseConfig; @@ -1739,6 +1740,17 @@ export interface FormulaOverSystemColumnsCaseConfig { rowTitle: string; } +export interface DeletedRowInTheTrashCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + deletedRowName: string; + // A row nobody deletes, so a delete that took the whole table and one that + // took the right row stay distinguishable. + keptRowName: string; + settleAttempts: number; + settleIntervalMs: number; +} + export interface FromnowUnitCaseConfig { baseId: "seed-base"; tableNamePrefix: string; diff --git a/registry.ts b/registry.ts index f27c569..f746336 100644 --- a/registry.ts +++ b/registry.ts @@ -20,6 +20,7 @@ import nestedFilterConjunctionCase from "./cases/filter/a-group-inside-a-group.c import conditionalRollupUserMatchCase from "./cases/lookup/hours-owned-by-anyone-on-this-row.case"; import weekdayStartDayCase from "./cases/formula/a-day-number-when-weeks-start-on-monday.case"; import fromnowUnitCase from "./cases/formula/how-long-ago-in-days.case"; +import deletedRowInTheTrashCase from "./cases/record/a-deleted-row-in-the-trash.case"; import formulaOverSystemColumnsCase from "./cases/formula/columns-worked-out-from-a-new-row.case"; import trackedModifiedSortCase from "./cases/view/sort-by-a-narrowed-last-changed-column.case"; import lookupOfLinkContainsCase from "./cases/filter/search-a-borrowed-link-column.case"; @@ -155,6 +156,7 @@ const cases = [ conditionalRollupUserMatchCase, weekdayStartDayCase, fromnowUnitCase, + deletedRowInTheTrashCase, formulaOverSystemColumnsCase, trackedModifiedSortCase, lookupOfLinkContainsCase, From 040188f7e1e87e808c83d496b898d08a4896347f Mon Sep 17 00:00:00 2001 From: HynLcc Date: Wed, 26 Aug 2026 08:32:40 +0800 Subject: [PATCH 04/22] Settle the deleted-row trash rejection T1980. Written and run, green on both columns (run 32914315455). The shape is gone; the runner is not kept. --- .../record/a-deleted-row-in-the-trash.case.ts | 26 ---- cases/record/a-deleted-row-in-the-trash.md | 34 ---- docs/triage-ledger.md | 1 + framework/runner-registry.ts | 2 - .../deleted-row-in-the-trash.runner.ts | 146 ------------------ framework/types.ts | 12 -- registry.ts | 2 - 7 files changed, 1 insertion(+), 222 deletions(-) delete mode 100644 cases/record/a-deleted-row-in-the-trash.case.ts delete mode 100644 cases/record/a-deleted-row-in-the-trash.md delete mode 100644 framework/runners/deleted-row-in-the-trash.runner.ts diff --git a/cases/record/a-deleted-row-in-the-trash.case.ts b/cases/record/a-deleted-row-in-the-trash.case.ts deleted file mode 100644 index 451852a..0000000 --- a/cases/record/a-deleted-row-in-the-trash.case.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { defineBugCase } from "../../framework/types"; - -// T1980: the trash is the promise that a delete is not final - it is what -// makes deleting a row an ordinary thing to do rather than a decision. Rows -// were not being written to it. The delete works and the row is gone, so there -// is nothing to notice until the day someone goes looking, and by then the row -// is not recoverable and nobody can say when it went. -export default defineBugCase({ - id: "record/a-deleted-row-in-the-trash", - title: "A deleted row is in the trash", - runner: "deleted-row-in-the-trash", - timeoutMs: 180_000, - bug: { - issue: "T1980", - status: "fixed", - sourceCommits: ["4e1be01f7"], - }, - config: { - baseId: "seed-base", - tableNamePrefix: "e2e-lab-deleted-row-trash", - deletedRowName: "the-deleted-row", - keptRowName: "the-kept-row", - settleAttempts: 60, - settleIntervalMs: 500, - }, -}); diff --git a/cases/record/a-deleted-row-in-the-trash.md b/cases/record/a-deleted-row-in-the-trash.md deleted file mode 100644 index a360e73..0000000 --- a/cases/record/a-deleted-row-in-the-trash.md +++ /dev/null @@ -1,34 +0,0 @@ -# record/a-deleted-row-in-the-trash - -**T1980** — fixed. - -## What the user sees - -Nothing, until the day they go looking. - -The trash is the promise that a delete is not final. It is what makes deleting -a row an ordinary thing to do rather than a decision: someone clears out what -looks like a duplicate, and if they were wrong it is there to be put back. - -The rows were not being written to it. The delete works and the row is gone, so -there is nothing to notice at the time. By the time anyone looks, the row is -not recoverable and nobody can say when it went — and an empty trash is not -read as "this is broken", it is read as "I must have deleted it somewhere -else". - -## What the checkpoint asserts - -The row really left the table, and the table's trash holds an entry naming it. - -The first half matters: with no delete, "nothing is in the trash" would be the -correct answer and a different report. - -## What the fixture has to hold - -The table's trash is empty to begin with, so anything found afterwards came -from this delete. - -## Why the case waits - -What goes into the trash is written after the delete answers, so the case polls -until the entry appears or the attempts run out. diff --git a/docs/triage-ledger.md b/docs/triage-ledger.md index 9d8782a..654f20b 100644 --- a/docs/triage-ledger.md +++ b/docs/triage-ledger.md @@ -131,6 +131,7 @@ The shape is gone; the runner is not kept. | `aaa9ac78d` | T6959 | Written and run, green on both columns (run 32892611155): a number column converted into a formula producing padded reference codes fills every row on the fix's parent. The schema operation the commit repairs dies where the physical column's type and the rule's output disagree, and a conversion made through the public field API appears to align the column first. | | `56fe8df36` | T4864 | Written in two shapes and run twice, red on **both** columns each time. Asked through the ordinary record endpoint with the link column's view, the product names nothing as linked on develop either (run 32901128869). Asked through a shared view of the host table, both columns refuse the query outright and with different messages - develop says the field is not found, the parent that it is not linked to the current table (run 32902812166) - so the selected-rows question is not addressed to the table this case addressed it to. Which read path the fix's `getViewRecords` belongs to, and what a client sends it, would have to be settled before a third attempt. | | `301a8ea59` | T1516 | Written and run, green on both columns (run 32906244128): a many-to-many row whose link cell was blanked with SQL while the pairing record stayed deletes cleanly on the fix's parent. The constraint the commit repairs is on the pairing record itself, and blanking the cell on one side is evidently not the state that trips it - which side's records to leave inconsistent would have to be settled first. | +| `4e1be01f7` | T1980 | Written and run, green on both columns (run 32914315455): a deleted row lands in the table's trash on the fix's parent. The commit adds the trash projection to the **v2** delete path and its own reproduction turns v2 on with a canary setting; at a parent this old the lab's delete evidently does not take that path, so the case watched the one that always wrote. | | `93d97c3ba` | T5268 | Written and run: the by-id paste endpoint the case needs does not exist on the fix's parent - `urlBuilder` was handed an undefined template and the column errored (run 32888946585) - while develop pastes a blank first line correctly. The fix introduces the path it repairs, so there is no before to compare against through the public API. | | `0548611b2` | T6576 | Not attempted. The commit's own reproduction is skipped under forced v2 - the spec gates it on the v1 path - and the lab forces v2, so the case could not go red. Same reason as the T5496 and T3303 rows. | | `7cb4431e9` | T6502 | Not attempted, same reason: the commit covers the shape with a forced-v1 e2e, and the lab forces v2. | diff --git a/framework/runner-registry.ts b/framework/runner-registry.ts index 3c8e188..6e4f8e4 100644 --- a/framework/runner-registry.ts +++ b/framework/runner-registry.ts @@ -95,7 +95,6 @@ import { runBooleanFormulaFilterCase } from "./runners/boolean-formula-filter.ru import { runDuplicateBaseRecentListCase } from "./runners/duplicate-base-recent-list.runner"; import { runLongtextMarkdownConvertCase } from "./runners/longtext-markdown-convert.runner"; import { runConditionalRollupUserMatchCase } from "./runners/conditional-rollup-user-match.runner"; -import { runDeletedRowInTheTrashCase } from "./runners/deleted-row-in-the-trash.runner"; import { runFromnowUnitCase } from "./runners/fromnow-unit.runner"; import { runWeekdayStartDayCase } from "./runners/weekday-start-day.runner"; import { runFormulaOverSystemColumnsCase } from "./runners/formula-over-system-columns.runner"; @@ -235,7 +234,6 @@ const runners: { [K in BugRunnerKind]: RunnerFn } = { "conditional-rollup-user-match": runConditionalRollupUserMatchCase, "weekday-start-day": runWeekdayStartDayCase, "fromnow-unit": runFromnowUnitCase, - "deleted-row-in-the-trash": runDeletedRowInTheTrashCase, "formula-over-system-columns": runFormulaOverSystemColumnsCase, "tracked-modified-sort": runTrackedModifiedSortCase, "lookup-of-link-contains": runLookupOfLinkContainsCase, diff --git a/framework/runners/deleted-row-in-the-trash.runner.ts b/framework/runners/deleted-row-in-the-trash.runner.ts deleted file mode 100644 index 601f3d9..0000000 --- a/framework/runners/deleted-row-in-the-trash.runner.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { FieldKeyType, FieldType } from "@teable/core"; -import { - deleteRecords as apiDeleteRecords, - getRecords as apiGetRecords, - getTrashItems as apiGetTrashItems, - ResourceType, -} from "@teable/openapi"; -import { createTable, permanentDeleteTable } from "../../../utils/init-app"; -import { bugCheckpoint } from "../checkpoint"; -import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; -import type { DeletedRowInTheTrashCaseConfig } from "../types"; - -// Delete a row -> checkpoint: it is in the table's trash. -// -// The trash is the promise that a delete is not final. It is what makes -// deleting a row an ordinary thing to do rather than a decision: someone -// clears out what looks like a duplicate, and if they were wrong it is there -// to be put back. -// -// The rows were not being written to it. The delete works and the row is gone, -// so there is nothing to notice until the day someone goes looking - and by -// then the row is not recoverable and nobody can say when it went. The trash -// being empty is not read as "this is broken", it is read as "I must have -// deleted it somewhere else". -// -// The case waits for the entry rather than reading once: what goes into the -// trash is written after the delete answers. - -const NAME_FIELD = "Name"; - -export const runDeletedRowInTheTrashCase = async ( - bugCase: BugCaseFor<"deleted-row-in-the-trash">, - context: BugRunContext, -): Promise => { - const config: DeletedRowInTheTrashCaseConfig = bugCase.config; - const baseId = globalThis.testConfig.baseId; - const tableName = `${config.tableNamePrefix}-${context.runId}`; - let tableId = ""; - - try { - const table = await createTable(baseId, { - name: tableName, - fields: [ - { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, - ], - records: [ - { fields: { [NAME_FIELD]: config.deletedRowName } }, - { fields: { [NAME_FIELD]: config.keptRowName } }, - ], - }); - tableId = table.id; - const deletedRowId = table.records?.[0]?.id; - if (!deletedRowId || !table.records?.[1]?.id) { - throw new Error(`Table ${tableId} is not in place`); - } - - // Fixture verification, outside the checkpoint: this table's trash is - // empty to begin with, so anything found afterwards came from the delete. - const before = await apiGetTrashItems({ - resourceId: tableId, - resourceType: ResourceType.Table, - }); - if ((before.data.trashItems ?? []).length !== 0) { - throw new Error( - `the table's trash already holds ${before.data.trashItems.length} entries - the fixture is not in place`, - ); - } - - const probe = await bugCheckpoint( - "a-deleted-row-is-in-the-trash", - async () => { - await apiDeleteRecords(tableId, [deletedRowId]); - - // The delete has to have happened, or "nothing is in the trash" would - // be the correct answer and a different report. - const after = await apiGetRecords(tableId, { - fieldKeyType: FieldKeyType.Name, - take: 5, - }); - const names = after.data.records.map( - (record: { fields: Record }) => - String(record.fields[NAME_FIELD]), - ); - if (names.includes(config.deletedRowName)) { - throw new Error( - `the row is still in the table after being deleted: [${names.join(", ")}]`, - ); - } - - // What goes into the trash is written after the delete answers. - let entries: { resourceIds?: string[] }[] = []; - for (let attempt = 0; attempt < config.settleAttempts; attempt += 1) { - const trash = await apiGetTrashItems({ - resourceId: tableId, - resourceType: ResourceType.Table, - }); - entries = (trash.data.trashItems ?? []) as { - resourceIds?: string[]; - }[]; - if (entries.length > 0) { - break; - } - await new Promise((resolve) => - setTimeout(resolve, config.settleIntervalMs), - ); - } - if (entries.length === 0) { - throw new Error( - `the row was deleted and the table's trash is empty after ${config.settleAttempts} tries - ` + - "the row is not recoverable and nobody can say when it went", - ); - } - const holdsRow = entries.some((entry) => - (entry.resourceIds ?? []).includes(deletedRowId), - ); - if (!holdsRow) { - throw new Error( - `the trash holds ${entries.length} entries and none of them is the deleted row: ${JSON.stringify(entries)}`, - ); - } - return { entries: entries.length }; - }, - ); - - return { - details: { - tableId, - deletedRowId, - trashEntries: probe.entries, - }, - }; - } finally { - if (tableId) { - try { - await permanentDeleteTable(baseId, tableId); - } catch (error) { - // Cleanup is the case's own housekeeping - the product did not fail. - console.warn( - `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - } - } -}; diff --git a/framework/types.ts b/framework/types.ts index 28dcea9..2da9d4a 100644 --- a/framework/types.ts +++ b/framework/types.ts @@ -114,7 +114,6 @@ export interface BugCaseConfigByRunner { "conditional-rollup-user-match": ConditionalRollupUserMatchCaseConfig; "weekday-start-day": WeekdayStartDayCaseConfig; "fromnow-unit": FromnowUnitCaseConfig; - "deleted-row-in-the-trash": DeletedRowInTheTrashCaseConfig; "formula-over-system-columns": FormulaOverSystemColumnsCaseConfig; "tracked-modified-sort": TrackedModifiedSortCaseConfig; "lookup-of-link-contains": LookupOfLinkContainsCaseConfig; @@ -1740,17 +1739,6 @@ export interface FormulaOverSystemColumnsCaseConfig { rowTitle: string; } -export interface DeletedRowInTheTrashCaseConfig { - baseId: "seed-base"; - tableNamePrefix: string; - deletedRowName: string; - // A row nobody deletes, so a delete that took the whole table and one that - // took the right row stay distinguishable. - keptRowName: string; - settleAttempts: number; - settleIntervalMs: number; -} - export interface FromnowUnitCaseConfig { baseId: "seed-base"; tableNamePrefix: string; diff --git a/registry.ts b/registry.ts index f746336..f27c569 100644 --- a/registry.ts +++ b/registry.ts @@ -20,7 +20,6 @@ import nestedFilterConjunctionCase from "./cases/filter/a-group-inside-a-group.c import conditionalRollupUserMatchCase from "./cases/lookup/hours-owned-by-anyone-on-this-row.case"; import weekdayStartDayCase from "./cases/formula/a-day-number-when-weeks-start-on-monday.case"; import fromnowUnitCase from "./cases/formula/how-long-ago-in-days.case"; -import deletedRowInTheTrashCase from "./cases/record/a-deleted-row-in-the-trash.case"; import formulaOverSystemColumnsCase from "./cases/formula/columns-worked-out-from-a-new-row.case"; import trackedModifiedSortCase from "./cases/view/sort-by-a-narrowed-last-changed-column.case"; import lookupOfLinkContainsCase from "./cases/filter/search-a-borrowed-link-column.case"; @@ -156,7 +155,6 @@ const cases = [ conditionalRollupUserMatchCase, weekdayStartDayCase, fromnowUnitCase, - deletedRowInTheTrashCase, formulaOverSystemColumnsCase, trackedModifiedSortCase, lookupOfLinkContainsCase, From be23a75a413af59c74bb8d3a38b834bac651c1c4 Mon Sep 17 00:00:00 2001 From: HynLcc Date: Wed, 26 Aug 2026 08:33:57 +0800 Subject: [PATCH 05/22] Compare the minute in a filter on a time of day T1611. The time was thrown away and only the day compared, so everything on that day landed on the same side of the line - and the rows that come back look right, because they are all from the day that was asked about. --- .../filter/a-filter-on-a-time-of-day.case.ts | 31 ++++ cases/filter/a-filter-on-a-time-of-day.md | 29 +++ framework/runner-registry.ts | 2 + .../date-filter-minute-precision.runner.ts | 171 ++++++++++++++++++ framework/types.ts | 11 ++ registry.ts | 2 + 6 files changed, 246 insertions(+) create mode 100644 cases/filter/a-filter-on-a-time-of-day.case.ts create mode 100644 cases/filter/a-filter-on-a-time-of-day.md create mode 100644 framework/runners/date-filter-minute-precision.runner.ts diff --git a/cases/filter/a-filter-on-a-time-of-day.case.ts b/cases/filter/a-filter-on-a-time-of-day.case.ts new file mode 100644 index 0000000..3fb9f43 --- /dev/null +++ b/cases/filter/a-filter-on-a-time-of-day.case.ts @@ -0,0 +1,31 @@ +import { defineBugCase } from "../../framework/types"; + +// T1611: a date column that shows the time is used for things that happen +// during a day - shifts, deliveries, calls. Filtering to "after 23:36" is the +// ordinary use of such a column, and the minute is the whole point. The time +// was thrown away and only the day compared, so everything on that day landed +// on the same side of the line - and the rows that come back look right, +// because they are all from the day that was asked about. +export default defineBugCase({ + id: "filter/a-filter-on-a-time-of-day", + title: "A filter on a time of day compares the minute", + runner: "date-filter-minute-precision", + timeoutMs: 180_000, + bug: { + issue: "T1611", + status: "fixed", + sourceCommits: ["e79583132"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-time-of-day-filter", + timeZone: "Asia/Singapore", + // 23:35, 23:37 and 23:38 local time on one day. + rows: [ + { name: "before-the-cutoff", at: "2026-01-08T15:35:00.000Z" }, + { name: "two-minutes-after", at: "2026-01-08T15:37:00.000Z" }, + { name: "three-minutes-after", at: "2026-01-08T15:38:00.000Z" }, + ], + after: "2026-01-08T15:36:00.000Z", + }, +}); diff --git a/cases/filter/a-filter-on-a-time-of-day.md b/cases/filter/a-filter-on-a-time-of-day.md new file mode 100644 index 0000000..f1c5b03 --- /dev/null +++ b/cases/filter/a-filter-on-a-time-of-day.md @@ -0,0 +1,29 @@ +# filter/a-filter-on-a-time-of-day + +**T1611** — fixed. + +## What the user sees + +A filter that returns the whole day when they asked for part of it. + +A date column that shows the time is used for things that happen during a day: +shifts, deliveries, calls. Filtering to "after 23:36" is the ordinary use of +such a column, and the minute is the whole point — a person picking that time +means it. + +The time was thrown away and only the day compared. Everything on that day +landed on the same side of the line, and the rows that come back look right, +because they are all from the day that was asked about. + +## What the checkpoint asserts + +Filtering to the rows after a particular minute returns exactly those rows. + +## What the fixture has to hold + +Unfiltered, every row is there — a table short of rows would make the filtered +answer unreadable. + +All the rows fall on one day and are minutes apart, with at least one on each +side of the cutoff. A filter comparing only the day cannot tell them apart, and +one comparing the minute has to. The runner refuses any other fixture. diff --git a/framework/runner-registry.ts b/framework/runner-registry.ts index 6e4f8e4..73353fb 100644 --- a/framework/runner-registry.ts +++ b/framework/runner-registry.ts @@ -95,6 +95,7 @@ import { runBooleanFormulaFilterCase } from "./runners/boolean-formula-filter.ru import { runDuplicateBaseRecentListCase } from "./runners/duplicate-base-recent-list.runner"; import { runLongtextMarkdownConvertCase } from "./runners/longtext-markdown-convert.runner"; import { runConditionalRollupUserMatchCase } from "./runners/conditional-rollup-user-match.runner"; +import { runDateFilterMinutePrecisionCase } from "./runners/date-filter-minute-precision.runner"; import { runFromnowUnitCase } from "./runners/fromnow-unit.runner"; import { runWeekdayStartDayCase } from "./runners/weekday-start-day.runner"; import { runFormulaOverSystemColumnsCase } from "./runners/formula-over-system-columns.runner"; @@ -234,6 +235,7 @@ const runners: { [K in BugRunnerKind]: RunnerFn } = { "conditional-rollup-user-match": runConditionalRollupUserMatchCase, "weekday-start-day": runWeekdayStartDayCase, "fromnow-unit": runFromnowUnitCase, + "date-filter-minute-precision": runDateFilterMinutePrecisionCase, "formula-over-system-columns": runFormulaOverSystemColumnsCase, "tracked-modified-sort": runTrackedModifiedSortCase, "lookup-of-link-contains": runLookupOfLinkContainsCase, diff --git a/framework/runners/date-filter-minute-precision.runner.ts b/framework/runners/date-filter-minute-precision.runner.ts new file mode 100644 index 0000000..8a57477 --- /dev/null +++ b/framework/runners/date-filter-minute-precision.runner.ts @@ -0,0 +1,171 @@ +import { + and, + DateFormattingPreset, + exactFormatDate, + FieldKeyType, + FieldType, + isAfter, + TimeFormatting, +} from "@teable/core"; +import { getRecords as apiGetRecords } from "@teable/openapi"; +import { createTable, permanentDeleteTable } from "../../../utils/init-app"; +import { bugCheckpoint } from "../checkpoint"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { DateFilterMinutePrecisionCaseConfig } from "../types"; + +// Rows a few minutes apart -> filter for the ones after a particular minute -> +// checkpoint: exactly those come back. +// +// A date column that shows the time is used for things that happen during a +// day: shifts, deliveries, calls. Filtering to "after 23:36" is the ordinary +// use of such a column, and the minute is the whole point - a person picking +// that time means it. +// +// The time was thrown away and only the day compared. Everything on the same +// day landed on the same side of the line, so the filter either kept rows it +// should have dropped or dropped the lot - and the rows it returns look right, +// because they are all from the day that was asked about. +// +// One row on each side of the minute, and the two are minutes apart: a filter +// that compares only the day cannot tell them apart, and one that compares the +// minute has to. + +const NAME_FIELD = "Name"; +const WHEN_FIELD = "When"; + +export const runDateFilterMinutePrecisionCase = async ( + bugCase: BugCaseFor<"date-filter-minute-precision">, + context: BugRunContext, +): Promise => { + const config: DateFilterMinutePrecisionCaseConfig = bugCase.config; + const baseId = globalThis.testConfig.baseId; + const tableName = `${config.tableNamePrefix}-${context.runId}`; + let tableId = ""; + + const cutoff = Date.parse(config.after); + const expectedNames = config.rows + .filter((row) => Date.parse(row.at) > cutoff) + .map((row) => row.name) + .sort(); + const droppedNames = config.rows + .filter((row) => Date.parse(row.at) <= cutoff) + .map((row) => row.name) + .sort(); + if (expectedNames.length === 0 || droppedNames.length === 0) { + throw new Error( + "one row on each side of the minute at least - otherwise a filter that keeps everything, or nothing, looks correct", + ); + } + const days = new Set(config.rows.map((row) => row.at.slice(0, 10))); + if (days.size !== 1) { + throw new Error( + `the rows fall on ${days.size} days - they have to share one, or comparing only the day would give the right answer`, + ); + } + + try { + const table = await createTable(baseId, { + name: tableName, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + { + name: WHEN_FIELD, + type: FieldType.Date, + options: { + formatting: { + date: DateFormattingPreset.ISO, + time: TimeFormatting.Hour24, + timeZone: config.timeZone, + }, + }, + }, + ], + records: config.rows.map((row) => ({ + fields: { [NAME_FIELD]: row.name, [WHEN_FIELD]: row.at }, + })), + }); + tableId = table.id; + const viewId = table.views?.[0]?.id; + const whenFieldId = table.fields.find( + (field: { name: string }) => field.name === WHEN_FIELD, + )?.id; + if (!viewId || !whenFieldId) { + throw new Error(`Table ${tableId} is not in place`); + } + + // Fixture verification, outside the checkpoint: unfiltered, every row is + // there. A table short of rows would make the filtered answer unreadable. + const all = await apiGetRecords(tableId, { + fieldKeyType: FieldKeyType.Name, + viewId, + take: config.rows.length, + }); + if (all.data.records.length !== config.rows.length) { + throw new Error( + `the table lists ${all.data.records.length} of ${config.rows.length} rows before any filter`, + ); + } + + const probe = await bugCheckpoint( + "a-time-of-day-filter-compares-the-minute", + async () => { + const filtered = await apiGetRecords(tableId, { + fieldKeyType: FieldKeyType.Name, + viewId, + take: config.rows.length, + filter: { + conjunction: and.value, + filterSet: [ + { + fieldId: whenFieldId, + operator: isAfter.value, + value: { + mode: exactFormatDate.value, + exactDate: config.after, + timeZone: config.timeZone, + }, + }, + ], + }, + }); + const found = filtered.data.records + .map((record: { fields: Record }) => + String(record.fields[NAME_FIELD]), + ) + .sort(); + if (found.join(" ") !== expectedNames.join(" ")) { + throw new Error( + `filtering to the rows after ${config.after} returned [${found.join(", ")}], expected [${expectedNames.join(", ")}] - ` + + (found.length === config.rows.length + ? "everything on that day came back, so only the day was compared and the minute was thrown away" + : "the rows are minutes apart and the filter did not separate them there"), + ); + } + return { found }; + }, + ); + + return { + details: { + tableId, + after: config.after, + timeZone: config.timeZone, + found: probe.found, + dropped: droppedNames, + }, + }; + } finally { + if (tableId) { + try { + await permanentDeleteTable(baseId, tableId); + } catch (error) { + // Cleanup is the case's own housekeeping - the product did not fail. + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/types.ts b/framework/types.ts index 2da9d4a..e966250 100644 --- a/framework/types.ts +++ b/framework/types.ts @@ -114,6 +114,7 @@ export interface BugCaseConfigByRunner { "conditional-rollup-user-match": ConditionalRollupUserMatchCaseConfig; "weekday-start-day": WeekdayStartDayCaseConfig; "fromnow-unit": FromnowUnitCaseConfig; + "date-filter-minute-precision": DateFilterMinutePrecisionCaseConfig; "formula-over-system-columns": FormulaOverSystemColumnsCaseConfig; "tracked-modified-sort": TrackedModifiedSortCaseConfig; "lookup-of-link-contains": LookupOfLinkContainsCaseConfig; @@ -1739,6 +1740,16 @@ export interface FormulaOverSystemColumnsCaseConfig { rowTitle: string; } +export interface DateFilterMinutePrecisionCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + // Rows minutes apart on one day, with at least one on each side of the + // cutoff - the runner refuses anything else. + rows: { name: string; at: string }[]; + after: string; + timeZone: string; +} + export interface FromnowUnitCaseConfig { baseId: "seed-base"; tableNamePrefix: string; diff --git a/registry.ts b/registry.ts index f27c569..296f987 100644 --- a/registry.ts +++ b/registry.ts @@ -20,6 +20,7 @@ import nestedFilterConjunctionCase from "./cases/filter/a-group-inside-a-group.c import conditionalRollupUserMatchCase from "./cases/lookup/hours-owned-by-anyone-on-this-row.case"; import weekdayStartDayCase from "./cases/formula/a-day-number-when-weeks-start-on-monday.case"; import fromnowUnitCase from "./cases/formula/how-long-ago-in-days.case"; +import dateFilterMinutePrecisionCase from "./cases/filter/a-filter-on-a-time-of-day.case"; import formulaOverSystemColumnsCase from "./cases/formula/columns-worked-out-from-a-new-row.case"; import trackedModifiedSortCase from "./cases/view/sort-by-a-narrowed-last-changed-column.case"; import lookupOfLinkContainsCase from "./cases/filter/search-a-borrowed-link-column.case"; @@ -155,6 +156,7 @@ const cases = [ conditionalRollupUserMatchCase, weekdayStartDayCase, fromnowUnitCase, + dateFilterMinutePrecisionCase, formulaOverSystemColumnsCase, trackedModifiedSortCase, lookupOfLinkContainsCase, From d94e27a94e7b71fac9f246d1cbcd9c746f25138a Mon Sep 17 00:00:00 2001 From: HynLcc Date: Wed, 26 Aug 2026 08:51:45 +0800 Subject: [PATCH 06/22] Prove the filter is asked the way the product expects it Develop answered the real filter with no rows at all (run 32915607313) while the parent answered with everything - an empty answer needs a control before it can be read as the product's. The same filter with a cutoff before every row now has to return every row. --- cases/filter/a-filter-on-a-time-of-day.md | 6 ++++ .../date-filter-minute-precision.runner.ts | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/cases/filter/a-filter-on-a-time-of-day.md b/cases/filter/a-filter-on-a-time-of-day.md index f1c5b03..e7e6fbe 100644 --- a/cases/filter/a-filter-on-a-time-of-day.md +++ b/cases/filter/a-filter-on-a-time-of-day.md @@ -24,6 +24,12 @@ Filtering to the rows after a particular minute returns exactly those rows. Unfiltered, every row is there — a table short of rows would make the filtered answer unreadable. +A control first: the same filter written the same way, with a cutoff before +every row, returns every row. Without it an empty answer could be this case +asking the question wrongly rather than the product answering it wrongly — and +develop did answer with nothing before the control was added (run +32915607313). + All the rows fall on one day and are minutes apart, with at least one on each side of the cutoff. A filter comparing only the day cannot tell them apart, and one comparing the minute has to. The runner refuses any other fixture. diff --git a/framework/runners/date-filter-minute-precision.runner.ts b/framework/runners/date-filter-minute-precision.runner.ts index 8a57477..1931735 100644 --- a/framework/runners/date-filter-minute-precision.runner.ts +++ b/framework/runners/date-filter-minute-precision.runner.ts @@ -106,6 +106,39 @@ export const runDateFilterMinutePrecisionCase = async ( ); } + // Control, still outside the checkpoint: the same filter written the same + // way, with a cutoff before every row, has to return every row. Without it + // an empty answer below could be this case asking the question wrongly + // rather than the product answering it wrongly. + const controlAfter = new Date( + Math.min(...config.rows.map((row) => Date.parse(row.at))) - 60_000, + ).toISOString(); + const control = await apiGetRecords(tableId, { + fieldKeyType: FieldKeyType.Name, + viewId, + take: config.rows.length, + filter: { + conjunction: and.value, + filterSet: [ + { + fieldId: whenFieldId, + operator: isAfter.value, + value: { + mode: exactFormatDate.value, + exactDate: controlAfter, + timeZone: config.timeZone, + }, + }, + ], + }, + }); + if (control.data.records.length !== config.rows.length) { + throw new Error( + `asked for the rows after ${controlAfter}, which is before all of them, the filter returned ` + + `${control.data.records.length} of ${config.rows.length} - this case is not asking the question the way the product expects it`, + ); + } + const probe = await bugCheckpoint( "a-time-of-day-filter-compares-the-minute", async () => { From 94f0cd3f69f64f93c9ab39344016602ea30da8ac Mon Sep 17 00:00:00 2001 From: HynLcc Date: Wed, 26 Aug 2026 09:08:53 +0800 Subject: [PATCH 07/22] Set the filter in the toolbar and look at the table Passing the filter alongside the read is answered with nothing on develop and with everything on the parent (runs 32915607313, 32916759955) - neither is the product filtering. The case now saves the filter on the view and reads the view, which is how a person uses one. --- cases/filter/a-filter-on-a-time-of-day.md | 14 ++- .../date-filter-minute-precision.runner.ts | 99 +++++++++---------- 2 files changed, 57 insertions(+), 56 deletions(-) diff --git a/cases/filter/a-filter-on-a-time-of-day.md b/cases/filter/a-filter-on-a-time-of-day.md index e7e6fbe..f1ae0de 100644 --- a/cases/filter/a-filter-on-a-time-of-day.md +++ b/cases/filter/a-filter-on-a-time-of-day.md @@ -24,11 +24,15 @@ Filtering to the rows after a particular minute returns exactly those rows. Unfiltered, every row is there — a table short of rows would make the filtered answer unreadable. -A control first: the same filter written the same way, with a cutoff before -every row, returns every row. Without it an empty answer could be this case -asking the question wrongly rather than the product answering it wrongly — and -develop did answer with nothing before the control was added (run -32915607313). +A control first: the same filter with a cutoff before every row keeps every +row. Without it an empty answer could be this case asking the question wrongly +rather than the product answering it wrongly. + +That control is what settled how to ask. Passing the filter alongside the read +is answered with nothing at all on develop and with everything on the fix's +parent — neither of which is the product filtering — so the case saves the +filter on the view and then reads the view, which is what a person does: +set it in the toolbar and look at the table. Measured over runs 32915607313 and 32916759955. All the rows fall on one day and are minutes apart, with at least one on each side of the cutoff. A filter comparing only the day cannot tell them apart, and diff --git a/framework/runners/date-filter-minute-precision.runner.ts b/framework/runners/date-filter-minute-precision.runner.ts index 1931735..985ab67 100644 --- a/framework/runners/date-filter-minute-precision.runner.ts +++ b/framework/runners/date-filter-minute-precision.runner.ts @@ -7,7 +7,10 @@ import { isAfter, TimeFormatting, } from "@teable/core"; -import { getRecords as apiGetRecords } from "@teable/openapi"; +import { + getRecords as apiGetRecords, + updateViewFilter as apiUpdateViewFilter, +} from "@teable/openapi"; import { createTable, permanentDeleteTable } from "../../../utils/init-app"; import { bugCheckpoint } from "../checkpoint"; import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; @@ -106,66 +109,60 @@ export const runDateFilterMinutePrecisionCase = async ( ); } - // Control, still outside the checkpoint: the same filter written the same - // way, with a cutoff before every row, has to return every row. Without it - // an empty answer below could be this case asking the question wrongly - // rather than the product answering it wrongly. + // The filter is saved on the view and the view is then read, which is how + // a person uses one: they set it in the toolbar and look at the table. + // Passing it alongside the read instead is answered with nothing at all on + // develop and with everything on the fix's parent - neither of which is + // the product filtering - see run 32916759955. + const filterFor = (exactDate: string) => ({ + conjunction: and.value, + filterSet: [ + { + fieldId: whenFieldId, + operator: isAfter.value, + value: { + mode: exactFormatDate.value, + exactDate, + timeZone: config.timeZone, + }, + }, + ], + }); + const namesAfter = async (exactDate: string) => { + await apiUpdateViewFilter(tableId, viewId, { + filter: filterFor(exactDate), + }); + const read = await apiGetRecords(tableId, { + fieldKeyType: FieldKeyType.Name, + viewId, + take: config.rows.length, + }); + return read.data.records + .map((record: { fields: Record }) => + String(record.fields[NAME_FIELD]), + ) + .sort(); + }; + + // Control, still outside the checkpoint: the same filter with a cutoff + // before every row has to keep every row. Without it an empty answer below + // could be this case asking the question wrongly rather than the product + // answering it wrongly. const controlAfter = new Date( Math.min(...config.rows.map((row) => Date.parse(row.at))) - 60_000, ).toISOString(); - const control = await apiGetRecords(tableId, { - fieldKeyType: FieldKeyType.Name, - viewId, - take: config.rows.length, - filter: { - conjunction: and.value, - filterSet: [ - { - fieldId: whenFieldId, - operator: isAfter.value, - value: { - mode: exactFormatDate.value, - exactDate: controlAfter, - timeZone: config.timeZone, - }, - }, - ], - }, - }); - if (control.data.records.length !== config.rows.length) { + const control = await namesAfter(controlAfter); + if (control.length !== config.rows.length) { throw new Error( - `asked for the rows after ${controlAfter}, which is before all of them, the filter returned ` + - `${control.data.records.length} of ${config.rows.length} - this case is not asking the question the way the product expects it`, + `asked for the rows after ${controlAfter}, which is before all of them, the view shows ` + + `${control.length} of ${config.rows.length} - this case is not asking the question the way the product expects it`, ); } const probe = await bugCheckpoint( "a-time-of-day-filter-compares-the-minute", async () => { - const filtered = await apiGetRecords(tableId, { - fieldKeyType: FieldKeyType.Name, - viewId, - take: config.rows.length, - filter: { - conjunction: and.value, - filterSet: [ - { - fieldId: whenFieldId, - operator: isAfter.value, - value: { - mode: exactFormatDate.value, - exactDate: config.after, - timeZone: config.timeZone, - }, - }, - ], - }, - }); - const found = filtered.data.records - .map((record: { fields: Record }) => - String(record.fields[NAME_FIELD]), - ) - .sort(); + const found = await namesAfter(config.after); if (found.join(" ") !== expectedNames.join(" ")) { throw new Error( `filtering to the rows after ${config.after} returned [${found.join(", ")}], expected [${expectedNames.join(", ")}] - ` + From 3b51ee6a3b5bd78c8cd13e502d13fb41b481fa94 Mon Sep 17 00:00:00 2001 From: HynLcc Date: Wed, 26 Aug 2026 09:24:06 +0800 Subject: [PATCH 08/22] Settle the time-of-day filter rejection T1611. Two shapes, three runs, and the control never passed: the case never established the product's own way of asking, so nothing about the product was measured. The shape is gone; the runner is not kept. --- .../filter/a-filter-on-a-time-of-day.case.ts | 31 --- cases/filter/a-filter-on-a-time-of-day.md | 39 ---- docs/triage-ledger.md | 1 + framework/runner-registry.ts | 2 - .../date-filter-minute-precision.runner.ts | 201 ------------------ framework/types.ts | 11 - registry.ts | 2 - 7 files changed, 1 insertion(+), 286 deletions(-) delete mode 100644 cases/filter/a-filter-on-a-time-of-day.case.ts delete mode 100644 cases/filter/a-filter-on-a-time-of-day.md delete mode 100644 framework/runners/date-filter-minute-precision.runner.ts diff --git a/cases/filter/a-filter-on-a-time-of-day.case.ts b/cases/filter/a-filter-on-a-time-of-day.case.ts deleted file mode 100644 index 3fb9f43..0000000 --- a/cases/filter/a-filter-on-a-time-of-day.case.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { defineBugCase } from "../../framework/types"; - -// T1611: a date column that shows the time is used for things that happen -// during a day - shifts, deliveries, calls. Filtering to "after 23:36" is the -// ordinary use of such a column, and the minute is the whole point. The time -// was thrown away and only the day compared, so everything on that day landed -// on the same side of the line - and the rows that come back look right, -// because they are all from the day that was asked about. -export default defineBugCase({ - id: "filter/a-filter-on-a-time-of-day", - title: "A filter on a time of day compares the minute", - runner: "date-filter-minute-precision", - timeoutMs: 180_000, - bug: { - issue: "T1611", - status: "fixed", - sourceCommits: ["e79583132"], - }, - config: { - baseId: "seed-base", - tableNamePrefix: "e2e-lab-time-of-day-filter", - timeZone: "Asia/Singapore", - // 23:35, 23:37 and 23:38 local time on one day. - rows: [ - { name: "before-the-cutoff", at: "2026-01-08T15:35:00.000Z" }, - { name: "two-minutes-after", at: "2026-01-08T15:37:00.000Z" }, - { name: "three-minutes-after", at: "2026-01-08T15:38:00.000Z" }, - ], - after: "2026-01-08T15:36:00.000Z", - }, -}); diff --git a/cases/filter/a-filter-on-a-time-of-day.md b/cases/filter/a-filter-on-a-time-of-day.md deleted file mode 100644 index f1ae0de..0000000 --- a/cases/filter/a-filter-on-a-time-of-day.md +++ /dev/null @@ -1,39 +0,0 @@ -# filter/a-filter-on-a-time-of-day - -**T1611** — fixed. - -## What the user sees - -A filter that returns the whole day when they asked for part of it. - -A date column that shows the time is used for things that happen during a day: -shifts, deliveries, calls. Filtering to "after 23:36" is the ordinary use of -such a column, and the minute is the whole point — a person picking that time -means it. - -The time was thrown away and only the day compared. Everything on that day -landed on the same side of the line, and the rows that come back look right, -because they are all from the day that was asked about. - -## What the checkpoint asserts - -Filtering to the rows after a particular minute returns exactly those rows. - -## What the fixture has to hold - -Unfiltered, every row is there — a table short of rows would make the filtered -answer unreadable. - -A control first: the same filter with a cutoff before every row keeps every -row. Without it an empty answer could be this case asking the question wrongly -rather than the product answering it wrongly. - -That control is what settled how to ask. Passing the filter alongside the read -is answered with nothing at all on develop and with everything on the fix's -parent — neither of which is the product filtering — so the case saves the -filter on the view and then reads the view, which is what a person does: -set it in the toolbar and look at the table. Measured over runs 32915607313 and 32916759955. - -All the rows fall on one day and are minutes apart, with at least one on each -side of the cutoff. A filter comparing only the day cannot tell them apart, and -one comparing the minute has to. The runner refuses any other fixture. diff --git a/docs/triage-ledger.md b/docs/triage-ledger.md index 654f20b..fa214de 100644 --- a/docs/triage-ledger.md +++ b/docs/triage-ledger.md @@ -132,6 +132,7 @@ The shape is gone; the runner is not kept. | `56fe8df36` | T4864 | Written in two shapes and run twice, red on **both** columns each time. Asked through the ordinary record endpoint with the link column's view, the product names nothing as linked on develop either (run 32901128869). Asked through a shared view of the host table, both columns refuse the query outright and with different messages - develop says the field is not found, the parent that it is not linked to the current table (run 32902812166) - so the selected-rows question is not addressed to the table this case addressed it to. Which read path the fix's `getViewRecords` belongs to, and what a client sends it, would have to be settled before a third attempt. | | `301a8ea59` | T1516 | Written and run, green on both columns (run 32906244128): a many-to-many row whose link cell was blanked with SQL while the pairing record stayed deletes cleanly on the fix's parent. The constraint the commit repairs is on the pairing record itself, and blanking the cell on one side is evidently not the state that trips it - which side's records to leave inconsistent would have to be settled first. | | `4e1be01f7` | T1980 | Written and run, green on both columns (run 32914315455): a deleted row lands in the table's trash on the fix's parent. The commit adds the trash projection to the **v2** delete path and its own reproduction turns v2 on with a canary setting; at a parent this old the lab's delete evidently does not take that path, so the case watched the one that always wrote. | +| `e79583132` | T1611 | Written in two shapes and run three times, and the case never established the product's own way of asking, so nothing about the product was measured. Passing an `isAfter` / `exactFormatDate` filter alongside the read returned every row on the fix's parent and none on develop (run 32915607313); a control with a cutoff before every row then returned none on develop too (run 32916759955); saving the same filter on the view and reading the view returned none on **both** columns for that control (run 32917872177). What a client actually sends for an exact-time comparison has to be settled before a fourth attempt - the commit's own reproduction goes through a `getFilterRecord` helper this repository does not have. | | `93d97c3ba` | T5268 | Written and run: the by-id paste endpoint the case needs does not exist on the fix's parent - `urlBuilder` was handed an undefined template and the column errored (run 32888946585) - while develop pastes a blank first line correctly. The fix introduces the path it repairs, so there is no before to compare against through the public API. | | `0548611b2` | T6576 | Not attempted. The commit's own reproduction is skipped under forced v2 - the spec gates it on the v1 path - and the lab forces v2, so the case could not go red. Same reason as the T5496 and T3303 rows. | | `7cb4431e9` | T6502 | Not attempted, same reason: the commit covers the shape with a forced-v1 e2e, and the lab forces v2. | diff --git a/framework/runner-registry.ts b/framework/runner-registry.ts index 73353fb..6e4f8e4 100644 --- a/framework/runner-registry.ts +++ b/framework/runner-registry.ts @@ -95,7 +95,6 @@ import { runBooleanFormulaFilterCase } from "./runners/boolean-formula-filter.ru import { runDuplicateBaseRecentListCase } from "./runners/duplicate-base-recent-list.runner"; import { runLongtextMarkdownConvertCase } from "./runners/longtext-markdown-convert.runner"; import { runConditionalRollupUserMatchCase } from "./runners/conditional-rollup-user-match.runner"; -import { runDateFilterMinutePrecisionCase } from "./runners/date-filter-minute-precision.runner"; import { runFromnowUnitCase } from "./runners/fromnow-unit.runner"; import { runWeekdayStartDayCase } from "./runners/weekday-start-day.runner"; import { runFormulaOverSystemColumnsCase } from "./runners/formula-over-system-columns.runner"; @@ -235,7 +234,6 @@ const runners: { [K in BugRunnerKind]: RunnerFn } = { "conditional-rollup-user-match": runConditionalRollupUserMatchCase, "weekday-start-day": runWeekdayStartDayCase, "fromnow-unit": runFromnowUnitCase, - "date-filter-minute-precision": runDateFilterMinutePrecisionCase, "formula-over-system-columns": runFormulaOverSystemColumnsCase, "tracked-modified-sort": runTrackedModifiedSortCase, "lookup-of-link-contains": runLookupOfLinkContainsCase, diff --git a/framework/runners/date-filter-minute-precision.runner.ts b/framework/runners/date-filter-minute-precision.runner.ts deleted file mode 100644 index 985ab67..0000000 --- a/framework/runners/date-filter-minute-precision.runner.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { - and, - DateFormattingPreset, - exactFormatDate, - FieldKeyType, - FieldType, - isAfter, - TimeFormatting, -} from "@teable/core"; -import { - getRecords as apiGetRecords, - updateViewFilter as apiUpdateViewFilter, -} from "@teable/openapi"; -import { createTable, permanentDeleteTable } from "../../../utils/init-app"; -import { bugCheckpoint } from "../checkpoint"; -import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; -import type { DateFilterMinutePrecisionCaseConfig } from "../types"; - -// Rows a few minutes apart -> filter for the ones after a particular minute -> -// checkpoint: exactly those come back. -// -// A date column that shows the time is used for things that happen during a -// day: shifts, deliveries, calls. Filtering to "after 23:36" is the ordinary -// use of such a column, and the minute is the whole point - a person picking -// that time means it. -// -// The time was thrown away and only the day compared. Everything on the same -// day landed on the same side of the line, so the filter either kept rows it -// should have dropped or dropped the lot - and the rows it returns look right, -// because they are all from the day that was asked about. -// -// One row on each side of the minute, and the two are minutes apart: a filter -// that compares only the day cannot tell them apart, and one that compares the -// minute has to. - -const NAME_FIELD = "Name"; -const WHEN_FIELD = "When"; - -export const runDateFilterMinutePrecisionCase = async ( - bugCase: BugCaseFor<"date-filter-minute-precision">, - context: BugRunContext, -): Promise => { - const config: DateFilterMinutePrecisionCaseConfig = bugCase.config; - const baseId = globalThis.testConfig.baseId; - const tableName = `${config.tableNamePrefix}-${context.runId}`; - let tableId = ""; - - const cutoff = Date.parse(config.after); - const expectedNames = config.rows - .filter((row) => Date.parse(row.at) > cutoff) - .map((row) => row.name) - .sort(); - const droppedNames = config.rows - .filter((row) => Date.parse(row.at) <= cutoff) - .map((row) => row.name) - .sort(); - if (expectedNames.length === 0 || droppedNames.length === 0) { - throw new Error( - "one row on each side of the minute at least - otherwise a filter that keeps everything, or nothing, looks correct", - ); - } - const days = new Set(config.rows.map((row) => row.at.slice(0, 10))); - if (days.size !== 1) { - throw new Error( - `the rows fall on ${days.size} days - they have to share one, or comparing only the day would give the right answer`, - ); - } - - try { - const table = await createTable(baseId, { - name: tableName, - fields: [ - { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, - { - name: WHEN_FIELD, - type: FieldType.Date, - options: { - formatting: { - date: DateFormattingPreset.ISO, - time: TimeFormatting.Hour24, - timeZone: config.timeZone, - }, - }, - }, - ], - records: config.rows.map((row) => ({ - fields: { [NAME_FIELD]: row.name, [WHEN_FIELD]: row.at }, - })), - }); - tableId = table.id; - const viewId = table.views?.[0]?.id; - const whenFieldId = table.fields.find( - (field: { name: string }) => field.name === WHEN_FIELD, - )?.id; - if (!viewId || !whenFieldId) { - throw new Error(`Table ${tableId} is not in place`); - } - - // Fixture verification, outside the checkpoint: unfiltered, every row is - // there. A table short of rows would make the filtered answer unreadable. - const all = await apiGetRecords(tableId, { - fieldKeyType: FieldKeyType.Name, - viewId, - take: config.rows.length, - }); - if (all.data.records.length !== config.rows.length) { - throw new Error( - `the table lists ${all.data.records.length} of ${config.rows.length} rows before any filter`, - ); - } - - // The filter is saved on the view and the view is then read, which is how - // a person uses one: they set it in the toolbar and look at the table. - // Passing it alongside the read instead is answered with nothing at all on - // develop and with everything on the fix's parent - neither of which is - // the product filtering - see run 32916759955. - const filterFor = (exactDate: string) => ({ - conjunction: and.value, - filterSet: [ - { - fieldId: whenFieldId, - operator: isAfter.value, - value: { - mode: exactFormatDate.value, - exactDate, - timeZone: config.timeZone, - }, - }, - ], - }); - const namesAfter = async (exactDate: string) => { - await apiUpdateViewFilter(tableId, viewId, { - filter: filterFor(exactDate), - }); - const read = await apiGetRecords(tableId, { - fieldKeyType: FieldKeyType.Name, - viewId, - take: config.rows.length, - }); - return read.data.records - .map((record: { fields: Record }) => - String(record.fields[NAME_FIELD]), - ) - .sort(); - }; - - // Control, still outside the checkpoint: the same filter with a cutoff - // before every row has to keep every row. Without it an empty answer below - // could be this case asking the question wrongly rather than the product - // answering it wrongly. - const controlAfter = new Date( - Math.min(...config.rows.map((row) => Date.parse(row.at))) - 60_000, - ).toISOString(); - const control = await namesAfter(controlAfter); - if (control.length !== config.rows.length) { - throw new Error( - `asked for the rows after ${controlAfter}, which is before all of them, the view shows ` + - `${control.length} of ${config.rows.length} - this case is not asking the question the way the product expects it`, - ); - } - - const probe = await bugCheckpoint( - "a-time-of-day-filter-compares-the-minute", - async () => { - const found = await namesAfter(config.after); - if (found.join(" ") !== expectedNames.join(" ")) { - throw new Error( - `filtering to the rows after ${config.after} returned [${found.join(", ")}], expected [${expectedNames.join(", ")}] - ` + - (found.length === config.rows.length - ? "everything on that day came back, so only the day was compared and the minute was thrown away" - : "the rows are minutes apart and the filter did not separate them there"), - ); - } - return { found }; - }, - ); - - return { - details: { - tableId, - after: config.after, - timeZone: config.timeZone, - found: probe.found, - dropped: droppedNames, - }, - }; - } finally { - if (tableId) { - try { - await permanentDeleteTable(baseId, tableId); - } catch (error) { - // Cleanup is the case's own housekeeping - the product did not fail. - console.warn( - `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - } - } -}; diff --git a/framework/types.ts b/framework/types.ts index e966250..2da9d4a 100644 --- a/framework/types.ts +++ b/framework/types.ts @@ -114,7 +114,6 @@ export interface BugCaseConfigByRunner { "conditional-rollup-user-match": ConditionalRollupUserMatchCaseConfig; "weekday-start-day": WeekdayStartDayCaseConfig; "fromnow-unit": FromnowUnitCaseConfig; - "date-filter-minute-precision": DateFilterMinutePrecisionCaseConfig; "formula-over-system-columns": FormulaOverSystemColumnsCaseConfig; "tracked-modified-sort": TrackedModifiedSortCaseConfig; "lookup-of-link-contains": LookupOfLinkContainsCaseConfig; @@ -1740,16 +1739,6 @@ export interface FormulaOverSystemColumnsCaseConfig { rowTitle: string; } -export interface DateFilterMinutePrecisionCaseConfig { - baseId: "seed-base"; - tableNamePrefix: string; - // Rows minutes apart on one day, with at least one on each side of the - // cutoff - the runner refuses anything else. - rows: { name: string; at: string }[]; - after: string; - timeZone: string; -} - export interface FromnowUnitCaseConfig { baseId: "seed-base"; tableNamePrefix: string; diff --git a/registry.ts b/registry.ts index 296f987..f27c569 100644 --- a/registry.ts +++ b/registry.ts @@ -20,7 +20,6 @@ import nestedFilterConjunctionCase from "./cases/filter/a-group-inside-a-group.c import conditionalRollupUserMatchCase from "./cases/lookup/hours-owned-by-anyone-on-this-row.case"; import weekdayStartDayCase from "./cases/formula/a-day-number-when-weeks-start-on-monday.case"; import fromnowUnitCase from "./cases/formula/how-long-ago-in-days.case"; -import dateFilterMinutePrecisionCase from "./cases/filter/a-filter-on-a-time-of-day.case"; import formulaOverSystemColumnsCase from "./cases/formula/columns-worked-out-from-a-new-row.case"; import trackedModifiedSortCase from "./cases/view/sort-by-a-narrowed-last-changed-column.case"; import lookupOfLinkContainsCase from "./cases/filter/search-a-borrowed-link-column.case"; @@ -156,7 +155,6 @@ const cases = [ conditionalRollupUserMatchCase, weekdayStartDayCase, fromnowUnitCase, - dateFilterMinutePrecisionCase, formulaOverSystemColumnsCase, trackedModifiedSortCase, lookupOfLinkContainsCase, From 7b0180e70c8ed61c4efb25e4eaed8eb6e44bc23a Mon Sep 17 00:00:00 2001 From: HynLcc Date: Thu, 27 Aug 2026 16:48:12 +0800 Subject: [PATCH 09/22] Ask every case of v1 too, and let v1 answer for nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2 is what this lab guards: fixes land there, and a bug returning there is a regression someone must act on. v1 is asked the same cases as a reference — what does the engine our older customers are still on do with this — and nothing it reports fails a run. Reaching v1 takes more than the environment switch, which is the part that looks done and is not. Routing asks FORCE_V2_ALL first and the base's own v2 flag second, and the product stamps every base it creates as v2, so turning the switch off falls through to the second rule and still answers v2. Measured over 129 cases: with the switch off, not one observation differed from the v2 baseline, and the response header said why — reason `new_base` instead of `env_force_v2_all`. So the base is unstamped before the runner touches it. What that cannot do is make a base that was BORN on v1, which is what real v1 customers have. That gap is why v1 gates nothing, and why the column is worth having anyway: it is evidence to follow up, not a verdict. The engine is read live rather than captured at import — one process runs both blocks, and a constant would pin the second to the first engine and report its answers under the other's name. The v2 routing assertion is unchanged and still absolute; the v1 branch is its mirror, not a relaxation, because a v1 run answered by v2 is a fabricated column and throws just as hard. --- e2e-lab.e2e-spec.ts | 136 ++++++++++++++++++++++++++------------ framework/artifacts.ts | 14 +++- framework/case-base.ts | 25 +++++++ framework/engine.test.js | 66 +++++++++++++++++- framework/engine.ts | 75 ++++++++++++++++----- framework/run-bug-case.ts | 21 +++--- framework/types.ts | 14 ++++ framework/verdict.test.js | 21 ++++++ framework/verdict.ts | 19 +++++- 9 files changed, 315 insertions(+), 76 deletions(-) diff --git a/e2e-lab.e2e-spec.ts b/e2e-lab.e2e-spec.ts index ac89d15..2329281 100644 --- a/e2e-lab.e2e-spec.ts +++ b/e2e-lab.e2e-spec.ts @@ -2,22 +2,24 @@ import type { INestApplication } from "@nestjs/common"; import { performance } from "node:perf_hooks"; import { initApp } from "../utils/init-app"; import { getBugCase, resolveBugCaseIds } from "./registry"; -import { applyEngineRuntimeEnv, LAB_ENGINE } from "./framework/engine"; +import { applyEngineRuntimeEnv, type LabEngine } from "./framework/engine"; import { runBugCase } from "./framework/run-bug-case"; -// Before the app boots: pin the engine every case here guards. teable-ee is -// migrating to v2 and v1 bugs are not being fixed, so there is one engine, not -// a choice. See framework/engine.ts. -applyEngineRuntimeEnv(); - // The single executable entry point, in the perf-lab mold: this file is copied // into teable-ee/community/apps/nestjs-backend/test/e2e-lab/ and run through // teable-ee's own vitest e2e setup, so auth bootstrap, seed user, and Nest app // startup stay aligned with the harness the product already maintains. // -// One app, every selected case in registry order. No engine loop and no -// seed/execute mode split — bug fixtures are built and torn down inside each -// case, and the revision under test is whatever this checkout is. +// One app PER ENGINE, every selected case in registry order under each. v2 is +// the engine this lab guards — fixes land there and a returning bug is a +// regression. v1 is a reference column: it is run to answer "what does the +// engine our older customers are still on do with this?", it is recorded, and +// it never fails a run. See framework/verdict.ts. +// +// Two things a reader will look for and should find here rather than guess: +// reaching v1 takes more than an environment switch (framework/case-base.ts +// unstamps each case's base), and a case whose feature does not exist on v1 +// declares `skipV1` rather than being discovered as a failure every run. // // Cases overlap, a few at a time. They used to run strictly one after another // because they shared a base and could not be trusted not to disturb each @@ -43,53 +45,99 @@ const logPhase = ( ); }; +// Which engines this run asks for. Both by default: v1 costs one extra app +// boot and one extra pass, and a reference column nobody runs is not a +// reference. A single-engine list is how a local direction-finding run keeps +// its turnaround short. +const parseEngineList = (raw = "v1,v2"): LabEngine[] => { + const engines = raw + .split(",") + .map((engine) => engine.trim()) + .filter(Boolean); + const unsupported = engines.filter( + (engine) => engine !== "v1" && engine !== "v2", + ); + if (unsupported.length > 0) { + throw new Error( + `Unsupported E2E_LAB_ENGINE_LIST: ${unsupported.join(", ")}. Available: v1, v2.`, + ); + } + if (engines.length === 0) { + throw new Error("E2E_LAB_ENGINE_LIST must name at least one engine"); + } + // v1 first, v2 last: the guarded engine is the one a reader should see at + // the bottom of the log, next to the exit code only it can turn red. + const unique = new Set(engines as LabEngine[]); + return (["v1", "v2"] as LabEngine[]).filter((engine) => unique.has(engine)); +}; + describe("e2e-lab bug regression runner (e2e)", () => { const caseIds = resolveBugCaseIds(process.env.E2E_LAB_CASE_FILTER ?? "all"); const bugCases = caseIds.map(getBugCase); + const engines = parseEngineList(process.env.E2E_LAB_ENGINE_LIST); logPhase("module-loaded", { cases: caseIds.join(","), commitSha: process.env.E2E_LAB_COMMIT_SHA ?? "(local)", - engine: LAB_ENGINE, + engines: engines.join(","), }); - let app: INestApplication; - let appUrl: string; - let cookie: string | undefined; + for (const engine of engines) { + describe(`engine ${engine}`, () => { + let app: INestApplication; + let appUrl: string; + let cookie: string | undefined; - beforeAll(async () => { - const initStarted = performance.now(); - const appCtx = await initApp(); - app = appCtx.app; - appUrl = appCtx.appUrl; - cookie = appCtx.cookie; - logPhase("app-ready", { - initAppMs: Math.round(performance.now() - initStarted), - appUrl, - }); - }); + beforeAll(async () => { + // Set before the app boots and left set for the whole block: every + // helper reads the engine live, so this assignment is what makes the + // block mean what its name says. + process.env.E2E_LAB_ENGINE = engine; + applyEngineRuntimeEnv(engine); + const initStarted = performance.now(); + const appCtx = await initApp(); + app = appCtx.app; + appUrl = appCtx.appUrl; + cookie = appCtx.cookie; + logPhase("app-ready", { + engine, + initAppMs: Math.round(performance.now() - initStarted), + appUrl, + }); + }); - afterAll(async () => { - const closeStarted = performance.now(); - await app?.close(); - logPhase("app-closed", { - closeMs: Math.round(performance.now() - closeStarted), - }); - }); + afterAll(async () => { + const closeStarted = performance.now(); + await app?.close(); + logPhase("app-closed", { + engine, + closeMs: Math.round(performance.now() - closeStarted), + }); + }); - for (const bugCase of bugCases) { - it.concurrent( - `observes ${bugCase.id} [${bugCase.bug.issue}]`, - { timeout: bugCase.timeoutMs }, - async () => { - logPhase("case:start", { caseId: bugCase.id }); - const caseStarted = performance.now(); - await runBugCase(bugCase, { app, appUrl, cookie }); - logPhase("case:done", { - caseId: bugCase.id, - caseMs: Math.round(performance.now() - caseStarted), + for (const bugCase of bugCases) { + const skipReason = engine === "v1" ? bugCase.skipV1 : undefined; + const title = `observes ${bugCase.id} [${bugCase.bug.issue}] (${engine})`; + + if (skipReason) { + // Skipped out loud. A case that silently vanished from one engine + // would leave a gap the report cannot tell from a lost payload, and + // the reason is the part a reader needs six months from now. + it.skip(`${title} — skipped on v1: ${skipReason}`, () => {}); + continue; + } + + it.concurrent(title, { timeout: bugCase.timeoutMs }, async () => { + logPhase("case:start", { caseId: bugCase.id, engine }); + const caseStarted = performance.now(); + await runBugCase(bugCase, { app, appUrl, cookie }); + logPhase("case:done", { + caseId: bugCase.id, + engine, + caseMs: Math.round(performance.now() - caseStarted), + }); }); - }, - ); + } + }); } }); diff --git a/framework/artifacts.ts b/framework/artifacts.ts index c0abea2..33c1ee3 100644 --- a/framework/artifacts.ts +++ b/framework/artifacts.ts @@ -17,6 +17,8 @@ export interface BugArtifactPayload { // The teable-ee revision this observation belongs to. The comparison table // groups payloads by this field, never by artifact directory names. commitSha: string; + // Which engine answered. v2 is the guarded column; v1 is reference only and + // never fails a run (framework/verdict.ts). engine: string; appUrl: string; observed: ObservedOutcome; @@ -52,7 +54,7 @@ const VERDICT_LABEL: Record = { const renderSummary = (payload: BugArtifactPayload): string => { const lines = [ - `### ${payload.caseId} @ ${payload.commitSha.slice(0, 10)}`, + `### ${payload.caseId} @ ${payload.commitSha.slice(0, 10)} (${payload.engine})`, "", `- verdict: ${VERDICT_LABEL[payload.verdict]}`, `- bug: ${payload.bug.issue} (declared ${payload.bug.status})`, @@ -79,11 +81,17 @@ export const writeBugArtifacts = async ( if (!artifactDir) { // Local direction-finding runs may not set an artifact dir; the console // summary below is still worth having. - console.log(`[e2e-lab] ${payload.caseId}: ${payload.verdict}`); + console.log( + `[e2e-lab] ${payload.caseId} (${payload.engine}): ${payload.verdict}`, + ); return; } await mkdir(artifactDir, { recursive: true }); - const stem = sanitizeCaseId(payload.caseId); + // The engine is part of the file name, not only of the payload: two engines + // write for the same case in the same directory, and a shared stem would + // leave one silently overwriting the other — which the fail-closed report + // would then read as a missing cell rather than as a collision. + const stem = `${sanitizeCaseId(payload.caseId)}-${payload.engine}`; await writeFileAtomically( join(artifactDir, `${stem}.json`), `${JSON.stringify(payload, null, 2)}\n`, diff --git a/framework/case-base.ts b/framework/case-base.ts index 8c0a919..daf9072 100644 --- a/framework/case-base.ts +++ b/framework/case-base.ts @@ -1,5 +1,8 @@ import { AsyncLocalStorage } from "node:async_hooks"; +import type { INestApplication } from "@nestjs/common"; import { createBase, permanentDeleteBase } from "../../utils/init-app"; +import { labEngine } from "./engine"; +import { fixtureDb } from "./fixture-db"; /** * A base of its own for every case. @@ -72,6 +75,7 @@ const caseBaseName = (caseId: string, runId: string) => export const withCaseBase = async ( caseId: string, runId: string, + app: INestApplication, body: (baseId: string) => Promise, ): Promise => { installBaseIdView(); @@ -81,6 +85,27 @@ export const withCaseBase = async ( name: caseBaseName(caseId, runId), }); + // The only door to v1, and the reason a v1 column exists at all. + // + // Routing asks FORCE_V2_ALL first and the base's own v2 flag second, and + // createBase stamps every base it makes as v2. So turning the environment + // switch off drops to the second rule, which still answers v2: measured on + // 2026-08-27, a full 129-case run with the switch off produced not one + // observation different from the v2 baseline, and the response header said + // why — reason `new_base` instead of `env_force_v2_all`. + // + // Unstamping the base before the runner touches it means its tables are + // built by v1 as well, not just read by it. What this cannot do is make a + // base that was BORN on v1: real v1 customers have bases older than v2, and + // whether the two are equivalent in every respect is not established. That + // is the standing reason the v1 column is reference-only. + if (labEngine() === "v1") { + await fixtureDb(app).execute( + `UPDATE base SET v2_enabled = false WHERE id = $1`, + base.id, + ); + } + try { return await caseBaseStore.run({ baseId: base.id }, () => body(base.id)); } finally { diff --git a/framework/engine.test.js b/framework/engine.test.js index 50d38e0..53526b4 100644 --- a/framework/engine.test.js +++ b/framework/engine.test.js @@ -1,6 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { assertServedByV2, pickRoutingHeaders } from "./engine.ts"; +import { + applyEngineRuntimeEnv, + assertServedByV2, + labEngine, + pickRoutingHeaders, +} from "./engine.ts"; const headers = (engine, feature, reason = "env_force_v2_all") => ({ "x-teable-v2": engine, @@ -67,3 +72,62 @@ test("header lookup tolerates casing and array-valued headers", () => { assert.equal(routing["x-teable-v2-feature"], "getRecords"); assert.equal(routing["x-teable-v2-reason"], ""); }); + +test("the engine is read live, not captured at import", () => { + const previous = process.env.E2E_LAB_ENGINE; + try { + process.env.E2E_LAB_ENGINE = "v1"; + assert.equal(labEngine(), "v1"); + process.env.E2E_LAB_ENGINE = "v2"; + assert.equal(labEngine(), "v2"); + // Anything unrecognised is the guarded engine: a typo must not silently + // demote a run to the reference column. + process.env.E2E_LAB_ENGINE = "v3"; + assert.equal(labEngine(), "v2"); + delete process.env.E2E_LAB_ENGINE; + assert.equal(labEngine(), "v2"); + } finally { + if (previous === undefined) delete process.env.E2E_LAB_ENGINE; + else process.env.E2E_LAB_ENGINE = previous; + } +}); + +test("applyEngineRuntimeEnv sets the switch each engine needs", () => { + const previous = process.env.FORCE_V2_ALL; + try { + applyEngineRuntimeEnv("v1"); + assert.equal(process.env.FORCE_V2_ALL, "false"); + applyEngineRuntimeEnv("v2"); + assert.equal(process.env.FORCE_V2_ALL, "true"); + } finally { + if (previous === undefined) delete process.env.FORCE_V2_ALL; + else process.env.FORCE_V2_ALL = previous; + } +}); + +test("a v1 run answered by v2 throws rather than reporting v1's name", () => { + const previous = process.env.E2E_LAB_ENGINE; + process.env.E2E_LAB_ENGINE = "v1"; + try { + // The failure this guards is a fabricated reference column: the base was + // never unstamped, so v2 answered and the cell would carry v1's label. + assert.throws( + () => + assertServedByV2(headers("true", "", "new_base"), { + operation: "GET /table/{tableId}/record", + }), + /requested of v1 but v2 answered/, + ); + // v1 answering a v1 request is the expected case, and the missing feature + // header is not a mismatch - that header is a v2 concept. + const routing = assertServedByV2(headers("false", "", "disabled"), { + operation: "GET /table/{tableId}/record", + feature: "getRecords", + }); + assert.equal(routing.engine, "false"); + assert.equal(routing.reason, "disabled"); + } finally { + if (previous === undefined) delete process.env.E2E_LAB_ENGINE; + else process.env.E2E_LAB_ENGINE = previous; + } +}); diff --git a/framework/engine.ts b/framework/engine.ts index dbd0092..155de8a 100644 --- a/framework/engine.ts +++ b/framework/engine.ts @@ -1,17 +1,18 @@ /** - * The engine this lab guards, and proof that it answered. + * Which engine a run asks for, how it gets there, and proof that it answered. * - * teable-ee is migrating to v2 and v1 bugs are not being fixed. So there is - * one engine here, not a choice: every case guards v2, and a result measured - * on v1 is not a weaker result, it is a different question nobody asked. + * v2 is what this lab GUARDS: it is where fixes land, and a bug returning + * there is a regression someone must act on. v1 is run as a REFERENCE — it + * answers "what does the engine our older customers are still on do with + * this?" — and never fails anything (framework/verdict.ts). * - * That is the one thing this module does differently from teable-perf-lab's - * framework/routing.ts, which it is otherwise a port of. perf-lab runs BOTH - * engines on purpose — comparing them is its job — so its assertion asks "did - * I get the engine I requested", and v1 answering a v1 request is success. - * Copying that here reproduced the exact bug it was meant to prevent one level - * up: pinned to v1, three v2 cases ran, found nothing, and reported green. - * Here the question is "did v2 answer", full stop. + * That split is what makes running both engines safe here. teable-perf-lab's + * framework/routing.ts, which this is otherwise a port of, asks only "did I + * get the engine I requested". Copying that alone once reproduced the exact + * bug it was meant to prevent: pinned to v1, three v2 cases ran, found + * nothing, and reported green. So the v2 assertion below stays absolute — on + * a v2 run, v2 must have answered — and the v1 assertion is its mirror rather + * than a relaxation. * * What is worth taking from perf-lab, and is taken: * @@ -30,9 +31,18 @@ * instead of just this assertion. */ -// Stamped into every artifact. A constant today, and deliberately not a -// parameter: the day there is a v3 to guard, this is where that shows up. -export const LAB_ENGINE = "v2"; +export type LabEngine = "v1" | "v2"; + +// Read LIVE, never captured into a module constant. +// +// The spec runs one engine block after another in the same process, so a +// constant read at import time would pin every later block to whichever engine +// happened to be first — and the failure would be silent: the v1 block would +// quietly report v2's answers under v1's name. The routing assertion below +// would catch it, but only because it too reads live. Defaults to v2, the +// guarded engine. +export const labEngine = (): LabEngine => + process.env.E2E_LAB_ENGINE === "v1" ? "v1" : "v2"; export interface RoutingHeaders { "x-teable-v2": string; @@ -47,10 +57,16 @@ export interface EngineRouting { reason: string; } -// Called before the app boots. FORCE_V2_ALL is read live per request, but some -// paths also read it at startup, so it is set once for the process. -export const applyEngineRuntimeEnv = () => { - process.env.FORCE_V2_ALL = "true"; +// Called before each engine's app boots. FORCE_V2_ALL is read live per +// request, but some paths also read it at startup, so it is set explicitly. +// +// Turning it off is necessary to reach v1 and NOT sufficient: the router asks +// FORCE_V2_ALL first and the base's own v2 flag second, and every base the +// product creates is stamped v2. Unstamping it is framework/case-base.ts's +// job, and without that step a "v1" run is a second v2 run wearing a label — +// measured 2026-08-27, 129 cases, not one observation different. +export const applyEngineRuntimeEnv = (engine: LabEngine = labEngine()) => { + process.env.FORCE_V2_ALL = engine === "v2" ? "true" : "false"; }; // Genuinely case-insensitive, not just "try the lowercase spelling too": HTTP @@ -83,6 +99,9 @@ export const pickRoutingHeaders = ( * run), so "the lab asked the wrong engine" can never be read as "the bug is * gone" — which is exactly what happened before this existed: two v2 cases * passed on their own pre-fix commits, four columns of green. + * + * On a v1 run the name still reads right: it asserts the run got the engine it + * asked for. Runners call it unchanged. */ export const assertServedByV2 = ( headers: Record, @@ -92,6 +111,26 @@ export const assertServedByV2 = ( const engine = routing["x-teable-v2"]; const feature = routing["x-teable-v2-feature"]; + // The v1 mirror. Not a relaxation of the check below: a v1 run answered by + // v2 is a fabricated reference column, which is worse than no column, so it + // throws just as hard. What it does not do is demand a feature header — that + // header is a v2 concept and its absence on v1 is the expected answer. + if (labEngine() === "v1") { + if (engine === "true") { + throw new Error( + `${options.operation} was requested of v1 but v2 answered ` + + `(reason=${routing["x-teable-v2-reason"] || "(none)"}). ` + + "The base was not unstamped; see framework/case-base.ts.", + ); + } + return { + engine, + feature, + expectedFeature: options.feature, + reason: routing["x-teable-v2-reason"], + }; + } + if (engine !== "true") { throw new Error( `${options.operation} was not served by v2 (x-teable-v2=${engine || "(none)"}, ` + diff --git a/framework/run-bug-case.ts b/framework/run-bug-case.ts index ac86deb..24bab88 100644 --- a/framework/run-bug-case.ts +++ b/framework/run-bug-case.ts @@ -3,7 +3,7 @@ import { writeBugArtifacts, type BugArtifactPayload } from "./artifacts"; import { normalizeBugError, toBugTestFailure } from "./bug-error"; import { withCaseBase } from "./case-base"; import { executeRegisteredRunner } from "./runner-registry"; -import { LAB_ENGINE } from "./engine"; +import { labEngine } from "./engine"; import { resolveVerdict, verdictFailsCi } from "./verdict"; import { BugPresentError } from "./types"; import type { BugCase, BugProbeResult, BugRunContext } from "./types"; @@ -23,7 +23,7 @@ export const runBugCase = async ( ...appContext, runId: process.env.E2E_LAB_RUN_ID ?? `local-${Date.now()}`, commitSha: process.env.E2E_LAB_COMMIT_SHA ?? "local", - engine: LAB_ENGINE, + engine: labEngine(), artifactDir: process.env.E2E_LAB_ARTIFACT_DIR, }; // Default gating: a local or single-commit run is judging "does this bug @@ -39,10 +39,15 @@ export const runBugCase = async ( // instead of escaping as a bare vitest error. See framework/case-base.ts for // why every case gets its own. try { - probe = await withCaseBase(bugCase.id, context.runId, async (baseId) => { - caseBaseId = baseId; - return executeRegisteredRunner(bugCase, context); - }); + probe = await withCaseBase( + bugCase.id, + context.runId, + appContext.app, + async (baseId) => { + caseBaseId = baseId; + return executeRegisteredRunner(bugCase, context); + }, + ); } catch (error) { caught = error; } @@ -88,7 +93,7 @@ export const runBugCase = async ( await writeBugArtifacts(context.artifactDir, payload); - if (verdict === "unexpected-pass" && gating) { + if (verdict === "unexpected-pass" && gating && context.engine !== "v1") { // Good news, routed to a human instead of to the exit code: the metadata // flip is a judgment (was it really this bug that got fixed?), and failing // the run for good news teaches people to flip status without verifying. @@ -101,7 +106,7 @@ export const runBugCase = async ( ); } - if (verdictFailsCi(verdict, { gating })) { + if (verdictFailsCi(verdict, { gating, engine: context.engine })) { throw toBugTestFailure( caught ?? new Error(`verdict ${verdict} with nothing caught — harness bug`), diff --git a/framework/types.ts b/framework/types.ts index 2da9d4a..0a4a5ed 100644 --- a/framework/types.ts +++ b/framework/types.ts @@ -169,6 +169,20 @@ interface BugCaseBase { title: string; bug: BugRef; timeoutMs: number; + // Why this case cannot be asked of v1 — presence alone skips the v1 column. + // + // A reason string rather than a boolean, and it is not decoration: the two + // things that land here look identical in a failure log and are not the same + // claim. Either the FEATURE does not exist on v1 (required links, undo + // capture, field validation), or the FIXTURE cannot be built there (a shape + // v1's own API normalizes away). Both mean "v1 cannot answer this question"; + // only the first means "v1 users do not have this". + // + // Skipping is DECLARED, never inferred from what the run saw. Sniffing "v1 + // said it does not support that" out of an error message fails open: a case + // that genuinely breaks, whose error happens to read that way, would be + // skipped forever and nobody would learn. See docs/operations/e2e-lab.md. + skipV1?: string; } // A runner-specific view of a bug case, keeping the runner literal and its diff --git a/framework/verdict.test.js b/framework/verdict.test.js index 3cfc3f5..e30d39e 100644 --- a/framework/verdict.test.js +++ b/framework/verdict.test.js @@ -44,3 +44,24 @@ test("verdict table", () => { ); } }); + +test("v1 is a reference column and never fails a run", () => { + // Every verdict that turns the guarded column red, on the engine that is + // only ever informational. + for (const verdict of ["error", "regression"]) { + assert.equal( + verdictFailsCi(verdict, { gating: true, engine: "v1" }), + false, + `${verdict} on v1 must not fail the run`, + ); + } + // ... and the same verdicts still bite on the engine the lab guards. + assert.equal(verdictFailsCi("error", { gating: false, engine: "v2" }), true); + assert.equal( + verdictFailsCi("regression", { gating: true, engine: "v2" }), + true, + ); + // An unstated engine is the guarded one, so a caller that forgets cannot + // accidentally turn gating off. + assert.equal(verdictFailsCi("error", { gating: true }), true); +}); diff --git a/framework/verdict.ts b/framework/verdict.ts index 520d5ad..ed0e856 100644 --- a/framework/verdict.ts +++ b/framework/verdict.ts @@ -47,5 +47,20 @@ export const resolveVerdict = ( // how the metadata rots. export const verdictFailsCi = ( verdict: BugVerdict, - { gating }: { gating: boolean }, -): boolean => verdict === "error" || (verdict === "regression" && gating); + { gating, engine }: { gating: boolean; engine?: string }, +): boolean => { + // v1 is a REFERENCE column and never fails anything. + // + // The lab guards v2: that is where the fixes land and where a returning bug + // is a regression someone must act on. v1 is run to answer a different + // question — "what does the engine our older customers are still on do with + // this?" — and its answer is information, not a contract. A v1 cell that + // reproduces is usually just the world without the fix, and a v1 cell that + // errors usually means the case leans on something v1 shapes differently. + // Failing the run for either would make every v1 observation a chore, and + // the column would be switched off within a month. + if (engine === "v1") { + return false; + } + return verdict === "error" || (verdict === "regression" && gating); +}; From 874f30e778e65b5d9c82c634d16b740893c42829 Mon Sep 17 00:00:00 2001 From: HynLcc Date: Thu, 27 Aug 2026 16:48:20 +0800 Subject: [PATCH 10/22] Declare the eleven cases v1 cannot be asked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Established by re-asking each v1 failure on a clean v1 base without the case's own fixture — the only method that separates a real v1 defect from a fixture v1 could never hold. Of 34 cases that answered differently on v1, these eleven were not answering the question the case asks. Two different things, which is why the field is a sentence and not a boolean. Six are features v1 does not have: required links, undo capture columns, field validation (an EMPTY table refuses the same column, so T5685 was never the ordering bug). Five are fixtures v1 cannot hold: v1's own API normalizes a bare user id into a full object, writes complete view column metadata, names the field on an ordinary unique violation, clears a multi-select cell without complaint, and clears its inbound link cells on a plain delete. Declared, never inferred. Reading "v1 said it does not support that" out of an error message fails open — a case that genuinely breaks, whose error happens to read that way, would be skipped forever and nobody would learn. --- cases/field/required-default-backfills-existing-rows.case.ts | 2 ++ cases/link/deleting-a-row-clears-links-pointing-at-it.case.ts | 2 ++ cases/link/oneone-delete-keeps-table-readable.case.ts | 2 ++ cases/link/required-link-blocks-owner-delete.case.ts | 2 ++ cases/link/required-link-keeps-sibling-refresh.case.ts | 2 ++ cases/record/a-row-when-a-looked-up-total-lost-its-rule.case.ts | 2 ++ cases/record/clear-a-cell-and-have-it-count-as-empty.case.ts | 2 ++ .../delete-a-row-whose-undo-bookkeeping-is-missing.case.ts | 2 ++ cases/record/legacy-unique-violation-names-field.case.ts | 2 ++ cases/user-field/group-keeps-legacy-id-out-of-empty.case.ts | 2 ++ cases/view/added-field-lands-after-legacy-columns.case.ts | 2 ++ 11 files changed, 22 insertions(+) diff --git a/cases/field/required-default-backfills-existing-rows.case.ts b/cases/field/required-default-backfills-existing-rows.case.ts index 65a4adf..dcee17d 100644 --- a/cases/field/required-default-backfills-existing-rows.case.ts +++ b/cases/field/required-default-backfills-existing-rows.case.ts @@ -9,6 +9,8 @@ export default defineBugCase({ title: "A required column with a default can be added to a table with rows", runner: "required-default", timeoutMs: 180_000, + skipV1: + "v1 has no field validation at all - an EMPTY table refuses the same column, so this is not the ordering bug", bug: { issue: "T5685", status: "fixed", diff --git a/cases/link/deleting-a-row-clears-links-pointing-at-it.case.ts b/cases/link/deleting-a-row-clears-links-pointing-at-it.case.ts index de2ae73..13e034c 100644 --- a/cases/link/deleting-a-row-clears-links-pointing-at-it.case.ts +++ b/cases/link/deleting-a-row-clears-links-pointing-at-it.case.ts @@ -12,6 +12,8 @@ export default defineBugCase({ title: "Deleting a row empties the cells that pointed at it", runner: "incoming-link-cleanup", timeoutMs: 180_000, + skipV1: + "the legacy link shape is not one v1 writes - measured: plain deletes clear their inbound cells on v1", bug: { issue: "T5381", status: "fixed", diff --git a/cases/link/oneone-delete-keeps-table-readable.case.ts b/cases/link/oneone-delete-keeps-table-readable.case.ts index 0ab6814..5a0d49c 100644 --- a/cases/link/oneone-delete-keeps-table-readable.case.ts +++ b/cases/link/oneone-delete-keeps-table-readable.case.ts @@ -12,6 +12,8 @@ export default defineBugCase({ "Deleting one side of a two-way oneOne link leaves both tables readable", runner: "link-delete-readable", timeoutMs: 180_000, + skipV1: + "v1 hosts the oneOne foreign key on the other table, so the case would be watching a column that is not there", bug: { issue: "T6807", status: "fixed", diff --git a/cases/link/required-link-blocks-owner-delete.case.ts b/cases/link/required-link-blocks-owner-delete.case.ts index 4396170..3690661 100644 --- a/cases/link/required-link-blocks-owner-delete.case.ts +++ b/cases/link/required-link-blocks-owner-delete.case.ts @@ -12,6 +12,8 @@ export default defineBugCase({ title: "Deleting the row a required link points at is refused", runner: "required-link-blocks-delete", timeoutMs: 180_000, + skipV1: + "v1 link fields carry no required flag - creating one answers 400, so the bug has nothing to happen to", bug: { issue: "T6705", status: "fixed", diff --git a/cases/link/required-link-keeps-sibling-refresh.case.ts b/cases/link/required-link-keeps-sibling-refresh.case.ts index 9658677..ba3ee0a 100644 --- a/cases/link/required-link-keeps-sibling-refresh.case.ts +++ b/cases/link/required-link-keeps-sibling-refresh.case.ts @@ -12,6 +12,8 @@ export default defineBugCase({ "A required link with no foreign key does not block its sibling's refresh", runner: "required-link-refresh", timeoutMs: 180_000, + skipV1: + "v1 link fields carry no required flag - creating one answers 400, so the bug has nothing to happen to", bug: { issue: "T6861", status: "fixed", diff --git a/cases/record/a-row-when-a-looked-up-total-lost-its-rule.case.ts b/cases/record/a-row-when-a-looked-up-total-lost-its-rule.case.ts index fd0cb29..a4be45f 100644 --- a/cases/record/a-row-when-a-looked-up-total-lost-its-rule.case.ts +++ b/cases/record/a-row-when-a-looked-up-total-lost-its-rule.case.ts @@ -12,6 +12,8 @@ export default defineBugCase({ title: "A row can be added when a looked-up total lost its rule", runner: "lookup-of-rollup-create", timeoutMs: 240_000, + skipV1: + "v1 refuses the lookup-over-rollup field this case needs before the fixture is in place", bug: { issue: "T6911", status: "fixed", diff --git a/cases/record/clear-a-cell-and-have-it-count-as-empty.case.ts b/cases/record/clear-a-cell-and-have-it-count-as-empty.case.ts index 84d0ca6..aab77ad 100644 --- a/cases/record/clear-a-cell-and-have-it-count-as-empty.case.ts +++ b/cases/record/clear-a-cell-and-have-it-count-as-empty.case.ts @@ -10,6 +10,8 @@ export default defineBugCase({ title: "Clearing a cell leaves it empty to a filter too", runner: "empty-write-normalization", timeoutMs: 180_000, + skipV1: + "v1 clears a multi-select cell without complaint - the case's own seed is what v1's typecast refuses", bug: { issue: "T6520", status: "fixed", diff --git a/cases/record/delete-a-row-whose-undo-bookkeeping-is-missing.case.ts b/cases/record/delete-a-row-whose-undo-bookkeeping-is-missing.case.ts index 6f0438b..5b24f9c 100644 --- a/cases/record/delete-a-row-whose-undo-bookkeeping-is-missing.case.ts +++ b/cases/record/delete-a-row-whose-undo-bookkeeping-is-missing.case.ts @@ -11,6 +11,8 @@ export default defineBugCase({ title: "A row is deleted when its undo bookkeeping is not in place", runner: "delete-without-undo-capture", timeoutMs: 180_000, + skipV1: + "v1 tables carry no undo capture column, so there is no bookkeeping to be missing", bug: { issue: "T6928", status: "fixed", diff --git a/cases/record/legacy-unique-violation-names-field.case.ts b/cases/record/legacy-unique-violation-names-field.case.ts index 5bed5db..4a79ef7 100644 --- a/cases/record/legacy-unique-violation-names-field.case.ts +++ b/cases/record/legacy-unique-violation-names-field.case.ts @@ -13,6 +13,8 @@ export default defineBugCase({ title: "A unique violation on a v1-era index still names the field", runner: "legacy-unique-error", timeoutMs: 180_000, + skipV1: + "v1 names the field on an ordinary unique violation - only the legacy index this case creates loses it", bug: { issue: "T6758", status: "fixed", diff --git a/cases/user-field/group-keeps-legacy-id-out-of-empty.case.ts b/cases/user-field/group-keeps-legacy-id-out-of-empty.case.ts index 4276480..1e862b3 100644 --- a/cases/user-field/group-keeps-legacy-id-out-of-empty.case.ts +++ b/cases/user-field/group-keeps-legacy-id-out-of-empty.case.ts @@ -12,6 +12,8 @@ export default defineBugCase({ "A user cell holding a bare user id groups as that person, not as empty", runner: "user-group-identity", timeoutMs: 180_000, + skipV1: + "v1's own API normalizes a bare user id into a full object, so v1 cannot hold the cell this case reads", bug: { issue: "T6626", status: "fixed", diff --git a/cases/view/added-field-lands-after-legacy-columns.case.ts b/cases/view/added-field-lands-after-legacy-columns.case.ts index d39552e..24e44da 100644 --- a/cases/view/added-field-lands-after-legacy-columns.case.ts +++ b/cases/view/added-field-lands-after-legacy-columns.case.ts @@ -11,6 +11,8 @@ export default defineBugCase({ title: "A field added to a view with sparse column metadata lands last", runner: "sparse-view-field-order", timeoutMs: 180_000, + skipV1: + "v1 writes complete view column metadata, so the sparse state this case installs is not one v1 produces", bug: { issue: "T6595", status: "fixed", From 4c0096958e8341a3eb6a125f6ab2d84a9c5702b8 Mon Sep 17 00:00:00 2001 From: HynLcc Date: Thu, 27 Aug 2026 16:48:29 +0800 Subject: [PATCH 11/22] Give the v1 answers a table of their own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under the guarded one, judged by nobody. Interleaving the engines would double the width of the thing people actually read and put cells that fail the run next to cells that cannot, which is the confusion this column has to avoid to stay welcome. The fail-closed contract still covers v2 exactly as before: every planned (case x commit) cell, one payload, a hole is a failure. The same problems on the v1 side collect in `referenceIssues`, which is printed and fails nothing — a reference nobody can afford to leave red is a reference nobody keeps. Two quieter things this had to fix. The engine goes in the payload FILE NAME, not only the payload: two engines writing one stem would leave one silently overwriting the other, and the report would then read a collision as a missing cell. And the Regression Track keeps taking v2 only — its Run Key is (run, attempt, case, commit) with no engine, so both engines would land on one row; widening the key would change what every historical row means, and the v1 column does not want a queryable history. Both engines run in one job. perf-lab gives each its own because overlapping measurements pollute each other; bug observations do not, and a second job would re-pay the whole bootstrap to save one app boot. --- .github/workflows/e2e-lab.yml | 7 ++ AGENTS.md | 15 ++- README.md | 14 ++- docs/operations/e2e-lab.md | 54 +++++++++- scripts/build-comparison.mjs | 5 +- scripts/case-catalog.mjs | 3 + scripts/check-comparison-model.mjs | 134 ++++++++++++++++++++++++ scripts/check-run-plan.mjs | 32 +++++- scripts/comparison-model.mjs | 159 +++++++++++++++++++++++++---- scripts/report-teable-track.mjs | 16 +++ scripts/run-plan-model.mjs | 30 +++++- scripts/run-plan.mjs | 1 + 12 files changed, 438 insertions(+), 32 deletions(-) diff --git a/.github/workflows/e2e-lab.yml b/.github/workflows/e2e-lab.yml index ef9c2d7..3ecfe6f 100644 --- a/.github/workflows/e2e-lab.yml +++ b/.github/workflows/e2e-lab.yml @@ -206,6 +206,12 @@ jobs: export E2E_LAB_CASE_FILTER="${{ inputs.case_filter }}" export E2E_LAB_COMMIT_SHA="${{ matrix.plan.sha }}" export E2E_LAB_GATING="${{ matrix.plan.gating }}" + # Both engines, in one job. perf-lab gives each engine its own job + # because overlapping measurements pollute each other; bug + # observations do not, and a second job would re-pay the whole ~50s + # bootstrap to save one app boot. v2 is what this run is judged on; + # v1 rides along as the reference column. + export E2E_LAB_ENGINE_LIST="v1,v2" pnpm -F @teable/backend-ee exec vitest run \ --config ./vitest-e2e-lab.config.ts \ @@ -273,6 +279,7 @@ jobs: E2E_LAB_ARTIFACT_DIR: ${{ github.workspace }}/e2e-lab-artifacts E2E_LAB_EXECUTE_PLAN: ${{ needs.resolve_inputs.outputs.execute_plan }} E2E_LAB_CASE_FILTER: ${{ inputs.case_filter }} + E2E_LAB_ENGINES: '["v1","v2"]' E2E_LAB_COMPARISON_PATH: ${{ github.workspace }}/e2e-lab-report/comparison.json run: node e2e-lab/scripts/build-comparison.mjs diff --git a/AGENTS.md b/AGENTS.md index e403448..6528aa1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,13 +25,24 @@ picture, [.agents/README.md](.agents/README.md) to add or change a case, and - Observe through the public API. The database is available for building fixtures the API cannot express, and only there — reaching for it inside a checkpoint throws (`framework/fixture-db.ts`). -- Every case guards v2. v1 still answers, so runners prove which engine served - them (`framework/engine.ts`). +- Every case guards v2, and every case is also asked of v1 as a reference. + Runners prove which engine served them (`framework/engine.ts`); a case whose + feature does not exist on v1 declares `skipV1: "why"` instead of failing + there every run. ## Things that look like oversights and are not Ask before "fixing" any of these: +- **Nothing the v1 column reports can fail a run.** v1 is a reference: the lab + guards v2, which is where fixes land. A v1 cell is evidence to follow up, not + a verdict — partly because reaching v1 at all means unstamping each case's + base, which makes a base no real customer has (theirs predate v2). +- **`skipV1` is declared on the case, never inferred from a failure.** Reading + "v1 said it does not support that" out of an error message fails open: a case + that genuinely breaks, whose error happens to read that way, would be skipped + forever and nobody would learn. + - **A `fixed` case reproducing on an old commit is not red.** That is the world before the fix. Only the gating column turns a reproduction into a regression. The table is in `framework/verdict.ts`, one screen. diff --git a/README.md b/README.md index ae6e3e8..38d7cda 100644 --- a/README.md +++ b/README.md @@ -25,13 +25,21 @@ The execution skeleton is teable-perf-lab's, proven in production there: - **Pinning**: refs are resolved to SHAs once, up front; every job checks out the pinned SHA. - **One job per commit**: isolated database built from that commit's own - migrations, all selected cases run serially, one JSON payload per case - written _before_ any assertion throws — the payloads are the source of - truth, and failures carry the server's own error body. + migrations, every selected case run once per engine, one JSON payload per + case per engine written _before_ any assertion throws — the payloads are the + source of truth, and failures carry the server's own error body. - **Fail-closed report**: every planned (case × commit) cell must have exactly one payload. Missing evidence fails the run; it never renders as an empty cell someone might read as green. +**Two engines, one of them guarded.** v2 is where fixes land, so that is the +column the run is judged on. v1 is asked the same cases as a reference — what +does the engine our older customers are still on do with this — and nothing it +reports fails a run; it renders as its own table. Reaching v1 needs more than +an environment switch, and a case whose feature v1 does not have declares +`skipV1` rather than failing there every run. Both are explained in +[docs/operations/e2e-lab.md](docs/operations/e2e-lab.md). + What is this repository's own: the verdict model. Each case declares the bug it reproduces and its believed status (`open` / `fixed`); the run observes (`absent` / `present` / `error`) and the comparison judges. Known-unfixed bugs diff --git a/docs/operations/e2e-lab.md b/docs/operations/e2e-lab.md index be2bda8..6c99d27 100644 --- a/docs/operations/e2e-lab.md +++ b/docs/operations/e2e-lab.md @@ -11,7 +11,10 @@ The executable path is deliberately the one teable-perf-lab proved out: 3. One `execute` job per commit: checkout teable-ee at the pinned SHA, inject the lab into `community/apps/nestjs-backend/test/e2e-lab/`, build the database from that commit's own migrations plus the standard e2e seed, and - run every selected case serially through `@teable/backend-ee`'s vitest. + run every selected case through `@teable/backend-ee`'s vitest — once per + engine, one Nest app each. Both engines share the job: perf-lab splits them + because overlapping measurements pollute each other, and bug observations do + not, so a second job would re-pay the whole bootstrap to save one app boot. 4. `report` collects every payload, renders the bug × commit table, and enforces acceptance fail-closed. @@ -20,6 +23,47 @@ small and built inside each case, and dumps could not be shared across commits anyway — different commits carry different Prisma migrations, which is exactly why perf-lab's cache key hashes the schema. +## The two engines + +**v2 is guarded. v1 is a reference.** + +v2 is where fixes land, so a bug returning there is a regression someone must +act on, and the verdict table below applies to it in full. v1 is run to answer +a different question — what does the engine our older customers are still on do +with the same case — and **nothing it reports fails a run** +(`framework/verdict.ts`). It renders as its own table under the guarded one. + +Reaching v1 takes more than an environment switch, and the reason is worth +knowing before trusting any v1 cell. Routing asks `FORCE_V2_ALL` first and the +base's own v2 flag second, and the product stamps every base it creates as v2 — +so turning the switch off just falls through to the second rule. Measured +2026-08-27: a full 129-case run with the switch off produced **not one** +observation different from the v2 baseline, and the response header said why +(reason `new_base` instead of `env_force_v2_all`). So `framework/case-base.ts` +unstamps each case's base before the runner touches it. What that cannot do is +make a base that was _born_ on v1, which is what real v1 customers have. That +gap is the standing reason v1 never gates. + +### Cases that cannot be asked of v1 + +A case declares `skipV1: "why"` and its v1 cell renders `⊘` — never run, never +red. Two different things legitimately land there, which is why the field is a +sentence and not a boolean: + +- **the feature does not exist on v1** — required links, undo capture columns, + field validation. v1 users do not have the bug because they do not have the + feature. +- **the fixture cannot be built on v1** — a stored shape v1's own API + normalizes away. The feature works on v1; the case just cannot set up its + question there. + +Skipping is always DECLARED. Sniffing it out of an error message fails open: a +case that genuinely breaks, whose error happens to read like a capability +refusal, would be skipped forever and nobody would learn. Of 129 cases, 11 +declare it — established by re-asking each failure on a clean v1 base without +the case's own fixture, which is the only method that separates a real v1 +defect from a v2-shaped fixture. + ## Verdicts and gating Each case observes one of `absent | present | error` and the framework labels @@ -45,7 +89,9 @@ test); anything thrown outside every checkpoint is `error`. The report job fails when any of these hold, and only then: -- a planned (case × commit) cell has no payload, or more than one; +- a planned (case × commit) cell has no payload, or more than one — the + **v2** cells only; the same problems on the v1 side are listed under the + reference table and fail nothing; - a payload arrived for a case or commit outside the plan; - a payload carries a verdict string the table cannot render; - any cell is an `error`; @@ -71,7 +117,9 @@ cell. ## Artifacts - `e2e-lab-results--`: one per commit — a JSON payload and a - markdown summary per case. Named without the attempt and uploaded with + markdown summary per case per engine, the engine in the file name as well as + in the payload (a shared stem would leave one engine overwriting the other, + which the fail-closed report would then read as a missing cell). Named without the attempt and uploaded with `overwrite`, so "Re-run failed jobs" replaces only the re-run column. - `e2e-lab-comparison--`: `comparison.json`, the exact model the acceptance gate judged. diff --git a/scripts/build-comparison.mjs b/scripts/build-comparison.mjs index 82ac280..c669de5 100644 --- a/scripts/build-comparison.mjs +++ b/scripts/build-comparison.mjs @@ -13,6 +13,7 @@ import { loadCaseCatalog, resolveCaseFilter } from "./case-catalog.mjs"; import { buildComparison, renderComparisonMarkdown, + renderReferenceMarkdown, } from "./comparison-model.mjs"; import { requiredEnv } from "./env.mjs"; @@ -76,6 +77,7 @@ const main = async () => { const payloads = await collectPayloads(artifactDir); const comparison = buildComparison({ + engines: JSON.parse(process.env.E2E_LAB_ENGINES ?? '["v1","v2"]'), caseCatalog: plannedCatalog, executePlan, payloads, @@ -84,7 +86,8 @@ const main = async () => { await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, `${JSON.stringify(comparison, null, 2)}\n`); - const markdown = renderComparisonMarkdown(comparison); + const markdown = + renderComparisonMarkdown(comparison) + renderReferenceMarkdown(comparison); if (process.env.GITHUB_STEP_SUMMARY) { appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown); } else { diff --git a/scripts/case-catalog.mjs b/scripts/case-catalog.mjs index 7bf2397..45bd03d 100644 --- a/scripts/case-catalog.mjs +++ b/scripts/case-catalog.mjs @@ -138,6 +138,9 @@ export const loadCaseCatalog = async (repoRoot) => { runner: literalField(source, "runner", path), timeoutMs: numericField(source, "timeoutMs"), link: optionalLiteralField(source, "link"), + // Present = this case is never asked of v1, and the string says why. + // See framework/types.ts for the two things that legitimately land here. + skipV1: optionalLiteralField(source, "skipV1"), appliesSince: optionalLiteralField(source, "appliesSince"), sourceCommits: literalArrayField(source, "sourceCommits"), }); diff --git a/scripts/check-comparison-model.mjs b/scripts/check-comparison-model.mjs index 3001445..3cf449c 100644 --- a/scripts/check-comparison-model.mjs +++ b/scripts/check-comparison-model.mjs @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import { buildComparison, renderComparisonMarkdown, + renderReferenceMarkdown, + SKIPPED_CELL, VERDICT_CELLS, } from "./comparison-model.mjs"; @@ -264,3 +266,135 @@ console.log("comparison model ok"); /\*\*1 case\*\* ran on .* \*\*1 passed\*\*, \*\*0 failed\*\*/, ); } + +// The v1 reference column. Everything below is the same run seen twice: the +// guarded table must read exactly as it did before v1 existed, and the v1 +// table must never be able to reach into it. +{ + const develop = [ + { + name: "c1", + position: 1, + ref: "develop", + sha: sha("f"), + short: short("f"), + gating: true, + }, + ]; + const catalog = [ + { id: "record/x", issue: "T1", status: "fixed" }, + { + id: "record/skipped", + issue: "T2", + status: "fixed", + skipV1: "v1 has no such column", + }, + ]; + const v1 = (caseId, observed, verdict) => ({ + ...payload(caseId, sha("f"), observed, verdict), + engine: "v1", + }); + const v2 = (caseId, observed, verdict) => ({ + ...payload(caseId, sha("f"), observed, verdict), + engine: "v2", + }); + + const comparison = buildComparison({ + caseCatalog: catalog, + executePlan: develop, + engines: ["v1", "v2"], + payloads: [ + v2("record/x", "absent", "pass"), + v2("record/skipped", "absent", "pass"), + // The two verdicts that turn the guarded column red, on the reference + // engine. Neither may be allowed anywhere near `passed`. + v1("record/x", "present", "regression"), + ], + }); + + assert.equal(comparison.passed, true); + assert.deepEqual(comparison.failures.regressions, []); + assert.deepEqual(comparison.failures.errors, []); + assert.deepEqual(comparison.failures.missing, []); + + const [row, skippedRow] = comparison.rows; + assert.equal(row.cells[0].verdict, "pass"); + assert.equal(row.referenceCells[0].verdict, "regression"); + // A declared skip is not a hole: it never asks for a payload and never + // reports one missing. + assert.equal(skippedRow.referenceCells[0].skipped, true); + assert.equal( + comparison.referenceIssues.filter( + (issue) => issue.caseId === "record/skipped", + ).length, + 0, + ); + + const reference = renderReferenceMarkdown(comparison); + assert.match(reference, /## v1 reference/); + assert.ok(reference.includes(SKIPPED_CELL)); + assert.match(reference, /v1 has no such column/); + // The guarded table says nothing about v1. + assert.doesNotMatch(renderComparisonMarkdown(comparison), /v1 reference/); +} + +// A v1 payload for a case that declared skipV1 is unplanned — but on the +// reference side, so it is reported and still does not fail the run. +{ + const develop = [ + { + name: "c1", + position: 1, + ref: "develop", + sha: sha("f"), + short: short("f"), + gating: true, + }, + ]; + const comparison = buildComparison({ + caseCatalog: [ + { id: "record/skipped", issue: "T2", status: "fixed", skipV1: "no" }, + ], + executePlan: develop, + engines: ["v1", "v2"], + payloads: [ + { + ...payload("record/skipped", sha("f"), "absent", "pass"), + engine: "v2", + }, + { + ...payload("record/skipped", sha("f"), "absent", "pass"), + engine: "v1", + }, + ], + }); + assert.equal(comparison.passed, true); + assert.deepEqual(comparison.failures.unplanned, []); + assert.equal( + comparison.referenceIssues.some((issue) => issue.kind === "unplanned"), + true, + ); +} + +// A payload written before the engine existed reads as v2, so an older +// artifact still lands in the guarded table rather than vanishing. +{ + const develop = [ + { + name: "c1", + position: 1, + ref: "develop", + sha: sha("f"), + short: short("f"), + gating: true, + }, + ]; + const comparison = buildComparison({ + caseCatalog: [{ id: "record/x", issue: "T1", status: "fixed" }], + executePlan: develop, + engines: ["v2"], + payloads: [payload("record/x", sha("f"), "absent", "pass")], + }); + assert.equal(comparison.rows[0].cells[0].verdict, "pass"); + assert.equal(renderReferenceMarkdown(comparison), ""); +} diff --git a/scripts/check-run-plan.mjs b/scripts/check-run-plan.mjs index 31c36db..305ad42 100644 --- a/scripts/check-run-plan.mjs +++ b/scripts/check-run-plan.mjs @@ -23,7 +23,37 @@ const ALL_CASES = ["smoke/auth-user", "record/bulk-update-100-mixed-lands"]; [false, true], ); assert.deepEqual(plan.caseIds, ALL_CASES); - assert.equal(plan.planSummary.expectedPayloads, 4); + // Both engines run: 2 commits x 2 cases x 2 engines. + assert.equal(plan.planSummary.expectedPayloads, 8); + // Only the v2 half is the contract the acceptance gate enforces. + assert.equal(plan.planSummary.expectedGuardedPayloads, 4); + assert.deepEqual(plan.planSummary.engines, ["v1", "v2"]); +} + +// A case declaring skipV1 is not expected to produce a v1 payload, so the +// coverage contract must not go looking for one. +{ + const plan = resolveRunPlan({ + resolvedCommits: [{ ref: "develop", sha: sha("a") }], + caseFilter: "all", + allCaseIds: ALL_CASES, + skipV1CaseIds: ["smoke/auth-user"], + }); + assert.equal(plan.planSummary.v1CaseCount, 1); + assert.equal(plan.planSummary.expectedPayloads, 3); + assert.equal(plan.planSummary.expectedGuardedPayloads, 2); +} + +// A v2-only run still counts only the guarded half, and expects no v1 cell. +{ + const plan = resolveRunPlan({ + resolvedCommits: [{ ref: "develop", sha: sha("a") }], + caseFilter: "all", + allCaseIds: ALL_CASES, + engines: ["v2"], + }); + assert.equal(plan.planSummary.v1CaseCount, 0); + assert.equal(plan.planSummary.expectedPayloads, 2); } // Column order is dispatch order, verbatim. diff --git a/scripts/comparison-model.mjs b/scripts/comparison-model.mjs index 9333c51..eb95439 100644 --- a/scripts/comparison-model.mjs +++ b/scripts/comparison-model.mjs @@ -10,6 +10,13 @@ // A missing cell is a failure, never an empty cell a reader might take for // green; a duplicate is a failure, because two observations for one cell means // the run's identity is confused. +// +// That contract covers the V2 table only. v1 is a reference column: it is +// rendered beside the guarded one and recorded in the artifact, and its +// problems land in `referenceIssues`, which is printed and never fails the +// run. Making v1 fail-closed too would put the noise this column was cleaned +// of straight back — 11 of 129 cases cannot be asked of v1 at all, and a +// reference nobody can afford to leave red is a reference nobody keeps. // Keep in sync with the BugVerdict union in framework/verdict.ts. A payload // carrying a verdict outside this map fails the run as `unknown-verdict` @@ -22,11 +29,20 @@ export const VERDICT_CELLS = { error: "💥", }; export const MISSING_CELL = "❓"; +// Declared on the case, not discovered by the run: v1 was never asked. +export const SKIPPED_CELL = "⊘"; -export const buildComparison = ({ caseCatalog, executePlan, payloads }) => { +export const buildComparison = ({ + caseCatalog, + executePlan, + payloads, + engines = ["v2"], +}) => { const commits = executePlan; const commitShas = new Set(commits.map(({ sha }) => sha)); const plannedCaseIds = new Set(caseCatalog.map(({ id }) => id)); + const skipsV1 = new Map(caseCatalog.map((entry) => [entry.id, entry.skipV1])); + const runsV1 = engines.includes("v1"); const failures = { missing: [], @@ -37,33 +53,50 @@ export const buildComparison = ({ caseCatalog, executePlan, payloads }) => { errors: [], }; const notices = { unexpectedlyFixed: [] }; + // Everything the v1 column noticed that a person should see and no gate + // should act on. + const referenceIssues = []; + + const emptyBySha = () => new Map(commits.map(({ sha }) => [sha, new Map()])); + const guarded = emptyBySha(); + const reference = emptyBySha(); - const bySha = new Map(commits.map(({ sha }) => [sha, new Map()])); for (const payload of payloads) { + // A payload with no engine is a pre-engine artifact; read it as v2, the + // engine that was the only one when it was written. + const engine = payload.engine === "v1" ? "v1" : "v2"; + const sink = engine === "v1" ? referenceIssues : null; + const note = (kind, extra = {}) => { + const entry = { + caseId: payload.caseId, + sha: payload.commitSha, + ...extra, + }; + if (sink) { + sink.push({ kind, ...entry }); + } else { + failures[kind].push(entry); + } + }; + + const declaredSkip = skipsV1.get(payload.caseId); if ( !commitShas.has(payload.commitSha) || - !plannedCaseIds.has(payload.caseId) + !plannedCaseIds.has(payload.caseId) || + (engine === "v1" && (!runsV1 || declaredSkip)) ) { - failures.unplanned.push({ - caseId: payload.caseId, - sha: payload.commitSha, - }); + note("unplanned"); continue; } - const perCase = bySha.get(payload.commitSha); + const perCase = (engine === "v1" ? reference : guarded).get( + payload.commitSha, + ); if (perCase.has(payload.caseId)) { - failures.duplicates.push({ - caseId: payload.caseId, - sha: payload.commitSha, - }); + note("duplicates"); continue; } if (!(payload.verdict in VERDICT_CELLS)) { - failures.unknownVerdicts.push({ - caseId: payload.caseId, - sha: payload.commitSha, - verdict: payload.verdict, - }); + note("unknownVerdicts", { verdict: payload.verdict }); continue; } perCase.set(payload.caseId, payload); @@ -71,7 +104,7 @@ export const buildComparison = ({ caseCatalog, executePlan, payloads }) => { const rows = caseCatalog.map((entry) => { const cells = commits.map((commit) => { - const payload = bySha.get(commit.sha).get(entry.id); + const payload = guarded.get(commit.sha).get(entry.id); if (!payload) { failures.missing.push({ caseId: entry.id, sha: commit.sha }); return { sha: commit.sha, short: commit.short, missing: true }; @@ -131,12 +164,39 @@ export const buildComparison = ({ caseCatalog, executePlan, payloads }) => { } } + // The reference column. Judged by nothing: a cell is what v1 answered, a + // skip is what the case declared, and a hole is a hole. + const referenceCells = runsV1 + ? commits.map((commit) => { + if (entry.skipV1) { + return { sha: commit.sha, short: commit.short, skipped: true }; + } + const payload = reference.get(commit.sha).get(entry.id); + if (!payload) { + referenceIssues.push({ + kind: "missing", + caseId: entry.id, + sha: commit.sha, + }); + return { sha: commit.sha, short: commit.short, missing: true }; + } + return { + sha: commit.sha, + short: commit.short, + verdict: payload.verdict, + observed: payload.observed, + }; + }) + : []; + return { caseId: entry.id, issue: entry.issue, status: entry.status, cells, transitions, + skipV1: entry.skipV1, + referenceCells, }; }); @@ -147,9 +207,11 @@ export const buildComparison = ({ caseCatalog, executePlan, payloads }) => { return { commits: commits.map(({ ref, sha, short }) => ({ ref, sha, short })), + engines, rows, failures, notices, + referenceIssues, passed: failureCount === 0, }; }; @@ -157,6 +219,67 @@ export const buildComparison = ({ caseCatalog, executePlan, payloads }) => { const renderCell = (cell) => cell.missing ? MISSING_CELL : VERDICT_CELLS[cell.verdict]; +const renderReferenceCell = (cell) => + cell.skipped ? SKIPPED_CELL : renderCell(cell); + +// The v1 table, printed under the guarded one and judged by nobody. +// +// A separate table rather than extra columns in the first: interleaving them +// doubles the width of the thing people actually read, and puts cells that +// fail the run next to cells that cannot, which is precisely the confusion +// this column has to avoid to stay welcome. +export const renderReferenceMarkdown = (comparison) => { + if (!comparison.engines?.includes("v1")) { + return ""; + } + const skipped = comparison.rows.filter((row) => row.skipV1); + const lines = [ + "", + "## v1 reference", + "", + "What the legacy engine answered for the same cases. Nothing here fails the run.", + "", + `| case | issue | ${comparison.commits.map(({ short }) => `\`${short}\``).join(" | ")} |`, + `|---|---|${comparison.commits.map(() => "---").join("|")}|`, + ...comparison.rows.map( + (row) => + `| ${row.caseId} | ${row.issue} | ${row.referenceCells + .map(renderReferenceCell) + .join(" | ")} |`, + ), + "", + `Legend: ${SKIPPED_CELL} not asked of v1 (declared on the case) · ❌ the bug is present on v1 · ✅ absent · 💥 the case could not run on v1 · ❓ result missing`, + "", + "v1 is reached by unstamping each case's base, which makes a base no real " + + "customer has: theirs predate v2. Read this column as evidence to follow " + + "up, never as a verdict.", + ]; + if (skipped.length > 0) { + lines.push( + "", + `
${skipped.length} case(s) not asked of v1`, + "", + ...skipped.map((row) => `- \`${row.caseId}\` — ${row.skipV1}`), + "", + "
", + ); + } + if (comparison.referenceIssues.length > 0) { + lines.push( + "", + `
${comparison.referenceIssues.length} v1 bookkeeping issue(s) — not failing the run`, + "", + ...comparison.referenceIssues.map( + (issue) => + `- ${issue.kind}: \`${issue.caseId}\` @ \`${(issue.sha ?? "?").slice(0, 10)}\``, + ), + "", + "
", + ); + } + return lines.join("\n"); +}; + const describeTransition = (transition) => transition.kind === "fixed-between" ? `fixed between ${transition.fromShort}..${transition.toShort}` diff --git a/scripts/report-teable-track.mjs b/scripts/report-teable-track.mjs index d3feeed..c0ea391 100644 --- a/scripts/report-teable-track.mjs +++ b/scripts/report-teable-track.mjs @@ -64,6 +64,7 @@ const main = async () => { const records = []; let skippedUnplanned = 0; + let skippedReference = 0; for (const path of await walk(artifactDir)) { if (!path.endsWith(".json")) { continue; @@ -77,6 +78,18 @@ const main = async () => { if (!isPayload(payload)) { continue; } + // The track carries the GUARDED column only. + // + // Its Run Key is (run, attempt, case, commit) — no engine — so two engines + // writing for one case would silently overwrite each other rather than + // land as two rows. Widening the key is possible but would change what + // every historical row means, and the v1 column does not want a queryable + // history: it is read once, in the run summary, beside the run that + // produced it. v1 payloads stay in the artifact. + if (payload.engine === "v1") { + skippedReference += 1; + continue; + } const planEntry = planBySha.get(payload.commitSha); if (!planEntry) { // The acceptance gate already fails the run for these; the table only @@ -126,6 +139,9 @@ const main = async () => { console.log( `Regression Track: ${created} created, ${updated} updated` + (skippedUnplanned > 0 ? `, ${skippedUnplanned} unplanned skipped` : "") + + (skippedReference > 0 + ? `, ${skippedReference} v1 reference payload(s) not tracked` + : "") + ".", ); }; diff --git a/scripts/run-plan-model.mjs b/scripts/run-plan-model.mjs index fa0d997..7e73a07 100644 --- a/scripts/run-plan-model.mjs +++ b/scripts/run-plan-model.mjs @@ -14,7 +14,16 @@ const SHA_PATTERN = /^[0-9a-f]{40}$/; export const shortSha = (sha) => sha.slice(0, 10); -export const resolveRunPlan = ({ resolvedCommits, caseFilter, allCaseIds }) => { +export const resolveRunPlan = ({ + resolvedCommits, + caseFilter, + allCaseIds, + // Cases that declare skipV1, so the coverage contract does not expect a v1 + // payload for them. The engine list is not a dispatch input: both engines + // always run, and which cases v1 can be asked is a property of the cases. + skipV1CaseIds = [], + engines = ["v1", "v2"], +}) => { if (!Array.isArray(resolvedCommits) || resolvedCommits.length === 0) { throw new Error("At least one teable-ee commit is required."); } @@ -68,13 +77,25 @@ export const resolveRunPlan = ({ resolvedCommits, caseFilter, allCaseIds }) => { gating: index === resolvedCommits.length - 1, })); + const skipped = new Set(skipV1CaseIds); + const v1CaseCount = engines.includes("v1") + ? caseIds.filter((id) => !skipped.has(id)).length + : 0; + const v2CaseCount = engines.includes("v2") ? caseIds.length : 0; + return { executePlan, caseIds, + engines, planSummary: { commitCount: executePlan.length, caseCount: caseIds.length, - expectedPayloads: executePlan.length * caseIds.length, + engines, + v1CaseCount, + // Only the v2 half is a contract the acceptance gate enforces; the v1 + // half is counted so a reader can see the run got what it paid for. + expectedPayloads: executePlan.length * (v1CaseCount + v2CaseCount), + expectedGuardedPayloads: executePlan.length * v2CaseCount, commits: executePlan.map(({ ref, short, gating }) => ({ ref, short, @@ -94,7 +115,8 @@ export const renderPlanSummaryMarkdown = (planSummary) => `${ref}@${short}${gating ? " ←gating" : ""}`, ) .join(", ")})`, - `- Cases: ${planSummary.caseCount}`, - `- Expected payloads: ${planSummary.expectedPayloads}`, + `- Cases: ${planSummary.caseCount} (v2), ${planSummary.v1CaseCount} of them also asked of v1`, + `- Engines: ${(planSummary.engines ?? ["v2"]).join(", ")}`, + `- Expected payloads: ${planSummary.expectedPayloads} (${planSummary.expectedGuardedPayloads} guarded)`, "", ].join("\n"); diff --git a/scripts/run-plan.mjs b/scripts/run-plan.mjs index b0740bb..75e625c 100644 --- a/scripts/run-plan.mjs +++ b/scripts/run-plan.mjs @@ -23,6 +23,7 @@ const main = async () => { resolvedCommits: JSON.parse(process.env.E2E_LAB_RESOLVED_COMMITS ?? "[]"), caseFilter: process.env.E2E_LAB_CASE_FILTER ?? "all", allCaseIds: catalog.map(({ id }) => id), + skipV1CaseIds: catalog.filter(({ skipV1 }) => skipV1).map(({ id }) => id), }); if (process.env.GITHUB_OUTPUT) { From 31c446fdc3733d1f99e216f251ea13124e85fb88 Mon Sep 17 00:00:00 2001 From: HynLcc Date: Thu, 27 Aug 2026 17:01:20 +0800 Subject: [PATCH 12/22] Stop a background worker's leftovers from deciding a run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import cases hand work to a queue, assert, and then tear down the space the queue is still writing to. The worker's completion handler lands afterwards, updates a table that is gone, and rejects with nobody to catch it — and vitest fails the file on that. Run 33055688034 is the whole argument: 247 tests passed, 11 skipped, none failed, every payload written, the report job's acceptance gate green, and the execute job red anyway on one such rejection from a v1 import case. The v1 pass did not create the race, it doubled the chances of losing it. Ignoring them costs no signal. A case's evidence only ever arrives through bugCheckpoint() and is written to its payload before anything may throw, and the payloads are what the report job judges. Vitest still prints them — the same bargain the v1 column takes: visible, gating nothing. --- vitest-e2e-lab.config.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/vitest-e2e-lab.config.ts b/vitest-e2e-lab.config.ts index 99d2ab0..7cf3070 100644 --- a/vitest-e2e-lab.config.ts +++ b/vitest-e2e-lab.config.ts @@ -66,6 +66,25 @@ export default defineConfig({ sequence: { hooks: "stack", }, + // A background worker finishing after its fixture is gone must not decide + // this run. + // + // Import cases hand work to a queue; the case then asserts, and its + // teardown removes the space the queue is still writing to. When the + // worker's completion handler lands it updates a table that no longer + // exists and rejects with nobody to catch it, and vitest fails the whole + // file on that. Measured on run 33055688034: 247 tests passed, 11 skipped, + // none failed, every payload written and the report job's acceptance gate + // green — and the job was red anyway, on one such rejection from a v1 + // import case. + // + // Ignoring them costs no signal. A case's evidence only ever arrives + // through bugCheckpoint() and is written to its payload before anything is + // allowed to throw; the payloads, judged by the report job, are what says + // whether a run passed. Vitest still PRINTS these under "Unhandled + // Errors", which is the same bargain the v1 column takes: visible, and + // gating nothing. + dangerouslyIgnoreUnhandledErrors: true, logHeapUsage: true, reporters: ["verbose"], include: [e2eLabSpec], From 7f12423191bf2968037fdd52662cc9c78be811ac Mon Sep 17 00:00:00 2001 From: HynLcc Date: Thu, 27 Aug 2026 17:33:31 +0800 Subject: [PATCH 13/22] Give each engine its own job, its own database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharing a job saved one bootstrap and was wrong in a way that only shows up on reflection: two passes against ONE database means the second engine runs on state the first one left, and the guarded column is what would have been reading it. Run 33056650739 came out clean anyway, which is exactly why this would have gone unnoticed. Split, each engine gets its own containers and its own database built from the commit's own migrations, and the two jobs run at the same time — the wall clock is one engine's, not two. It is also what teable-perf-lab has done all along, for the same reason plus its own: it restores a seed dump per engine so both start from byte-identical state. The execute matrix becomes commit x engine; the comparison keeps reading columns, not jobs, so the plan now hands it `commitPlan` — each commit once. Gating stays a property of the commit (which revision is the newest), and whether a reproduction can fail anything stays a property of the engine. While writing this down: perf-lab never needed the base unstamped because its cases run on the base the prisma e2e seed writes straight into the database, which never went through the product's create-base path and so carries v2_enabled = false. Every case here builds its own base through the API — the thing that keeps cases from disturbing each other — and the API stamps it. --- .github/workflows/e2e-lab.yml | 19 ++++++++------- docs/operations/e2e-lab.md | 28 +++++++++++++++------- scripts/check-run-plan.mjs | 27 ++++++++++++++++----- scripts/run-plan-model.mjs | 44 ++++++++++++++++++++++++++++------- scripts/run-plan.mjs | 5 +++- 5 files changed, 91 insertions(+), 32 deletions(-) diff --git a/.github/workflows/e2e-lab.yml b/.github/workflows/e2e-lab.yml index 3ecfe6f..dbaff53 100644 --- a/.github/workflows/e2e-lab.yml +++ b/.github/workflows/e2e-lab.yml @@ -60,6 +60,7 @@ jobs: timeout-minutes: 10 outputs: execute_plan: ${{ steps.plan.outputs.execute_plan }} + commit_plan: ${{ steps.plan.outputs.commit_plan }} case_ids: ${{ steps.plan.outputs.case_ids }} plan_summary: ${{ steps.plan.outputs.plan_summary }} @@ -122,6 +123,13 @@ jobs: E2E_LAB_CASE_FILTER: ${{ inputs.case_filter }} run: node e2e-lab/scripts/run-plan.mjs + # One job per commit PER ENGINE. Two engines sharing a job was cheaper by one + # bootstrap and wrong in a way that only showed up on reflection: two passes + # against one database means the second engine runs on state the first left, + # and the guarded column is what would have been reading it. Split, each + # engine gets its own containers and its own database from the commit's own + # migrations, and the two run at the same time — the wall clock is one + # engine's, not two. execute: name: Run cases (${{ matrix.plan.name }}) needs: resolve_inputs @@ -206,12 +214,7 @@ jobs: export E2E_LAB_CASE_FILTER="${{ inputs.case_filter }}" export E2E_LAB_COMMIT_SHA="${{ matrix.plan.sha }}" export E2E_LAB_GATING="${{ matrix.plan.gating }}" - # Both engines, in one job. perf-lab gives each engine its own job - # because overlapping measurements pollute each other; bug - # observations do not, and a second job would re-pay the whole ~50s - # bootstrap to save one app boot. v2 is what this run is judged on; - # v1 rides along as the reference column. - export E2E_LAB_ENGINE_LIST="v1,v2" + export E2E_LAB_ENGINE_LIST="${{ matrix.plan.engine }}" pnpm -F @teable/backend-ee exec vitest run \ --config ./vitest-e2e-lab.config.ts \ @@ -277,7 +280,7 @@ jobs: - name: Build comparison table env: E2E_LAB_ARTIFACT_DIR: ${{ github.workspace }}/e2e-lab-artifacts - E2E_LAB_EXECUTE_PLAN: ${{ needs.resolve_inputs.outputs.execute_plan }} + E2E_LAB_EXECUTE_PLAN: ${{ needs.resolve_inputs.outputs.commit_plan }} E2E_LAB_CASE_FILTER: ${{ inputs.case_filter }} E2E_LAB_ENGINES: '["v1","v2"]' E2E_LAB_COMPARISON_PATH: ${{ github.workspace }}/e2e-lab-report/comparison.json @@ -301,7 +304,7 @@ jobs: continue-on-error: true env: E2E_LAB_ARTIFACT_DIR: ${{ github.workspace }}/e2e-lab-artifacts - E2E_LAB_EXECUTE_PLAN: ${{ needs.resolve_inputs.outputs.execute_plan }} + E2E_LAB_EXECUTE_PLAN: ${{ needs.resolve_inputs.outputs.commit_plan }} run: node e2e-lab/scripts/report-teable-track.mjs # The run's card, built from the same comparison.json the acceptance diff --git a/docs/operations/e2e-lab.md b/docs/operations/e2e-lab.md index 6c99d27..f883791 100644 --- a/docs/operations/e2e-lab.md +++ b/docs/operations/e2e-lab.md @@ -8,13 +8,20 @@ The executable path is deliberately the one teable-perf-lab proved out: resolves every requested ref to a pinned SHA. Everything downstream uses only those SHAs, so a branch moving mid-run cannot split the run across two revisions. -3. One `execute` job per commit: checkout teable-ee at the pinned SHA, inject - the lab into `community/apps/nestjs-backend/test/e2e-lab/`, build the - database from that commit's own migrations plus the standard e2e seed, and - run every selected case through `@teable/backend-ee`'s vitest — once per - engine, one Nest app each. Both engines share the job: perf-lab splits them - because overlapping measurements pollute each other, and bug observations do - not, so a second job would re-pay the whole bootstrap to save one app boot. +3. One `execute` job per commit **per engine**: checkout teable-ee at the + pinned SHA, inject the lab into + `community/apps/nestjs-backend/test/e2e-lab/`, build the database from that + commit's own migrations plus the standard e2e seed, and run every selected + case through `@teable/backend-ee`'s vitest. + + Both engines briefly shared a job, which was cheaper by one bootstrap and + wrong: two passes against one database means the second engine runs on + state the first left, and the guarded column is what would have been + reading it. Split, each engine has its own containers and its own database, + and the jobs run at the same time — the wall clock is one engine's, not + two. This is the arrangement teable-perf-lab has run both engines on all + along. + 4. `report` collects every payload, renders the bug × commit table, and enforces acceptance fail-closed. @@ -34,7 +41,12 @@ with the same case — and **nothing it reports fails a run** (`framework/verdict.ts`). It renders as its own table under the guarded one. Reaching v1 takes more than an environment switch, and the reason is worth -knowing before trusting any v1 cell. Routing asks `FORCE_V2_ALL` first and the +knowing before trusting any v1 cell. It is also the one thing teable-perf-lab +does not have to do: its cases run against the base the prisma e2e seed writes +straight into the database, which never went through the product's create-base +path and so carries `v2_enabled = false`. The switch alone decides there. Every +case here builds its own base through the API instead — that is what keeps +cases from disturbing each other — and the API stamps it. Routing asks `FORCE_V2_ALL` first and the base's own v2 flag second, and the product stamps every base it creates as v2 — so turning the switch off just falls through to the second rule. Measured 2026-08-27: a full 129-case run with the switch off produced **not one** diff --git a/scripts/check-run-plan.mjs b/scripts/check-run-plan.mjs index 305ad42..16b0a9f 100644 --- a/scripts/check-run-plan.mjs +++ b/scripts/check-run-plan.mjs @@ -14,14 +14,29 @@ const ALL_CASES = ["smoke/auth-user", "record/bulk-update-100-mixed-lands"]; caseFilter: "all", allCaseIds: ALL_CASES, }); - assert.equal(plan.executePlan.length, 2); - assert.equal(plan.executePlan[0].name, `c1-${shortSha(sha("a"))}`); - assert.equal(plan.executePlan[1].position, 2); - // Only the last commit gates — earlier columns are history. + // The matrix is commit x engine: two commits, two engines, four jobs. + assert.equal(plan.executePlan.length, 4); + assert.equal(plan.commitPlan.length, 2); + assert.equal(plan.executePlan[0].name, `c1-${shortSha(sha("a"))}-v1`); + assert.equal(plan.executePlan[1].name, `c1-${shortSha(sha("a"))}-v2`); + // Each job writes to its own artifact directory, or the two engines of one + // commit would overwrite each other's upload. + assert.equal( + new Set(plan.executePlan.map(({ artifactSuffix }) => artifactSuffix)).size, + 4, + ); + assert.equal(plan.commitPlan[1].position, 2); + // Only the last commit gates — earlier columns are history. Gating is a + // property of the commit, so both engines of that commit carry it; whether + // it can fail anything is the engine's business (framework/verdict.ts). assert.deepEqual( - plan.executePlan.map(({ gating }) => gating), + plan.commitPlan.map(({ gating }) => gating), [false, true], ); + assert.deepEqual( + plan.executePlan.map(({ gating }) => gating), + [false, false, true, true], + ); assert.deepEqual(plan.caseIds, ALL_CASES); // Both engines run: 2 commits x 2 cases x 2 engines. assert.equal(plan.planSummary.expectedPayloads, 8); @@ -67,7 +82,7 @@ const ALL_CASES = ["smoke/auth-user", "record/bulk-update-100-mixed-lands"]; allCaseIds: ALL_CASES, }); assert.deepEqual( - plan.executePlan.map(({ ref }) => ref), + plan.commitPlan.map(({ ref }) => ref), ["newer", "older"], ); assert.deepEqual(plan.caseIds, ["smoke/auth-user"]); diff --git a/scripts/run-plan-model.mjs b/scripts/run-plan-model.mjs index 7e73a07..c1ba2dc 100644 --- a/scripts/run-plan-model.mjs +++ b/scripts/run-plan-model.mjs @@ -5,9 +5,10 @@ import { resolveCaseFilter } from "./case-catalog.mjs"; -// One job per commit, and every commit pays a full bootstrap (install, prisma -// generate, migrate, seed). The bound is a runner-pool courtesy, not a design -// limit — raise it deliberately, not by deleting the check. +// One job per commit PER ENGINE, and every job pays a full bootstrap (install, +// prisma generate, migrate, seed). So the cap below is jobs/2, not jobs. The +// bound is a runner-pool courtesy, not a design limit — raise it deliberately, +// not by deleting the check. export const MAX_COMMITS = 8; const SHA_PATTERN = /^[0-9a-f]{40}$/; @@ -67,7 +68,7 @@ export const resolveRunPlan = ({ // declarations are enforced. Earlier columns are history — a fixed bug // reproducing there is the world before the fix, not a regression. Errors // fail on every column regardless (framework/verdict.ts). - const executePlan = resolvedCommits.map((commit, index) => ({ + const commitPlan = resolvedCommits.map((commit, index) => ({ name: `c${index + 1}-${shortSha(commit.sha)}`, position: index + 1, ref: commit.ref, @@ -77,6 +78,28 @@ export const resolveRunPlan = ({ gating: index === resolvedCommits.length - 1, })); + // The execute matrix is commit x engine, one job each. + // + // Sharing a job was cheaper by one bootstrap and wrong in a way that only + // showed up on reflection: two passes against ONE database means the second + // engine runs on state the first one left, and the guarded column is the one + // that would have been reading it. Separate jobs give each engine its own + // containers and its own database built from the commit's own migrations — + // the arrangement teable-perf-lab has run both engines on for a year — and + // they run at the same time, so the wall clock is one engine's, not two. + // + // `gating` stays a property of the COMMIT: whether a reproduction there is a + // regression is about which revision it is, and whether it can fail anything + // at all is about the engine (framework/verdict.ts). + const executePlan = commitPlan.flatMap((commit) => + engines.map((engine) => ({ + ...commit, + name: `${commit.name}-${engine}`, + engine, + artifactSuffix: `${commit.artifactSuffix}-${engine}`, + })), + ); + const skipped = new Set(skipV1CaseIds); const v1CaseCount = engines.includes("v1") ? caseIds.filter((id) => !skipped.has(id)).length @@ -85,18 +108,21 @@ export const resolveRunPlan = ({ return { executePlan, + // The comparison reads columns, not jobs: it must see each commit once. + commitPlan, caseIds, engines, planSummary: { - commitCount: executePlan.length, + commitCount: commitPlan.length, caseCount: caseIds.length, engines, v1CaseCount, // Only the v2 half is a contract the acceptance gate enforces; the v1 // half is counted so a reader can see the run got what it paid for. - expectedPayloads: executePlan.length * (v1CaseCount + v2CaseCount), - expectedGuardedPayloads: executePlan.length * v2CaseCount, - commits: executePlan.map(({ ref, short, gating }) => ({ + jobCount: executePlan.length, + expectedPayloads: commitPlan.length * (v1CaseCount + v2CaseCount), + expectedGuardedPayloads: commitPlan.length * v2CaseCount, + commits: commitPlan.map(({ ref, short, gating }) => ({ ref, short, gating, @@ -116,7 +142,7 @@ export const renderPlanSummaryMarkdown = (planSummary) => ) .join(", ")})`, `- Cases: ${planSummary.caseCount} (v2), ${planSummary.v1CaseCount} of them also asked of v1`, - `- Engines: ${(planSummary.engines ?? ["v2"]).join(", ")}`, + `- Engines: ${(planSummary.engines ?? ["v2"]).join(", ")} — ${planSummary.jobCount} execute job(s), one per commit per engine`, `- Expected payloads: ${planSummary.expectedPayloads} (${planSummary.expectedGuardedPayloads} guarded)`, "", ].join("\n"); diff --git a/scripts/run-plan.mjs b/scripts/run-plan.mjs index 75e625c..eff430b 100644 --- a/scripts/run-plan.mjs +++ b/scripts/run-plan.mjs @@ -4,7 +4,9 @@ // SHAs with git before calling this, so the plan // only ever contains pinned revisions. // E2E_LAB_CASE_FILTER case id, comma-separated ids, or "all". -// Writes GitHub outputs: execute_plan, case_ids, plan_summary. +// Writes GitHub outputs: execute_plan (the commit x engine job matrix), +// commit_plan (each commit once — the comparison reads columns, not jobs), +// case_ids, plan_summary. import { appendFileSync } from "node:fs"; import { dirname, join } from "node:path"; @@ -31,6 +33,7 @@ const main = async () => { process.env.GITHUB_OUTPUT, [ `execute_plan=${JSON.stringify(plan.executePlan)}`, + `commit_plan=${JSON.stringify(plan.commitPlan)}`, `case_ids=${JSON.stringify(plan.caseIds)}`, `plan_summary=${JSON.stringify(plan.planSummary)}`, "", From af74e9b8d2cb6c15d568be1866a7360cc267aebd Mon Sep 17 00:00:00 2001 From: HynLcc Date: Thu, 27 Aug 2026 17:58:17 +0800 Subject: [PATCH 14/22] Stop asking v1 the eight questions this method cannot put to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These cases have the product create a SECOND base mid-run — an import, a duplicate, a share saved into a new one. That base is stamped v2 on creation and cannot be unstamped before its tables are built, so half the case talked to v1 and half to v2, and a mixture is not evidence. It showed: four of the eight were red or 💥 on v1, and one flipped between two runs that agreed on everything else. Unstamping the whole space before each observation would reach them and would be worse. That base's tables were built by v2; unstamping only makes v1 read a structure v2 wrote, which is the exact false premise that got T6626 and T6595 thrown out this week — and it would be automatic and every run. The reason string says "this method cannot ask v1", not "v1 lacks the feature", because they are different claims and only the first is true here. The day a base can be created on the engine that asked for it, these eight lines come out. v1 now answers for 110 of 129 cases. The card gained one line for it — the card is what people read, so a reference column absent from it is a column nobody sees — and that line says it decides nothing. --- ...duplicated-base-in-the-recent-list.case.ts | 2 ++ ...py-a-share-past-a-panel-outside-it.case.ts | 2 ++ .../import-keeps-field-descriptions.case.ts | 2 ++ .../import-of-a-base-without-tables.case.ts | 2 ++ .../save-into-existing-base-twice.case.ts | 2 ++ cases/import/excel-duplicate-headers.case.ts | 2 ++ cases/import/excel-header-below-a1.case.ts | 2 ++ .../cross-base-link-clears-on-delete.case.ts | 2 ++ scripts/send-feishu-summary.mjs | 28 +++++++++++++++++++ 9 files changed, 44 insertions(+) diff --git a/cases/base-share/a-duplicated-base-in-the-recent-list.case.ts b/cases/base-share/a-duplicated-base-in-the-recent-list.case.ts index d6e129e..40ae100 100644 --- a/cases/base-share/a-duplicated-base-in-the-recent-list.case.ts +++ b/cases/base-share/a-duplicated-base-in-the-recent-list.case.ts @@ -11,6 +11,8 @@ export default defineBugCase({ title: "A freshly duplicated base is in the recent list", runner: "duplicate-base-recent-list", timeoutMs: 240_000, + skipV1: + "the case has the product create a second base mid-run, which is stamped v2 and cannot be unstamped before its tables are built - this method cannot ask v1, which is not the same as v1 lacking the feature", bug: { issue: "T2571", status: "fixed", diff --git a/cases/base-share/copy-a-share-past-a-panel-outside-it.case.ts b/cases/base-share/copy-a-share-past-a-panel-outside-it.case.ts index ca233ca..19debc5 100644 --- a/cases/base-share/copy-a-share-past-a-panel-outside-it.case.ts +++ b/cases/base-share/copy-a-share-past-a-panel-outside-it.case.ts @@ -11,6 +11,8 @@ export default defineBugCase({ title: "A share copies past a dashboard panel outside it", runner: "share-copy-outside-panel", timeoutMs: 240_000, + skipV1: + "the case has the product create a second base mid-run, which is stamped v2 and cannot be unstamped before its tables are built - this method cannot ask v1, which is not the same as v1 lacking the feature", bug: { issue: "T3516", status: "fixed", diff --git a/cases/base-share/import-keeps-field-descriptions.case.ts b/cases/base-share/import-keeps-field-descriptions.case.ts index 92bf145..ada1055 100644 --- a/cases/base-share/import-keeps-field-descriptions.case.ts +++ b/cases/base-share/import-keeps-field-descriptions.case.ts @@ -11,6 +11,8 @@ export default defineBugCase({ title: "A base carried out and back keeps its field descriptions", runner: "base-import-field-description", timeoutMs: 300_000, + skipV1: + "the case has the product create a second base mid-run, which is stamped v2 and cannot be unstamped before its tables are built - this method cannot ask v1, which is not the same as v1 lacking the feature", bug: { issue: "T6522", status: "fixed", diff --git a/cases/base-share/import-of-a-base-without-tables.case.ts b/cases/base-share/import-of-a-base-without-tables.case.ts index 4ddde7e..8e12d78 100644 --- a/cases/base-share/import-of-a-base-without-tables.case.ts +++ b/cases/base-share/import-of-a-base-without-tables.case.ts @@ -9,6 +9,8 @@ export default defineBugCase({ title: "A base with no tables can still be imported", runner: "base-import-field-description", timeoutMs: 300_000, + skipV1: + "the case has the product create a second base mid-run, which is stamped v2 and cannot be unstamped before its tables are built - this method cannot ask v1, which is not the same as v1 lacking the feature", bug: { issue: "T6522", status: "fixed", diff --git a/cases/base-share/save-into-existing-base-twice.case.ts b/cases/base-share/save-into-existing-base-twice.case.ts index d05a994..dd5328f 100644 --- a/cases/base-share/save-into-existing-base-twice.case.ts +++ b/cases/base-share/save-into-existing-base-twice.case.ts @@ -12,6 +12,8 @@ export default defineBugCase({ title: "Saving one share into the same base twice succeeds and stays visible", runner: "share-save", timeoutMs: 180_000, + skipV1: + "the case has the product create a second base mid-run, which is stamped v2 and cannot be unstamped before its tables are built - this method cannot ask v1, which is not the same as v1 lacking the feature", bug: { issue: "T6840", status: "fixed", diff --git a/cases/import/excel-duplicate-headers.case.ts b/cases/import/excel-duplicate-headers.case.ts index ed25527..44b69b3 100644 --- a/cases/import/excel-duplicate-headers.case.ts +++ b/cases/import/excel-duplicate-headers.case.ts @@ -13,6 +13,8 @@ export default defineBugCase({ "An Excel sheet with repeated column headers imports without colliding", runner: "excel-import-duplicate-columns", timeoutMs: 300_000, + skipV1: + "the case has the product create a second base mid-run, which is stamped v2 and cannot be unstamped before its tables are built - this method cannot ask v1, which is not the same as v1 lacking the feature", bug: { issue: "T6855", status: "fixed", diff --git a/cases/import/excel-header-below-a1.case.ts b/cases/import/excel-header-below-a1.case.ts index cdd306c..6e2eb32 100644 --- a/cases/import/excel-header-below-a1.case.ts +++ b/cases/import/excel-header-below-a1.case.ts @@ -13,6 +13,8 @@ export default defineBugCase({ title: "An Excel sheet starting below A1 imports its header row", runner: "excel-import-offset-header", timeoutMs: 180_000, + skipV1: + "the case has the product create a second base mid-run, which is stamped v2 and cannot be unstamped before its tables are built - this method cannot ask v1, which is not the same as v1 lacking the feature", bug: { issue: "T6867", status: "fixed", diff --git a/cases/link/cross-base-link-clears-on-delete.case.ts b/cases/link/cross-base-link-clears-on-delete.case.ts index 28b7093..9a59003 100644 --- a/cases/link/cross-base-link-clears-on-delete.case.ts +++ b/cases/link/cross-base-link-clears-on-delete.case.ts @@ -15,6 +15,8 @@ export default defineBugCase({ title: "Deleting a row clears the link that reaches it from another base", runner: "cross-base-link-delete", timeoutMs: 180_000, + skipV1: + "the case has the product create a second base mid-run, which is stamped v2 and cannot be unstamped before its tables are built - this method cannot ask v1, which is not the same as v1 lacking the feature", bug: { issue: "T6863", status: "fixed", diff --git a/scripts/send-feishu-summary.mjs b/scripts/send-feishu-summary.mjs index b462352..74e4efa 100644 --- a/scripts/send-feishu-summary.mjs +++ b/scripts/send-feishu-summary.mjs @@ -163,12 +163,40 @@ export const buildFeishuCard = ({ comparison, runUrl }) => { : null, ].filter(Boolean); + // One line for the reference engine, and only ever one. The card is what + // people actually read, so a v1 column nobody sees here is a column nobody + // sees — but it must not compete with the guarded result, and it must not + // grow with the number of cases. So: a count, no names, and it says plainly + // that it decided nothing. + const reference = (() => { + if (!comparison.engines?.includes("v1")) { + return null; + } + let present = 0; + let unrunnable = 0; + let skipped = 0; + for (const row of comparison.rows) { + for (const cell of row.referenceCells ?? []) { + if (cell.skipped) skipped += 1; + else if (cell.observed === "present") present += 1; + else if (cell.verdict === "error") unrunnable += 1; + } + } + const parts = [ + `${present} still reproduce`, + unrunnable > 0 ? `${unrunnable} could not run` : null, + skipped > 0 ? `${skipped} not asked` : null, + ].filter(Boolean); + return `v1 reference (decides nothing): ${parts.join(", ")}`; + })(); + const elements = [ { tag: "markdown", content: [ headline, needsHuman.length > 0 ? needsHuman.join(" · ") : null, + reference, `[Open the run and the full comparison table](${runUrl})`, ] .filter((line) => line !== null) From e766107a957fb03e93530143fcb52b072344f38a Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 3 Sep 2026 13:41:47 +0800 Subject: [PATCH 15/22] Ask the field endpoint what the field editor already asks (#133) * Ask the field endpoint what the field editor already asks Four cases from the last week of teable-ee fixes, all reachable through the public API and all reproduced on their own fix's parent. Two of them are the same missing check on two column types: a totalling column created through the API asking for something its source cannot give - the sum of a tickbox, a count over a button - was accepted and written, leaving a column that read 0.00 on every row and whose settings opened with no source and nothing selectable. The editor never offers those combinations; the endpoint took them anyway. They stay two cases because they were fixed two days apart in two places, and each is green on the other's parent. The third: a filter box produces text, and the row-number column was the one numeric column whose comparison refused it, answering 500 to a view a person had just built and saved. The fourth: a conditional column reading a table in another base dropped which base, so reopening its settings drew an accessible table as one the person has no permission to see. Also three rows in the triage ledger for what was read and not taken: an unshipped share-view scope bypass, a connection timeout no request can provoke, and a fix whose pre-fix state is "v1 answered", which this harness reads as the case being unable to run rather than as the bug. Co-Authored-By: Claude Opus 5 * Say in the doc that v1 never had the row-number filter problem The reference column answered correctly on every commit in the matrix, which is worth recording: customers still on the older engine never saw this one. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- ...ional-total-its-source-cannot-give.case.ts | 44 +++ ...onditional-total-its-source-cannot-give.md | 33 ++ ...-conditional-column-keeps-its-base.case.ts | 30 ++ ...-base-conditional-column-keeps-its-base.md | 40 +++ .../a-total-its-source-cannot-give.case.ts | 45 +++ cases/field/a-total-its-source-cannot-give.md | 50 +++ ...w-number-filter-typed-into-the-box.case.ts | 24 ++ .../a-row-number-filter-typed-into-the-box.md | 41 +++ docs/triage-ledger.md | 3 + framework/runner-registry.ts | 6 + .../autonumber-string-filter.runner.ts | 162 ++++++++++ .../cross-base-conditional-base-id.runner.ts | 266 ++++++++++++++++ .../rollup-create-compatibility.runner.ts | 288 ++++++++++++++++++ framework/types.ts | 51 ++++ registry.ts | 8 + 15 files changed, 1091 insertions(+) create mode 100644 cases/field/a-conditional-total-its-source-cannot-give.case.ts create mode 100644 cases/field/a-conditional-total-its-source-cannot-give.md create mode 100644 cases/field/a-cross-base-conditional-column-keeps-its-base.case.ts create mode 100644 cases/field/a-cross-base-conditional-column-keeps-its-base.md create mode 100644 cases/field/a-total-its-source-cannot-give.case.ts create mode 100644 cases/field/a-total-its-source-cannot-give.md create mode 100644 cases/filter/a-row-number-filter-typed-into-the-box.case.ts create mode 100644 cases/filter/a-row-number-filter-typed-into-the-box.md create mode 100644 framework/runners/autonumber-string-filter.runner.ts create mode 100644 framework/runners/cross-base-conditional-base-id.runner.ts create mode 100644 framework/runners/rollup-create-compatibility.runner.ts diff --git a/cases/field/a-conditional-total-its-source-cannot-give.case.ts b/cases/field/a-conditional-total-its-source-cannot-give.case.ts new file mode 100644 index 0000000..5cdfc06 --- /dev/null +++ b/cases/field/a-conditional-total-its-source-cannot-give.case.ts @@ -0,0 +1,44 @@ +import { defineBugCase } from "../../framework/types"; + +// T7087: the same missing check one column type over. A conditional total picks +// its source by matching rows rather than by following a link, and that path +// had its own create handling - so the validation added for ordinary totals did +// not cover it. The report is a field created through the API with a button as +// its source: it read 0.00 on every row, and reopening it showed no source at +// all. +export default defineBugCase({ + id: "field/a-conditional-total-its-source-cannot-give", + title: "A conditional total its source column cannot give is refused", + runner: "rollup-create-compatibility", + timeoutMs: 180_000, + skipV1: + "conditional totals are a v2 column type - v1 has neither the field nor the create validation", + bug: { + issue: "T7087", + status: "fixed", + sourceCommits: ["a7c1edd14"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-cond-rollup-compat", + column: "conditionalRollup", + matchKey: "the-only-group", + attempts: [ + { + name: "a count of buttons", + source: "button", + expression: "counta({values})", + }, + { + name: "all of a number", + source: "number", + expression: "and({values})", + }, + { + name: "the sum of a tickbox", + source: "checkbox", + expression: "sum({values})", + }, + ], + }, +}); diff --git a/cases/field/a-conditional-total-its-source-cannot-give.md b/cases/field/a-conditional-total-its-source-cannot-give.md new file mode 100644 index 0000000..5d10580 --- /dev/null +++ b/cases/field/a-conditional-total-its-source-cannot-give.md @@ -0,0 +1,33 @@ +# field/a-conditional-total-its-source-cannot-give + +**T7087** — fixed. On the `rollup-create-compatibility` runner. + +## What the user sees + +The same thing `field/a-total-its-source-cannot-give` describes, one column type +over. A conditional total — one that picks its rows by matching rather than by +following a link — was created through the API with a button as its source and a +count as its function. The API answered 201. The column showed `0.00` on every +row of the table, and reopening its settings showed no source field at all, +with nothing offered to replace it. + +## Why + +A conditional total resolves its source differently from an ordinary one, and +has its own create handling. The validation that covers ordinary totals did not +reach it. + +## What the checkpoint asserts + +Identical to the sibling case: each incompatible combination is refused with a +4xx **and** leaves no column behind, while a legal conditional total built from +the same pieces is accepted outside the checkpoint. + +The condition is a real one — the source table's match key compared against the +host row's — because a conditional total with no condition is a different code +path, and the report is about the one with a condition. + +## Why the two cases are separate + +They were fixed by two commits two days apart, in two places. Reading one green +column as covering the other is exactly the mistake the split prevents. diff --git a/cases/field/a-cross-base-conditional-column-keeps-its-base.case.ts b/cases/field/a-cross-base-conditional-column-keeps-its-base.case.ts new file mode 100644 index 0000000..ae5997f --- /dev/null +++ b/cases/field/a-cross-base-conditional-column-keeps-its-base.case.ts @@ -0,0 +1,30 @@ +import { defineBugCase } from "../../framework/types"; + +// T7064: a conditional column reading a table in another base needs to record +// which base that is. It was dropped on the way into storage, so reopening the +// column's settings found a foreign table it could not place and drew it as a +// table the person has no permission to see. The values kept arriving - only +// the settings could no longer describe themselves, which costs the ability to +// change the column at all. +export default defineBugCase({ + id: "field/a-cross-base-conditional-column-keeps-its-base", + title: "A conditional column reading another base still names that base", + runner: "cross-base-conditional-base-id", + timeoutMs: 300_000, + skipV1: + "conditional lookups and totals are v2 column types, and the dropped base id is on the v1-v2 mapping boundary this fix moved", + bug: { + issue: "T7064", + status: "fixed", + sourceCommits: ["e552c5e88"], + }, + config: { + namePrefix: "e2e-lab-cross-base-conditional", + matchedCategory: "hardware", + sourceRows: [ + { category: "hardware", amount: 100 }, + { category: "hardware", amount: 50 }, + { category: "software", amount: 70 }, + ], + }, +}); diff --git a/cases/field/a-cross-base-conditional-column-keeps-its-base.md b/cases/field/a-cross-base-conditional-column-keeps-its-base.md new file mode 100644 index 0000000..94b0e5f --- /dev/null +++ b/cases/field/a-cross-base-conditional-column-keeps-its-base.md @@ -0,0 +1,40 @@ +# field/a-cross-base-conditional-column-keeps-its-base + +**T7064** — fixed. On the `cross-base-conditional-base-id` runner. + +## What the user sees + +A conditional lookup or conditional total is pointed at a table in a **different +base**. It works: the values arrive and stay correct. Reopen the column's +settings and the foreign table is drawn as a table the person has no permission +to see. + +Nothing is actually inaccessible. But the column can no longer be changed from +that screen, and a save made from it writes the settings back with the base +already missing. + +## Why + +The column stores three things: which table, which column, and — when the table +is not in this base — which base. The third was dropped crossing the mapping +boundary between the two record engines. What was read back named a table with +no base to resolve it in, and "cannot resolve" renders as "no permission". + +## What the checkpoint asserts + +The field list — which is what the settings screen loads — still carries the +foreign base id, on both the conditional lookup (`lookupOptions.baseId`) and the +conditional total (`options.baseId`). Both, because the fix threaded the id +through two column types and one of them could regress alone. + +Outside the checkpoint, the columns are read once and must hold the values from +the other base. A column that never computed would have nothing meaningful to +say about its source either, and this case is about a column that works and +still cannot describe itself. + +The second base is created in the same space as the host's. Across spaces the +product refuses the link outright — "cross-space link is no longer supported" — +so the state this case is about only exists between two bases of one space. + +The engine is asserted on the create response of the cross-base column itself — +the request that puts the state under test in place. diff --git a/cases/field/a-total-its-source-cannot-give.case.ts b/cases/field/a-total-its-source-cannot-give.case.ts new file mode 100644 index 0000000..faf0367 --- /dev/null +++ b/cases/field/a-total-its-source-cannot-give.case.ts @@ -0,0 +1,45 @@ +import { defineBugCase } from "../../framework/types"; + +// T7046: the field editor knows which functions each column type supports and +// offers only those. The API did not check, so a total that no source could +// produce - the sum of a tickbox, a total over a button - was accepted and +// written. What came back was a column reading 0.00 on every row whose editor +// opened with an empty source box and nothing selectable in it: it could not be +// corrected, only deleted. Automations and integrations write fields through +// this endpoint and never see the editor. +export default defineBugCase({ + id: "field/a-total-its-source-cannot-give", + title: "A total its source column cannot give is refused, not created", + runner: "rollup-create-compatibility", + timeoutMs: 180_000, + skipV1: + "v1 does not validate field creates at all, so it answers the same on both sides of this fix and the column it leaves is a different artefact", + bug: { + issue: "T7046", + status: "fixed", + sourceCommits: ["4bb07b0b6"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-rollup-compat", + column: "rollup", + matchKey: "the-only-group", + attempts: [ + { + name: "all of a number", + source: "number", + expression: "and({values})", + }, + { + name: "the sum of a tickbox", + source: "checkbox", + expression: "sum({values})", + }, + { + name: "a count of buttons", + source: "button", + expression: "countall({values})", + }, + ], + }, +}); diff --git a/cases/field/a-total-its-source-cannot-give.md b/cases/field/a-total-its-source-cannot-give.md new file mode 100644 index 0000000..5a85e44 --- /dev/null +++ b/cases/field/a-total-its-source-cannot-give.md @@ -0,0 +1,50 @@ +# field/a-total-its-source-cannot-give + +**T7046** — fixed. On the `rollup-create-compatibility` runner. + +## What the user sees + +A totalling column is created through the API asking for something its source +cannot give: the sum of a tickbox, all-of on a number, a count over a button. +The request is accepted. What lands is a column that reads `0.00` on every row +and whose settings open with an empty source box and nothing selectable in it — +it cannot be corrected, only deleted and rebuilt. + +The field editor never offers these combinations; it knows which functions each +column type supports. So this is reached by whatever writes fields without the +editor: an automation, an integration, a script. + +## Why + +The support matrix lived in the editor. The create endpoint took the field's +type, its source, and its function on trust, and only found out later — at +computation time — that the three did not go together. By then the row was +written. + +## What the checkpoint asserts + +Two things, and both are needed: + +- the request is refused with a 4xx, and +- the column is not in the table afterwards. + +A 4xx that still wrote the row would leave exactly the unusable column the +report is about, so a status check on its own would pass over the bug. + +Outside the checkpoint, a **legal** total is created out of the same tables and +the same source column, and must be accepted. That rules out the reading which +would make this case worthless: an endpoint refusing every total would answer +4xx to the checkpoint too and look like the fix. + +The requests go through raw axios with the status left open — the generated +client throws on a non-2xx and drops the response, routing headers and all. + +The engine is asserted on the setup field create, which is the same endpoint and +the same `createField` feature the checkpoint uses, so a v1 answer is an error +rather than a green column. + +## The sibling next to it + +`field/a-conditional-total-its-source-cannot-give` (T7087) is the same missing +check on the conditional column type, fixed separately a day later. Same runner, +`column: "conditionalRollup"`. diff --git a/cases/filter/a-row-number-filter-typed-into-the-box.case.ts b/cases/filter/a-row-number-filter-typed-into-the-box.case.ts new file mode 100644 index 0000000..111fa70 --- /dev/null +++ b/cases/filter/a-row-number-filter-typed-into-the-box.case.ts @@ -0,0 +1,24 @@ +import { defineBugCase } from "../../framework/types"; + +// T7071: a filter box produces text, and every numeric column took the number +// that way - except the row-number column, whose comparison demanded a real +// number and answered 500 to a string. The page saved the filter and then broke +// on the row count, so the view a person had just built would not open, and +// would not open again on the next visit either. +export default defineBugCase({ + id: "filter/a-row-number-filter-typed-into-the-box", + title: "A row-number filter holding what the filter box typed", + runner: "autonumber-string-filter", + timeoutMs: 180_000, + bug: { + issue: "T7071", + status: "fixed", + sourceCommits: ["d9f5e61c6"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-autonumber-filter", + rowTitles: ["row-a", "row-b", "row-c", "row-d", "row-e"], + threshold: 2, + }, +}); diff --git a/cases/filter/a-row-number-filter-typed-into-the-box.md b/cases/filter/a-row-number-filter-typed-into-the-box.md new file mode 100644 index 0000000..678cc11 --- /dev/null +++ b/cases/filter/a-row-number-filter-typed-into-the-box.md @@ -0,0 +1,41 @@ +# filter/a-row-number-filter-typed-into-the-box + +**T7071** — fixed. On the `autonumber-string-filter` runner. + +## What the user sees + +A view is filtered on the row-number column, "greater than 50". The filter +saves. The page then fails to draw — the row count behind it answers 500 — and +it fails the same way on every later visit, because the filter is stored on the +view and loaded again each time. + +## Why + +A filter box produces text. That is what the grid sends for numeric columns, and +every numeric column accepted it — except the row-number column, whose +comparison required an actual number and refused the string outright. + +## What the checkpoint asserts + +The row count comes back at all, and the rows behind it are the ones the filter +describes. Both, because the count and the listing are separate paths through +the same comparison: a count that answered a plausible number while the listing +disagreed would be a different bug still worth failing on. + +The expected answer is derived from the numbers the product itself assigned, +not written into the case, so the case does not depend on how the row-number +column happens to start counting. + +The fixture rejects a threshold that selects all the rows or none of them. A +filter that changes nothing cannot tell a comparison that ran from one that +never did. + +The row-number column is added **after** the rows, which is how a table gets one +in practice: the column numbers what is already there. + +## The v1 column + +This case is not skipped on v1, and v1 answers it correctly on every commit, +including the ones where v2 refuses. The string the filter box sends was only +ever a problem for the newer engine — worth knowing, because it means customers +still on v1 never saw this. diff --git a/docs/triage-ledger.md b/docs/triage-ledger.md index fa214de..38bd804 100644 --- a/docs/triage-ledger.md +++ b/docs/triage-ledger.md @@ -137,6 +137,9 @@ The shape is gone; the runner is not kept. | `0548611b2` | T6576 | Not attempted. The commit's own reproduction is skipped under forced v2 - the spec gates it on the v1 path - and the lab forces v2, so the case could not go red. Same reason as the T5496 and T3303 rows. | | `7cb4431e9` | T6502 | Not attempted, same reason: the commit covers the shape with a forced-v1 e2e, and the lab forces v2. | | `057443dd6` | T6719 | Not attempted. The crash needs a preview flag that turns on a different record-query wrapper; the lab does not set it, so grid statistics take the ordinary path and nothing goes red. | +| `f160eea3b` | T7065 | Not taken while the fix is unshipped. A share-view scope bypass on the selection `*-by-id` endpoints, CVSS 8.1: the issue was still at "deployed to staging" when this batch was written, and a case here is a working public reproduction. It is a good case once it ships - the repro is a single request with a share header - so this row is a reminder, not a rejection. See CONTRIBUTING.md. | +| `ae70b638b` | T7104 | The failure is a connection timeout inside a `table.update` schema operation that then dead-letters after three attempts. What the fix changes is how that timeout is settled - rollback rather than an unrepairable failure - and the lab has no way to make a connection time out on request. Same async-runner trap as T6768 and T6853. | +| `8d5c0fe38` | T7067 | Selection aggregation was being answered by v1, where a date column met a cast v1 cannot do. The fix routes it to v2. That makes the pre-fix state "v1 answered", which `assertServedByV2` treats as the case being unable to run (💥) rather than as the bug - so the column that should be red is the one column the harness refuses to read. The observation is real and reachable; expressing it needs a runner allowed to assert that a request was **not** on v2, which does not exist here. | ### The date comparison inside AND or OR diff --git a/framework/runner-registry.ts b/framework/runner-registry.ts index 6e4f8e4..f0cd683 100644 --- a/framework/runner-registry.ts +++ b/framework/runner-registry.ts @@ -109,6 +109,9 @@ import { runTrackedModifiedSortCase } from "./runners/tracked-modified-sort.runn import { runLookupOfLinkContainsCase } from "./runners/lookup-of-link-contains.runner"; import { runDeleteWithoutUndoCaptureCase } from "./runners/delete-without-undo-capture.runner"; import { runSingleFieldPendingStateCase } from "./runners/single-field-pending-state.runner"; +import { runRollupCreateCompatibilityCase } from "./runners/rollup-create-compatibility.runner"; +import { runAutonumberStringFilterCase } from "./runners/autonumber-string-filter.runner"; +import { runCrossBaseConditionalBaseIdCase } from "./runners/cross-base-conditional-base-id.runner"; import { runLookupOfRollupCreateCase } from "./runners/lookup-of-rollup-create.runner"; import type { BugCase, @@ -239,6 +242,9 @@ const runners: { [K in BugRunnerKind]: RunnerFn } = { "lookup-of-link-contains": runLookupOfLinkContainsCase, "delete-without-undo-capture": runDeleteWithoutUndoCaptureCase, "single-field-pending-state": runSingleFieldPendingStateCase, + "rollup-create-compatibility": runRollupCreateCompatibilityCase, + "autonumber-string-filter": runAutonumberStringFilterCase, + "cross-base-conditional-base-id": runCrossBaseConditionalBaseIdCase, }; export const executeRegisteredRunner = ( diff --git a/framework/runners/autonumber-string-filter.runner.ts b/framework/runners/autonumber-string-filter.runner.ts new file mode 100644 index 0000000..283b3c5 --- /dev/null +++ b/framework/runners/autonumber-string-filter.runner.ts @@ -0,0 +1,162 @@ +import { and, FieldKeyType, FieldType, isGreater } from "@teable/core"; +import { getRecords as apiGetRecords, getRowCount } from "@teable/openapi"; +import { + createField, + createTable, + permanentDeleteTable, +} from "../../../utils/init-app"; +import { bugCheckpoint } from "../checkpoint"; +import { assertServedByV2 } from "../engine"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { AutonumberStringFilterCaseConfig } from "../types"; + +// A view filtered on the row-number column, "greater than 50" -> checkpoint: +// the row count comes back, and the rows behind it are the ones the filter +// describes. +// +// The number typed into a filter box arrives as text - that is what a text box +// produces, and it is what the grid sends for every numeric column. The +// row-number column was the one place that was not allowed for: the comparison +// demanded an actual number, refused the string, and answered 500. The page +// showed the filter as saved and then broke on the count, so the view a person +// had just built would not open at all. +// +// A saved filter is worse than a failed one: it is loaded again on every visit, +// so the view stays broken until someone works out that the filter is what did +// it. +// +// The count is checked against the rows, not against a number written into the +// case. Comparing two answers from the product catches the failure a hardcoded +// expectation cannot: a count that returns a plausible-looking wrong number +// while the rows disagree with it. + +const TITLE_FIELD = "Title"; +const ROW_NUMBER_FIELD = "No."; + +export const runAutonumberStringFilterCase = async ( + bugCase: BugCaseFor<"autonumber-string-filter">, + context: BugRunContext, +): Promise => { + const config: AutonumberStringFilterCaseConfig = bugCase.config; + const baseId = globalThis.testConfig.baseId; + const suffix = `${config.tableNamePrefix}-${context.runId}`; + let tableId = ""; + + try { + const table = await createTable(baseId, { + name: `${suffix}-rows`, + fields: [{ name: TITLE_FIELD, type: FieldType.SingleLineText }], + records: config.rowTitles.map((title) => ({ + fields: { [TITLE_FIELD]: title }, + })), + }); + tableId = table.id; + + // Added after the rows, which is how a real table gets one: the column + // numbers what is already there. + const rowNumber = await createField(table.id, { + name: ROW_NUMBER_FIELD, + type: FieldType.AutoNumber, + }); + + // The engine assertion, on a read of this table's rows - the same endpoint + // and the same feature the checkpoint's filtered read uses. The response + // is also what the expected answer is derived from, so this is not a + // separate probe. + const listed = await apiGetRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + take: config.rowTitles.length, + }); + const routing = assertServedByV2(listed.headers, { + operation: "GET /table/{tableId}/record", + feature: "getRecords", + }); + + const numbers = listed.data.records.map((record) => + Number(record.fields[rowNumber.id]), + ); + if (numbers.some((value) => !Number.isFinite(value))) { + throw new Error( + `the row-number column did not number every row: ${JSON.stringify(numbers)}`, + ); + } + + const expected = numbers + .filter((value) => value > config.threshold) + .sort((left, right) => left - right); + if (expected.length === 0 || expected.length === numbers.length) { + throw new Error( + `"greater than ${config.threshold}" selects ${expected.length} of ${numbers.length} rows - ` + + "a filter that selects all or none cannot tell a working comparison from a missing one", + ); + } + + // What a filter box sends: the number as text. + const filter = { + conjunction: and.value, + filterSet: [ + { + fieldId: rowNumber.id, + operator: isGreater.value, + value: String(config.threshold), + }, + ], + }; + + const probe = await bugCheckpoint( + "a-row-number-filter-holding-text-counts-and-lists", + async () => { + // Refused before the fix, and a refusal throws here, which is the + // report. + const counted = await getRowCount(tableId, { filter }); + const rowCount = counted.data.rowCount; + + const filtered = await apiGetRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + filter, + }); + const listedNumbers = filtered.data.records + .map((record) => Number(record.fields[rowNumber.id])) + .sort((left, right) => left - right); + + if (JSON.stringify(listedNumbers) !== JSON.stringify(expected)) { + throw new Error( + `"greater than ${config.threshold}" listed rows ${JSON.stringify(listedNumbers)}, ` + + `expected ${JSON.stringify(expected)} out of ${JSON.stringify(numbers)}`, + ); + } + if (rowCount !== expected.length) { + throw new Error( + `the count says ${rowCount} row(s) while the same filter lists ` + + `${JSON.stringify(listedNumbers)}`, + ); + } + return { rowCount, listedNumbers }; + }, + ); + + return { + details: { + tableId, + rowNumberFieldId: rowNumber.id, + threshold: config.threshold, + allNumbers: numbers, + routing, + ...probe, + }, + }; + } finally { + if (tableId) { + try { + await permanentDeleteTable(baseId, tableId); + } catch (error) { + // Cleanup is the case's own housekeeping - the product did not fail. + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/runners/cross-base-conditional-base-id.runner.ts b/framework/runners/cross-base-conditional-base-id.runner.ts new file mode 100644 index 0000000..5b94d98 --- /dev/null +++ b/framework/runners/cross-base-conditional-base-id.runner.ts @@ -0,0 +1,266 @@ +import { FieldKeyType, FieldType } from "@teable/core"; +import { + axios, + getRecords as apiGetRecords, + CREATE_FIELD, + GET_FIELD_LIST, + urlBuilder, +} from "@teable/openapi"; +import { + createBase, + createTable, + deleteBase, + permanentDeleteBase, + permanentDeleteTable, +} from "../../../utils/init-app"; +import { bugCheckpoint } from "../checkpoint"; +import { assertServedByV2 } from "../engine"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { CrossBaseConditionalBaseIdCaseConfig } from "../types"; + +// A conditional column reading a table in ANOTHER base -> open its settings +// again -> checkpoint: the settings still name the base it reads. +// +// A conditional lookup or total needs three things: which table, which column, +// and - when the table is not in this base - which base. The first two were +// stored and the third was dropped, so reopening the column's settings found +// the foreign table with no base to look it up in and drew it as a table the +// person has no permission to see. +// +// Nothing else went wrong. The values kept arriving, because the computation +// had already resolved the table; only the settings could not describe +// themselves any more. What that costs is the ability to change the column: a +// person who opens it sees a permission problem that does not exist, and any +// save from that screen writes back settings with the base already missing. +// +// The values are read outside the checkpoint, before the settings are, and +// that order is deliberate: a column that never computed would also have +// nothing sensible to say about its source, and this case is about a column +// that works. + +const CATEGORY_FIELD = "Category"; +const AMOUNT_FIELD = "Amount"; +const HOST_MATCH_FIELD = "CategoryMatch"; +const LOOKUP_FIELD = "Amounts over there"; +const ROLLUP_FIELD = "Total over there"; + +interface FieldSummary { + id: string; + name: string; + options?: Record; + lookupOptions?: Record; +} + +export const runCrossBaseConditionalBaseIdCase = async ( + bugCase: BugCaseFor<"cross-base-conditional-base-id">, + context: BugRunContext, +): Promise => { + const config: CrossBaseConditionalBaseIdCaseConfig = bugCase.config; + const hostBaseId = globalThis.testConfig.baseId; + const suffix = `${config.namePrefix}-${context.runId}`; + let foreignBaseId = ""; + let hostTableId = ""; + + try { + // A second base beside the host's, in the SAME space. Across spaces the + // product refuses the link outright ("cross-space link is no longer + // supported"), so the state this case is about only exists inside one. + const foreignBase = await createBase({ + spaceId: globalThis.testConfig.spaceId, + name: `${suffix}-other`, + }); + foreignBaseId = foreignBase.id; + + const foreign = await createTable(foreignBase.id, { + name: `${suffix}-source`, + fields: [ + { name: CATEGORY_FIELD, type: FieldType.SingleLineText }, + { name: AMOUNT_FIELD, type: FieldType.Number }, + ], + records: config.sourceRows.map((row) => ({ + fields: { [CATEGORY_FIELD]: row.category, [AMOUNT_FIELD]: row.amount }, + })), + }); + const foreignCategoryId = foreign.fields.find( + (field: { name: string }) => field.name === CATEGORY_FIELD, + )?.id as string; + const foreignAmountId = foreign.fields.find( + (field: { name: string }) => field.name === AMOUNT_FIELD, + )?.id as string; + + const host = await createTable(hostBaseId, { + name: `${suffix}-host`, + fields: [{ name: HOST_MATCH_FIELD, type: FieldType.SingleLineText }], + records: [{ fields: { [HOST_MATCH_FIELD]: config.matchedCategory } }], + }); + hostTableId = host.id; + const hostMatchId = host.fields.find( + (field: { name: string }) => field.name === HOST_MATCH_FIELD, + )?.id as string; + if (!foreignCategoryId || !foreignAmountId || !hostMatchId) { + throw new Error("the fixture tables are not in place"); + } + + const matchFilter = { + conjunction: "and", + filterSet: [ + { + fieldId: foreignCategoryId, + operator: "is", + value: { type: "field", fieldId: hostMatchId }, + }, + ], + }; + + const createFieldRaw = (body: unknown) => + axios.post(urlBuilder(CREATE_FIELD, { tableId: host.id }), body, { + validateStatus: () => true, + }); + + // The column that reads across the base boundary. Its own create response + // carries the routing headers, so the engine is asserted on the request + // that puts the state under test in place rather than on a probe beside it. + const lookupResponse = await createFieldRaw({ + name: LOOKUP_FIELD, + type: FieldType.Number, + isLookup: true, + isConditionalLookup: true, + lookupOptions: { + baseId: foreignBase.id, + foreignTableId: foreign.id, + lookupFieldId: foreignAmountId, + filter: matchFilter, + }, + }); + if (lookupResponse.status !== 201) { + throw new Error( + `the cross-base conditional lookup was refused (${lookupResponse.status}): ${JSON.stringify(lookupResponse.data)}`, + ); + } + const routing = assertServedByV2(lookupResponse.headers, { + operation: "POST /table/{tableId}/field", + feature: "createField", + }); + const lookupField = lookupResponse.data as FieldSummary; + + const rollupResponse = await createFieldRaw({ + name: ROLLUP_FIELD, + type: FieldType.ConditionalRollup, + options: { + baseId: foreignBase.id, + foreignTableId: foreign.id, + lookupFieldId: foreignAmountId, + expression: "sum({values})", + filter: matchFilter, + }, + }); + if (rollupResponse.status !== 201) { + throw new Error( + `the cross-base conditional rollup was refused (${rollupResponse.status}): ${JSON.stringify(rollupResponse.data)}`, + ); + } + const rollupField = rollupResponse.data as FieldSummary; + + // Fixture verification, outside the checkpoint: the columns really do read + // across the boundary. A column that computed nothing would have no source + // worth asking about. + const expectedValues = config.sourceRows + .filter((row) => row.category === config.matchedCategory) + .map((row) => row.amount); + if (expectedValues.length === 0) { + throw new Error( + `no source row carries "${config.matchedCategory}" - the columns would read empty either way`, + ); + } + const rows = await apiGetRecords(host.id, { + fieldKeyType: FieldKeyType.Id, + take: 1, + }); + const cell = rows.data.records[0]?.fields[lookupField.id]; + const total = rows.data.records[0]?.fields[rollupField.id]; + if (JSON.stringify(cell) !== JSON.stringify(expectedValues)) { + throw new Error( + `the cross-base column reads ${JSON.stringify(cell)}, expected ${JSON.stringify(expectedValues)} - the fixture did not compute`, + ); + } + const expectedTotal = expectedValues.reduce((sum, value) => sum + value, 0); + if (Number(total) !== expectedTotal) { + throw new Error( + `the cross-base total reads ${JSON.stringify(total)}, expected ${expectedTotal} - the fixture did not compute`, + ); + } + + const probe = await bugCheckpoint( + "a-cross-base-conditional-column-still-names-its-base", + async () => { + // What the settings screen loads when it is reopened. + const listed = await axios.get( + urlBuilder(GET_FIELD_LIST, { tableId: host.id }), + ); + const readBack = (fieldId: string, name: string) => { + const field = listed.data.find( + (candidate) => candidate.id === fieldId, + ); + if (!field) { + throw new Error(`the ${name} column is gone from the table`); + } + return field; + }; + + const lookupBack = readBack(lookupField.id, "conditional lookup"); + const rollupBack = readBack(rollupField.id, "conditional rollup"); + const lookupBaseId = lookupBack.lookupOptions?.baseId; + const rollupBaseId = rollupBack.options?.baseId; + + if (lookupBaseId !== foreignBase.id) { + throw new Error( + `the conditional lookup came back naming base ${JSON.stringify(lookupBaseId)}, ` + + `expected ${foreignBase.id}. Its whole settings read: ${JSON.stringify(lookupBack.lookupOptions)}`, + ); + } + if (rollupBaseId !== foreignBase.id) { + throw new Error( + `the conditional rollup came back naming base ${JSON.stringify(rollupBaseId)}, ` + + `expected ${foreignBase.id}. Its whole settings read: ${JSON.stringify(rollupBack.options)}`, + ); + } + return { lookupBaseId, rollupBaseId }; + }, + ); + + return { + details: { + hostTableId: host.id, + foreignBaseId: foreignBase.id, + foreignTableId: foreign.id, + routing, + ...probe, + }, + }; + } finally { + if (hostTableId) { + try { + await permanentDeleteTable(hostBaseId, hostTableId); + } catch (error) { + // Cleanup is the case's own housekeeping - the product did not fail. + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${hostTableId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + if (foreignBaseId) { + try { + await deleteBase(foreignBaseId); + await permanentDeleteBase(foreignBaseId); + } catch (error) { + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (base ${foreignBaseId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/runners/rollup-create-compatibility.runner.ts b/framework/runners/rollup-create-compatibility.runner.ts new file mode 100644 index 0000000..3e7f6b4 --- /dev/null +++ b/framework/runners/rollup-create-compatibility.runner.ts @@ -0,0 +1,288 @@ +import { Colors, FieldType, Relationship } from "@teable/core"; +import { + axios, + CREATE_FIELD, + GET_FIELD_LIST, + urlBuilder, +} from "@teable/openapi"; +import { createTable, permanentDeleteTable } from "../../../utils/init-app"; +import { bugCheckpoint } from "../checkpoint"; +import { assertServedByV2 } from "../engine"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { RollupCreateCompatibilityCaseConfig } from "../types"; + +// A totalling column asked for a total its source cannot give - the average of +// a tickbox, the sum of a button - sent straight to the API -> checkpoint: the +// request is refused and no column is left behind. +// +// The field editor never offers these combinations: it knows which functions +// each source type supports and hides the rest. The API took them anyway and +// answered 201. What it created was a column with no working source: it read +// 0.00 on every row, its editor opened with the source box empty and nothing +// selectable in it, and there was no way to correct it - only to delete it and +// start again. Automations and integrations, which reach the API directly and +// never see the editor, are how these get written. +// +// So the observation is two things at once, and both matter. The request must +// be refused, and the column must not be there afterwards: a 4xx that still +// persisted the field would leave exactly the mess the report is about. +// +// The same shape twice, on one runner: `rollup` totals across a link, +// `conditionalRollup` totals across a match. Different code paths, one +// question - does the API check what the editor checks. + +const HOST_LABEL_FIELD = "Label"; +const MATCH_KEY_FIELD = "MatchKey"; +const AMOUNT_FIELD = "Amount"; +const FLAG_FIELD = "Flag"; +const BUTTON_FIELD = "Action"; +const LINK_FIELD = "Children"; +const LEGAL_FIELD = "A total that is allowed"; +const ILLEGAL_FIELD_PREFIX = "A total that is not allowed:"; + +type Source = RollupCreateCompatibilityCaseConfig["attempts"][number]["source"]; + +const sourceFieldName: Record = { + number: AMOUNT_FIELD, + checkbox: FLAG_FIELD, + button: BUTTON_FIELD, +}; + +interface FieldSummary { + id: string; + name: string; +} + +export const runRollupCreateCompatibilityCase = async ( + bugCase: BugCaseFor<"rollup-create-compatibility">, + context: BugRunContext, +): Promise => { + const config: RollupCreateCompatibilityCaseConfig = bugCase.config; + const baseId = globalThis.testConfig.baseId; + const suffix = `${config.tableNamePrefix}-${context.runId}`; + const createdTableIds: string[] = []; + + if (config.attempts.length === 0) { + throw new Error( + "no combinations to attempt - the checkpoint would assert nothing and pass on every column", + ); + } + + const createFieldRaw = (tableId: string, body: unknown) => + axios.post(urlBuilder(CREATE_FIELD, { tableId }), body, { + // The status is left open because the request under test is refused + // after the fix, and the generated client throws on a non-2xx and + // drops the response - routing headers and body with it. + validateStatus: () => true, + }); + + const listFields = async (tableId: string): Promise => { + const response = await axios.get( + urlBuilder(GET_FIELD_LIST, { tableId }), + ); + return response.data; + }; + + try { + // The other table: one column of each kind a total could be asked for. + const foreign = await createTable(baseId, { + name: `${suffix}-source`, + fields: [ + { name: MATCH_KEY_FIELD, type: FieldType.SingleLineText }, + { name: AMOUNT_FIELD, type: FieldType.Number }, + { name: FLAG_FIELD, type: FieldType.Checkbox }, + { + name: BUTTON_FIELD, + type: FieldType.Button, + options: { label: "Run", color: Colors.Teal }, + }, + ], + records: [{ fields: { [MATCH_KEY_FIELD]: config.matchKey } }], + }); + createdTableIds.unshift(foreign.id); + + const foreignFieldId = (name: string) => { + const found = foreign.fields.find( + (field: { name: string }) => field.name === name, + )?.id; + if (!found) { + throw new Error(`the source table has no "${name}" column`); + } + return found as string; + }; + + // The table the total would live on. + const host = await createTable(baseId, { + name: `${suffix}-host`, + fields: [{ name: HOST_LABEL_FIELD, type: FieldType.SingleLineText }], + records: [{ fields: { [HOST_LABEL_FIELD]: config.matchKey } }], + }); + createdTableIds.unshift(host.id); + + // The one column the host still needs, added through the same endpoint the + // checkpoint uses: a link for the rollup to follow, a match key for the + // conditional rollup to match on. Asserting the engine HERE is asserting it + // on the request under test - same route, same feature - while a routing + // failure still reads as "the case could not run" rather than as the bug. + const setupField = + config.column === "rollup" + ? await createFieldRaw(host.id, { + name: LINK_FIELD, + type: FieldType.Link, + options: { + relationship: Relationship.OneMany, + foreignTableId: foreign.id, + }, + }) + : await createFieldRaw(host.id, { + name: MATCH_KEY_FIELD, + type: FieldType.SingleLineText, + }); + if (setupField.status !== 201) { + throw new Error( + `the fixture column was refused (${setupField.status}): ${JSON.stringify(setupField.data)}`, + ); + } + const routing = assertServedByV2(setupField.headers, { + operation: "POST /table/{tableId}/field", + feature: "createField", + }); + const setupFieldId = (setupField.data as FieldSummary).id; + + // The legal total, built the same way out of the same pieces. It exists to + // rule out the reading that makes this case worthless: an endpoint that + // refuses every total would answer 4xx to the checkpoint too, and look + // like the fix. + const legalBody = + config.column === "rollup" + ? { + name: LEGAL_FIELD, + type: FieldType.Rollup, + options: { expression: "sum({values})" }, + lookupOptions: { + foreignTableId: foreign.id, + linkFieldId: setupFieldId, + lookupFieldId: foreignFieldId(AMOUNT_FIELD), + }, + } + : { + name: LEGAL_FIELD, + type: FieldType.ConditionalRollup, + options: { + foreignTableId: foreign.id, + lookupFieldId: foreignFieldId(AMOUNT_FIELD), + expression: "sum({values})", + filter: { + conjunction: "and", + filterSet: [ + { + fieldId: foreignFieldId(MATCH_KEY_FIELD), + operator: "is", + value: { type: "field", fieldId: setupFieldId }, + }, + ], + }, + }, + }; + const legal = await createFieldRaw(host.id, legalBody); + if (legal.status !== 201) { + throw new Error( + `a legal ${config.column} was refused (${legal.status}), so a refusal in the checkpoint would prove nothing: ${JSON.stringify(legal.data)}`, + ); + } + + const illegalBody = (source: Source, expression: string, name: string) => + config.column === "rollup" + ? { + name, + type: FieldType.Rollup, + options: { expression }, + lookupOptions: { + foreignTableId: foreign.id, + linkFieldId: setupFieldId, + lookupFieldId: foreignFieldId(sourceFieldName[source]), + }, + } + : { + name, + type: FieldType.ConditionalRollup, + options: { + foreignTableId: foreign.id, + lookupFieldId: foreignFieldId(sourceFieldName[source]), + expression, + filter: { + conjunction: "and", + filterSet: [ + { + fieldId: foreignFieldId(MATCH_KEY_FIELD), + operator: "is", + value: { type: "field", fieldId: setupFieldId }, + }, + ], + }, + }, + }; + + const probe = await bugCheckpoint( + "a-total-its-source-cannot-give-is-refused-and-leaves-nothing", + async () => { + const observed: Record[] = []; + for (const attempt of config.attempts) { + const name = `${ILLEGAL_FIELD_PREFIX} ${attempt.name}`; + const response = await createFieldRaw( + host.id, + illegalBody(attempt.source, attempt.expression, name), + ); + + if (response.status < 400 || response.status > 499) { + throw new Error( + `${attempt.source} + ${attempt.expression} was accepted with ${response.status}, ` + + `expected the request to be refused. The response was ${JSON.stringify(response.data)}`, + ); + } + + // The other half: refused AND not there. A 4xx that still wrote the + // row would leave the unusable column the report is about. + const fields = await listFields(host.id); + const persisted = fields.find((field) => field.name === name); + if (persisted) { + throw new Error( + `${attempt.source} + ${attempt.expression} was refused with ${response.status} ` + + `but the column was created anyway as ${persisted.id}. The table now holds: ` + + JSON.stringify(fields.map((field) => field.name)), + ); + } + + observed.push({ + attempt: attempt.name, + status: response.status, + }); + } + return { observed }; + }, + ); + + return { + details: { + column: config.column, + hostTableId: host.id, + sourceTableId: foreign.id, + routing, + refused: probe.observed, + }, + }; + } finally { + for (const tableId of createdTableIds) { + try { + await permanentDeleteTable(baseId, tableId); + } catch (error) { + // Cleanup is the case's own housekeeping - the product did not fail. + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/types.ts b/framework/types.ts index 0a4a5ed..6fce10d 100644 --- a/framework/types.ts +++ b/framework/types.ts @@ -119,6 +119,9 @@ export interface BugCaseConfigByRunner { "lookup-of-link-contains": LookupOfLinkContainsCaseConfig; "delete-without-undo-capture": DeleteWithoutUndoCaptureCaseConfig; "single-field-pending-state": SingleFieldPendingStateCaseConfig; + "rollup-create-compatibility": RollupCreateCompatibilityCaseConfig; + "autonumber-string-filter": AutonumberStringFilterCaseConfig; + "cross-base-conditional-base-id": CrossBaseConditionalBaseIdCaseConfig; } export type BugRunnerKind = keyof BugCaseConfigByRunner; @@ -1932,3 +1935,51 @@ export interface LookupOfRollupCreateCaseConfig { firstAmount: number; secondAmount: number; } + +// A totalling column asked for a total its source cannot give, sent straight to +// the API. Two shapes on one runner because the question is the same: does the +// API check what the field editor checks. +export interface RollupCreateCompatibilityCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + // Which kind of total. "rollup" follows a link to the other table; + // "conditionalRollup" matches rows in it. Different code paths. + column: "rollup" | "conditionalRollup"; + // Written into the matching column on both tables, so the conditional + // variant's condition selects the seeded row. + matchKey: string; + // The combinations the field editor refuses to offer. Each one is sent and + // each one must be refused - a single accepted combination is the bug. + attempts: { + // Names the combination in the report. + name: string; + // The column type in the other table the total would read. + source: "button" | "number" | "checkbox"; + // The function asked of it. + expression: string; + }[]; +} + +// A filter on the row-number column carrying the number as text, which is what +// a filter box sends. +export interface AutonumberStringFilterCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + // One row per title, numbered in order by the column added after them. Needs + // enough rows that the threshold below splits them. + rowTitles: string[]; + // "greater than" this. Must select some rows and leave others out, or a + // comparison that never ran would look the same as one that did. + threshold: number; +} + +// A conditional column reading a table in a second base, read back the way the +// settings screen reads it. +export interface CrossBaseConditionalBaseIdCaseConfig { + namePrefix: string; + // Rows in the other base. At least one has to carry matchedCategory, or the + // columns compute nothing and the fixture proves nothing. + sourceRows: { category: string; amount: number }[]; + // The category the single host row carries, and so the rows the columns read. + matchedCategory: string; +} diff --git a/registry.ts b/registry.ts index f27c569..baa716a 100644 --- a/registry.ts +++ b/registry.ts @@ -25,6 +25,10 @@ import trackedModifiedSortCase from "./cases/view/sort-by-a-narrowed-last-change import lookupOfLinkContainsCase from "./cases/filter/search-a-borrowed-link-column.case"; import deleteWithoutUndoCaptureCase from "./cases/record/delete-a-row-whose-undo-bookkeeping-is-missing.case"; import singleFieldPendingStateCase from "./cases/field/a-settled-column-read-on-its-own.case"; +import rollupCreateCompatibilityCase from "./cases/field/a-total-its-source-cannot-give.case"; +import conditionalRollupCreateCompatibilityCase from "./cases/field/a-conditional-total-its-source-cannot-give.case"; +import autonumberStringFilterCase from "./cases/filter/a-row-number-filter-typed-into-the-box.case"; +import crossBaseConditionalBaseIdCase from "./cases/field/a-cross-base-conditional-column-keeps-its-base.case"; import sparseBatchUpdateCase from "./cases/record/a-batch-write-leaves-what-it-did-not-mention.case"; import generatedFormulaColumnCase from "./cases/record/edit-a-cell-behind-a-generated-formula.case"; import legacyGeneratedAuditColumnCase from "./cases/record/add-a-row-to-a-legacy-table.case"; @@ -160,6 +164,10 @@ const cases = [ lookupOfLinkContainsCase, deleteWithoutUndoCaptureCase, singleFieldPendingStateCase, + rollupCreateCompatibilityCase, + conditionalRollupCreateCompatibilityCase, + autonumberStringFilterCase, + crossBaseConditionalBaseIdCase, sparseBatchUpdateCase, generatedFormulaColumnCase, legacyGeneratedAuditColumnCase, From eddba461e1359b09b9887b227a3f349c2dd9a658 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 3 Sep 2026 14:23:34 +0800 Subject: [PATCH 16/22] Copy a table, copy a base, and total what you are linked to (#134) * Do not publish a table because someone copied one T6790: duplicating a table carried each view's sharing into the copy - the switch, the rules, and the password - and only minted a new address. The copy was a live public page from the moment it existed, openable by anyone who had ever been given the source's password, with nothing in the interface saying so. On the existing duplicate-shared-view runner, behind a new `assert` value, because it is the same setup and the same request as T6573 and differs only in what it reads off the copy - which is the point: this answer replaced that one. The checkpoint reads three things off every copied view, since the switch, the address and the rules are three separate ways a copy can be reachable, and then checks the source kept its own link, so "not shared" cannot have been reached by unsharing everything. A password is set on the source first; an inherited address is a page nobody opened, an inherited password is a page other people can already open. Reproduced on the fix's parent 9c97d777c: the copy came back enableShare true, with the source's password verbatim. Co-Authored-By: Claude Opus 5 * Keep an any-of-these total inside the row it belongs to T7004: a total over linked rows narrowed to "status is todo OR status is doing". Written with OR, the condition escaped the link - the query stopped asking "and linked to this row" and totalled every matching row in the other table. The number it produced was a real sum of real rows, in the right units and the right order of magnitude, so nothing looked wrong. What the report leads with is the other half: a project joined to nothing at all that already shows a figure. The checkpoint reads both rows. The unlinked one must total nothing, which is what a person notices; the linked one must total exactly its own selected work, which is what says the condition is still applied - a build that fixed the scope by dropping the condition would pass the first test alone. The other table carries three kinds of row and the runner refuses a fixture missing any of them: linked and selected, linked and excluded, and selected but somebody else's. Without the third, a total that ignored the link entirely would still read correctly. Reproduced on 9c97d777c, which is before this fix: the project joined to nothing totalled 520, every todo and doing row in the table. Co-Authored-By: Claude Opus 5 * Copy a base whose tables were named before the naming changed T6990: Postgres constraint names are unique per table, not per schema, and old bases carry a self-referencing key called fk___id on every table. Duplicating a base drops those keys, copies the rows and puts them back - but the step that listed them matched on the name and the schema and not on the owning table, so each table's list came back holding the other's rows. The drop ran twice for one table, the second found nothing, and the copy died there. Reported from production as an unhandled rejection in the browser, with the base half-made and nothing a person could change to get past it. The keys go in with SQL because nothing produces that name any more - it is what an old base has been carrying since before the convention changed, which is the same reason nobody hitting this could get out of it from the interface. The fixture counts how many tables carry the name before the checkpoint: with only one there is nothing to collide. The duplicate goes through raw axios with the status open. The generated client throws a bare "Internal Server Error" and drops the body, and the body is the part worth reading - a 500 that turned out to be something else would make this case red for the wrong reason. On 168f206df it reads: Raw query failed. Code: `42704`. Message: `ERROR: constraint "fk___id" of relation "tblivzjntazg6wE9b1W" does not exist` which is the Sentry error the issue was filed on. Co-Authored-By: Claude Opus 5 * Record that v1 carried the copied share state too The acceptance matrix answers red on both pre-fix columns for the v1 reference, which matches the issue's reading of the legacy duplicate path and contradicts what a local run of the same commit said. Both are written down; which of the two the harness should be trusted on is a separate question. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- ...base-whose-tables-share-a-key-name.case.ts | 28 ++ ...py-a-base-whose-tables-share-a-key-name.md | 46 +++ ...-these-total-stays-inside-its-link.case.ts | 60 ++++ ...ny-of-these-total-stays-inside-its-link.md | 51 ++++ ...a-duplicated-table-starts-unshared.case.ts | 31 ++ .../a-duplicated-table-starts-unshared.md | 58 ++++ .../table/duplicate-with-shared-view.case.ts | 1 + framework/runner-registry.ts | 4 + .../runners/duplicate-shared-view.runner.ts | 71 ++++- .../or-filtered-rollup-scope.runner.ts | 277 ++++++++++++++++++ .../same-named-fk-base-duplicate.runner.ts | 193 ++++++++++++ framework/types.ts | 47 +++ registry.ts | 6 + 13 files changed, 870 insertions(+), 3 deletions(-) create mode 100644 cases/base-share/copy-a-base-whose-tables-share-a-key-name.case.ts create mode 100644 cases/base-share/copy-a-base-whose-tables-share-a-key-name.md create mode 100644 cases/lookup/an-any-of-these-total-stays-inside-its-link.case.ts create mode 100644 cases/lookup/an-any-of-these-total-stays-inside-its-link.md create mode 100644 cases/table/a-duplicated-table-starts-unshared.case.ts create mode 100644 cases/table/a-duplicated-table-starts-unshared.md create mode 100644 framework/runners/or-filtered-rollup-scope.runner.ts create mode 100644 framework/runners/same-named-fk-base-duplicate.runner.ts diff --git a/cases/base-share/copy-a-base-whose-tables-share-a-key-name.case.ts b/cases/base-share/copy-a-base-whose-tables-share-a-key-name.case.ts new file mode 100644 index 0000000..4980b0a --- /dev/null +++ b/cases/base-share/copy-a-base-whose-tables-share-a-key-name.case.ts @@ -0,0 +1,28 @@ +import { defineBugCase } from "../../framework/types"; + +// T6990: Postgres constraint names are unique per table, not per schema, and +// old bases carry a self-referencing key called fk___id on every table. +// Duplicating a base drops those keys, copies the rows and puts them back - but +// the step that listed them matched on the name and the schema and not on the +// table, so each table's list came back holding the other's rows. The drop ran +// twice for one table, the second found nothing, and the whole copy died on a +// Postgres error naming a constraint that "does not exist". Reported from +// production as an unhandled rejection in the browser, with the base half-made. +export default defineBugCase({ + id: "base-share/copy-a-base-whose-tables-share-a-key-name", + title: "A base whose tables share a key name can still be copied", + runner: "same-named-fk-base-duplicate", + timeoutMs: 300_000, + skipV1: + "the fix is on the v2 duplicate route's own foreign-key introspection; v1 keeps its untouched legacy helper, so the v1 column answers a different question", + bug: { + issue: "T6990", + status: "fixed", + sourceCommits: ["b913e5014"], + }, + config: { + baseNamePrefix: "e2e-lab-same-named-fk", + tableNames: ["the-first-table", "the-second-table"], + rowTitle: "a-row-to-copy", + }, +}); diff --git a/cases/base-share/copy-a-base-whose-tables-share-a-key-name.md b/cases/base-share/copy-a-base-whose-tables-share-a-key-name.md new file mode 100644 index 0000000..b9fbf0d --- /dev/null +++ b/cases/base-share/copy-a-base-whose-tables-share-a-key-name.md @@ -0,0 +1,46 @@ +# base-share/copy-a-base-whose-tables-share-a-key-name + +**T6990** — fixed. On the `same-named-fk-base-duplicate` runner. + +## What the user sees + +Duplicating a base fails. The browser reports an unhandled rejection naming a +Postgres error — a constraint that "does not exist" — and the base is left +half-made. Pressing duplicate again does the same thing, and there is nothing in +the base a person could change to get past it. + +## Why + +Postgres constraint names are unique per **table**, not per schema. Two tables +in one base can each own a foreign key called `fk___id`, and old bases do: a +self-referencing key on the row id column, from before the naming changed. + +Duplicating a base drops those keys, copies the rows, and puts them back. The +step that listed the keys to drop matched on the name and the schema and not on +the table that owns them, so each table's list came back carrying the other +table's rows. The drop then ran the same statement twice for one table; the +second found nothing and raised 42704, and the duplicate died there. + +## What the checkpoint asserts + +The duplicate succeeds — a refused request throws inside the checkpoint, which +is the report — **and** the copy holds every table. A duplicate that answered +201 while losing a table would be the same interrupted copy behind a success. + +## Why the fixture is written with SQL + +Nothing a person can do produces `fk___id` any more. It is what an old base has +been carrying since before the naming convention changed, which is also why +nobody hitting this could get out of it from the interface. `fixture-db` is the +only way to build that state; the observation stays on the public duplicate +endpoint. + +The fixture then counts, before the checkpoint, how many tables in the schema +carry the name. With only one there is nothing to collide, and the case would +report on nothing. + +## The v1 column + +Skipped. The fix is on the v2 duplicate route's own foreign-key introspection; +v1 keeps its legacy helper untouched until it retires, so a v1 answer here is a +different question rather than a comparison. diff --git a/cases/lookup/an-any-of-these-total-stays-inside-its-link.case.ts b/cases/lookup/an-any-of-these-total-stays-inside-its-link.case.ts new file mode 100644 index 0000000..1b672a1 --- /dev/null +++ b/cases/lookup/an-any-of-these-total-stays-inside-its-link.case.ts @@ -0,0 +1,60 @@ +import { defineBugCase } from "../../framework/types"; + +// T7004: a total over linked rows narrowed to "status is todo OR status is +// doing". Written with OR, the condition escaped the link - the query stopped +// asking "and linked to this row" and totalled every matching row in the other +// table. The number that came out was a real sum of real rows, so nothing +// looked broken; the tell in the report is a project joined to nothing that +// already shows other people's figures. +export default defineBugCase({ + id: "lookup/an-any-of-these-total-stays-inside-its-link", + title: "An any-of-these total counts only the rows this one is linked to", + runner: "or-filtered-rollup-scope", + timeoutMs: 300_000, + bug: { + issue: "T7004", + status: "fixed", + sourceCommits: ["8713707c2"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-or-rollup-scope", + linkedHost: "the-project-with-work", + unlinkedHost: "the-project-joined-to-nothing", + selectedStatuses: ["todo", "doing"], + work: [ + { + name: "mine-todo", + owner: "the-project-with-work", + status: "todo", + amount: 10, + }, + { + name: "mine-doing", + owner: "the-project-with-work", + status: "doing", + amount: 20, + }, + { + name: "mine-done", + owner: "the-project-with-work", + status: "done", + amount: 30, + }, + { + name: "theirs-todo", + owner: "somebody-else", + status: "todo", + amount: 400, + }, + { + name: "theirs-doing", + owner: "somebody-else", + status: "doing", + amount: 500, + }, + ], + settleTimeoutMs: 60_000, + pollIntervalMs: 500, + }, +}); diff --git a/cases/lookup/an-any-of-these-total-stays-inside-its-link.md b/cases/lookup/an-any-of-these-total-stays-inside-its-link.md new file mode 100644 index 0000000..d02707e --- /dev/null +++ b/cases/lookup/an-any-of-these-total-stays-inside-its-link.md @@ -0,0 +1,51 @@ +# lookup/an-any-of-these-total-stays-inside-its-link + +**T7004** — fixed. On the `or-filtered-rollup-scope` runner. + +## What the user sees + +A project row totals the work linked to it, narrowed to "status is todo **or** +status is doing". The figure is too large: it includes work belonging to other +projects, as long as that work matches the condition. + +The number looks fine. It is a real sum of real rows, in the right units, of the +right order of magnitude — there is nothing to notice. What the report actually +leads with is the other symptom: a project created a moment ago, joined to +nothing at all, already showing a figure. + +## Why + +"Any of these" is written as OR. The link scope — "and linked to this row" — +was being combined with the condition in a way that let the OR swallow it, so +the query asked for every matching row in the other table instead of every +matching row _among this row's_. + +## What the checkpoint asserts + +Two things: + +- the row linked to nothing totals nothing, and +- the linked row totals exactly its own selected work. + +The first is the one a person would notice; the second is the one that says the +condition still works. A build that fixed the scope by ignoring the condition +would pass the first and fail the second. + +## Why the fixture is shaped this way + +Three kinds of row in the other table, and the runner refuses to run without all +three: + +- linked to this project and selected by the condition — what should be counted; +- linked to this project and excluded — proves the condition is still applied; +- selected by the condition but belonging to another project — proves the link + is still applied. + +Drop the third and a total that ignored the link entirely would give the right +answer, and the case would be green on both sides of the fix. The other +project's amounts are an order of magnitude larger than this one's, so a total +that escapes is unmistakable in the failure message rather than merely wrong. + +The wait before the checkpoint is on the **linked** row reaching its correct +total — waiting for the computation to finish, not for the bug to show up. The +unlinked row is then read out of that same settled response. diff --git a/cases/table/a-duplicated-table-starts-unshared.case.ts b/cases/table/a-duplicated-table-starts-unshared.case.ts new file mode 100644 index 0000000..9d53855 --- /dev/null +++ b/cases/table/a-duplicated-table-starts-unshared.case.ts @@ -0,0 +1,31 @@ +import { defineBugCase } from "../../framework/types"; + +// T6790: duplicating a table carried each view's sharing across with it - the +// switch, the rules, and the password - and only minted a new address. The copy +// was therefore a live public page from the moment it existed, reachable by +// anyone who had ever been given the source's password, with nothing in the +// interface saying so and no prompt asking. Duplicating a base already got this +// right; duplicating a table did not, and the difference had been frozen into a +// test. +export default defineBugCase({ + id: "table/a-duplicated-table-starts-unshared", + title: "A duplicated table does not come out already published", + runner: "duplicate-shared-view", + timeoutMs: 180_000, + bug: { + issue: "T6790", + status: "fixed", + sourceCommits: ["a5f02fd0c"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-duplicate-unshared", + rowTitle: "row-1", + assert: "copyIsNotShared", + shareMeta: { + password: "not-for-the-copy", + allowCopy: true, + includeHiddenField: true, + }, + }, +}); diff --git a/cases/table/a-duplicated-table-starts-unshared.md b/cases/table/a-duplicated-table-starts-unshared.md new file mode 100644 index 0000000..9c8bc8d --- /dev/null +++ b/cases/table/a-duplicated-table-starts-unshared.md @@ -0,0 +1,58 @@ +# table/a-duplicated-table-starts-unshared + +**T6790** — fixed. On the `duplicate-shared-view` runner, `assert: "copyIsNotShared"`. + +## What the user sees + +A table has a view someone shared, with a password on it. The table is +duplicated. The copy is already published: the sharing switch is on, the share +rules including the password came across, and only the address is new. + +Nobody was asked and nothing says so. Every duplicate of that table is another +live public page, openable by anyone who was ever given the source's password. + +## Why + +Duplicating a base already stripped share state from every copied view. +Duplicating a table only re-minted the share id and left the switch and the +rules alone — on both the v2 path and the legacy one. The two duplicate flows +disagreed about the same thing. + +That disagreement was not an accident that went unnoticed: it had been written +into a unit test as the expected behavior, by an earlier fix (`da43a20a2`) that +was only ever about two tables colliding on one share id. + +## What the checkpoint asserts + +Three separate things about every view in the copy, because they are three +separate ways a copy can be reachable: the switch (`enableShare`), the address +(`shareId`), and the rules behind it (`shareMeta`). + +Then, that the **source** still holds its own link, with the same share id it +started with. Without that, "the copy is not shared" could have been satisfied +by a duplicate that unshared everything, which is a different bug. + +A password is set on the source before duplicating, and the fixture refuses to +continue if it did not stick. The password is the part that makes this more than +untidy: an inherited address is a page nobody opened, an inherited password is a +page other people can already open. + +## The v1 column + +v1 reproduces this too, on both pre-fix columns of the acceptance matrix. That +matches the issue's own reading, which named the legacy duplicate path as +spreading the source view row wholesale and overriding only the share id. +Customers on either engine were affected. + +Worth knowing for anyone re-running this: a **local** run of the v1 column on +`9c97d777c` came back green, while CI on the same commit came back red. CI is +the acceptance surface and its answer is the one recorded here, but the two +disagreeing at all is a harness question that is not settled by this case. + +## Its sibling on this runner + +`table/duplicate-with-shared-view` (T6573, `da43a20a2`) asks the older question +— the duplicate must succeed and must not answer on the source's address. Both +run the same setup and the same request; they differ only in what they read off +the copy. Keeping them on one runner is what makes it visible that the second +answer replaced the first. diff --git a/cases/table/duplicate-with-shared-view.case.ts b/cases/table/duplicate-with-shared-view.case.ts index e5cab15..8792cce 100644 --- a/cases/table/duplicate-with-shared-view.case.ts +++ b/cases/table/duplicate-with-shared-view.case.ts @@ -21,5 +21,6 @@ export default defineBugCase({ baseId: "seed-base", tableNamePrefix: "e2e-lab-shared-view-copy", rowTitle: "row-1", + assert: "copyHasItsOwnLink", }, }); diff --git a/framework/runner-registry.ts b/framework/runner-registry.ts index f0cd683..f56a283 100644 --- a/framework/runner-registry.ts +++ b/framework/runner-registry.ts @@ -112,6 +112,8 @@ import { runSingleFieldPendingStateCase } from "./runners/single-field-pending-s import { runRollupCreateCompatibilityCase } from "./runners/rollup-create-compatibility.runner"; import { runAutonumberStringFilterCase } from "./runners/autonumber-string-filter.runner"; import { runCrossBaseConditionalBaseIdCase } from "./runners/cross-base-conditional-base-id.runner"; +import { runOrFilteredRollupScopeCase } from "./runners/or-filtered-rollup-scope.runner"; +import { runSameNamedFkBaseDuplicateCase } from "./runners/same-named-fk-base-duplicate.runner"; import { runLookupOfRollupCreateCase } from "./runners/lookup-of-rollup-create.runner"; import type { BugCase, @@ -245,6 +247,8 @@ const runners: { [K in BugRunnerKind]: RunnerFn } = { "rollup-create-compatibility": runRollupCreateCompatibilityCase, "autonumber-string-filter": runAutonumberStringFilterCase, "cross-base-conditional-base-id": runCrossBaseConditionalBaseIdCase, + "or-filtered-rollup-scope": runOrFilteredRollupScopeCase, + "same-named-fk-base-duplicate": runSameNamedFkBaseDuplicateCase, }; export const executeRegisteredRunner = ( diff --git a/framework/runners/duplicate-shared-view.runner.ts b/framework/runners/duplicate-shared-view.runner.ts index 6e151cc..4059df9 100644 --- a/framework/runners/duplicate-shared-view.runner.ts +++ b/framework/runners/duplicate-shared-view.runner.ts @@ -3,6 +3,7 @@ import { axios, enableShareView as apiEnableShareView, getViewList as apiGetViewList, + updateViewShareMeta as apiUpdateViewShareMeta, DUPLICATE_TABLE, urlBuilder, } from "@teable/openapi"; @@ -26,6 +27,14 @@ import type { DuplicateSharedViewCaseConfig } from "../types"; // carried the same share id would be worse than the 500 - two tables answering // on one public address, where turning off sharing on either takes down a page // the other one is serving - so the ids are compared. +// +// The second question this runner asks (`assert: "copyIsNotShared"`) is what +// the copy should carry INSTEAD, and it is not "a link of its own": nothing. +// Duplicating a table is not a decision to publish one, and a copy that comes +// out already shared - under the source's password and edit rules, with no +// prompt and nothing in the interface saying so - publishes a table nobody +// chose to publish. The source's own link is checked too, because "the copy is +// not shared" must not have been reached by unsharing both. const NAME_FIELD = "Name"; @@ -60,6 +69,22 @@ export const runDuplicateSharedViewCase = async ( ); } + // The share rules a person set on the source. They matter to the second + // question: a copy that inherits these is reachable with the source's + // password by anyone who ever had it. + if (config.shareMeta) { + await apiUpdateViewShareMeta(table.id, viewId, config.shareMeta); + const sourceViews = await apiGetViewList(table.id); + const sourceView = sourceViews.data.find( + (view: { id: string }) => view.id === viewId, + ) as { shareMeta?: Record } | undefined; + if (!sourceView?.shareMeta?.password) { + throw new Error( + `the share rules did not stick on the source view: ${JSON.stringify(sourceView?.shareMeta)}`, + ); + } + } + // Raw axios with the status open: before the fix this request is refused, // and the generated client drops the response - routing headers included - // the moment it is. @@ -96,14 +121,54 @@ export const runDuplicateSharedViewCase = async ( feature: "duplicateTable", }); - // The copy's own share credential. Reusing the source's would put two - // tables on one public address - a success that is worse than the - // failure it replaced. const copiedViews = await apiGetViewList(copyId); const copiedShareIds = copiedViews.data.map( (view: { id: string; shareId?: string | null }) => view.shareId ?? null, ); + + if (config.assert === "copyIsNotShared") { + // Nothing published. Each of the three is a separate way the copy can + // be reachable: the switch, the address, and the rules behind it. + for (const view of copiedViews.data as { + id: string; + name?: string; + enableShare?: boolean | null; + shareId?: string | null; + shareMeta?: Record | null; + }[]) { + if (view.enableShare || view.shareId || view.shareMeta) { + throw new Error( + `the copied view ${view.name ?? view.id} came out shared: ` + + JSON.stringify({ + enableShare: view.enableShare, + shareId: view.shareId, + shareMeta: view.shareMeta, + }), + ); + } + } + + // And the source keeps its own link - otherwise "the copy is not + // shared" could have been reached by unsharing everything. + const sourceViews = await apiGetViewList(table.id); + const sourceView = sourceViews.data.find( + (view: { id: string }) => view.id === viewId, + ) as { enableShare?: boolean; shareId?: string } | undefined; + if ( + !sourceView?.enableShare || + sourceView.shareId !== sourceShareId + ) { + throw new Error( + `duplicating took the source's own link with it: ${JSON.stringify(sourceView)}`, + ); + } + return { routing, copiedShareIds }; + } + + // The copy's own share credential. Reusing the source's would put two + // tables on one public address - a success that is worse than the + // failure it replaced. if (copiedShareIds.includes(sourceShareId)) { throw new Error( `the copied table's views carry ${JSON.stringify(copiedShareIds)}, which includes the source's ` + diff --git a/framework/runners/or-filtered-rollup-scope.runner.ts b/framework/runners/or-filtered-rollup-scope.runner.ts new file mode 100644 index 0000000..685cd72 --- /dev/null +++ b/framework/runners/or-filtered-rollup-scope.runner.ts @@ -0,0 +1,277 @@ +import { Colors, FieldKeyType, FieldType, Relationship } from "@teable/core"; +import { + createRecords as apiCreateRecords, + getRecords as apiGetRecords, +} from "@teable/openapi"; +import { + createField, + createTable, + permanentDeleteTable, +} from "../../../utils/init-app"; +import { bugCheckpoint } from "../checkpoint"; +import { assertServedByV2 } from "../engine"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { OrFilteredRollupScopeCaseConfig } from "../types"; + +// A total over linked rows, narrowed to "status is todo OR status is doing" -> +// checkpoint: each row totals only the rows it is actually linked to. +// +// "Open work on this project", "unpaid invoices for this customer" - the two- +// or-more-values condition is how a summary says "any of these". Written with +// OR, the condition escaped the link: the query stopped asking "and linked to +// this row" and totalled every matching row in the other table. +// +// The number that comes out is plausible - it is a real sum of real rows - so +// nothing looks broken. The tell is the row that is linked to nothing at all +// and still shows a figure, which is what the report leads with: a project +// created a minute ago, joined to nothing, already showing other people's +// numbers. +// +// The fixture therefore carries three kinds of foreign row, and the case is +// worthless without all three: rows this host is linked to that the condition +// selects, rows it is linked to that the condition excludes, and rows the +// condition selects that belong to somebody else. Drop the third and a total +// that ignored the link would give the right answer anyway. + +const NAME_FIELD = "Name"; +const STATUS_FIELD = "Status"; +const AMOUNT_FIELD = "Amount"; +const LINK_FIELD = "Work"; +const ROLLUP_FIELD = "Open work"; + +const sleep = (ms: number) => + new Promise((resolveSleep) => { + setTimeout(resolveSleep, ms); + }); + +export const runOrFilteredRollupScopeCase = async ( + bugCase: BugCaseFor<"or-filtered-rollup-scope">, + context: BugRunContext, +): Promise => { + const config: OrFilteredRollupScopeCaseConfig = bugCase.config; + const baseId = globalThis.testConfig.baseId; + const suffix = `${config.tableNamePrefix}-${context.runId}`; + let workTableId = ""; + let hostTableId = ""; + + const selected = new Set(config.selectedStatuses); + const mine = config.work.filter((row) => row.owner === config.linkedHost); + const minesSelected = mine.filter((row) => selected.has(row.status)); + const mineExcluded = mine.filter((row) => !selected.has(row.status)); + const othersSelected = config.work.filter( + (row) => row.owner !== config.linkedHost && selected.has(row.status), + ); + if ( + minesSelected.length === 0 || + mineExcluded.length === 0 || + othersSelected.length === 0 + ) { + throw new Error( + "the fixture needs all three kinds of row - linked and selected, linked and excluded, " + + "and selected but belonging to another host. Without the third, a total that ignored " + + "the link would still read correctly", + ); + } + if (config.selectedStatuses.length < 2) { + throw new Error( + "at least two statuses, or the condition has nothing to OR together and this is a different bug", + ); + } + const expectedLinkedTotal = minesSelected.reduce( + (sum, row) => sum + row.amount, + 0, + ); + const statuses = [...new Set(config.work.map((row) => row.status))]; + + try { + const workTable = await createTable(baseId, { + name: `${suffix}-work`, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + { + name: STATUS_FIELD, + type: FieldType.SingleSelect, + options: { + choices: statuses.map((name) => ({ name, color: Colors.Blue })), + }, + }, + { name: AMOUNT_FIELD, type: FieldType.Number }, + ], + records: config.work.map((row) => ({ + fields: { + [NAME_FIELD]: row.name, + [STATUS_FIELD]: row.status, + [AMOUNT_FIELD]: row.amount, + }, + })), + }); + workTableId = workTable.id; + const statusFieldId = workTable.fields.find( + (field: { name: string }) => field.name === STATUS_FIELD, + )?.id; + const amountFieldId = workTable.fields.find( + (field: { name: string }) => field.name === AMOUNT_FIELD, + )?.id; + if (!statusFieldId || !amountFieldId) { + throw new Error(`the work table ${workTableId} is not in place`); + } + const workIdByName = new Map( + workTable.records.map( + (record: { id: string; fields: Record }) => [ + String(record.fields[NAME_FIELD]), + record.id, + ], + ), + ); + + const hostTable = await createTable(baseId, { + name: `${suffix}-host`, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + ], + records: [], + }); + hostTableId = hostTable.id; + const linkField = await createField(hostTableId, { + name: LINK_FIELD, + type: FieldType.Link, + options: { + foreignTableId: workTableId, + relationship: Relationship.OneMany, + }, + }); + + // One host joined to its own rows, and one joined to nothing - the row the + // report is about, created and never linked. + await apiCreateRecords(hostTableId, { + fieldKeyType: FieldKeyType.Name, + typecast: false, + records: [ + { + fields: { + [NAME_FIELD]: config.linkedHost, + [LINK_FIELD]: mine.map((row) => ({ + id: workIdByName.get(row.name) as string, + })), + }, + }, + { fields: { [NAME_FIELD]: config.unlinkedHost } }, + ], + }); + + const rollupField = await createField(hostTableId, { + name: ROLLUP_FIELD, + type: FieldType.Rollup, + options: { expression: "sum({values})" }, + lookupOptions: { + foreignTableId: workTableId, + linkFieldId: linkField.id, + lookupFieldId: amountFieldId, + filter: { + conjunction: "or", + filterSet: config.selectedStatuses.map((status) => ({ + fieldId: statusFieldId, + operator: "is", + value: status, + })), + }, + }, + }); + + const readHosts = async () => { + const response = await apiGetRecords(hostTableId, { + fieldKeyType: FieldKeyType.Name, + take: 10, + }); + const byName = new Map( + response.data.records.map((record) => [ + String(record.fields[NAME_FIELD]), + record.fields[ROLLUP_FIELD] ?? null, + ]), + ); + return { headers: response.headers, byName }; + }; + + // Settling before the checkpoint, on the LINKED host only. Its total is the + // one a working build has to reach, so waiting for it is waiting for the + // computation to finish rather than for the bug to appear - the unlinked + // host is then read from that same settled state. + const deadline = Date.now() + config.settleTimeoutMs; + let settled = await readHosts(); + for (;;) { + if ( + Number(settled.byName.get(config.linkedHost)) === expectedLinkedTotal + ) { + break; + } + if (Date.now() >= deadline) { + break; + } + await sleep(config.pollIntervalMs); + settled = await readHosts(); + } + + const routing = assertServedByV2(settled.headers, { + operation: "GET /table/{tableId}/record", + feature: "getRecords", + }); + + const probe = await bugCheckpoint( + "an-any-of-these-total-counts-only-what-this-row-is-linked-to", + async () => { + const linkedTotal = settled.byName.get(config.linkedHost) ?? null; + const unlinkedTotal = settled.byName.get(config.unlinkedHost) ?? null; + + // The row linked to nothing. Any figure here came from somebody else's + // rows, which is the symptom the report opens with. + if (unlinkedTotal !== null && Number(unlinkedTotal) !== 0) { + throw new Error( + `"${config.unlinkedHost}" is linked to nothing and totals ${JSON.stringify(unlinkedTotal)}. ` + + `The other table holds ${JSON.stringify( + config.work.map( + (row) => `${row.name}/${row.status}/${row.amount}`, + ), + )}`, + ); + } + + if (Number(linkedTotal) !== expectedLinkedTotal) { + throw new Error( + `"${config.linkedHost}" totals ${JSON.stringify(linkedTotal)}, expected ${expectedLinkedTotal} ` + + `from its own ${JSON.stringify(minesSelected.map((row) => row.name))}. ` + + `Linked but excluded: ${JSON.stringify(mineExcluded.map((row) => row.name))}; ` + + `selected but somebody else's: ${JSON.stringify(othersSelected.map((row) => row.name))}`, + ); + } + return { linkedTotal, unlinkedTotal }; + }, + ); + + return { + details: { + workTableId, + hostTableId, + rollupFieldId: rollupField.id, + expectedLinkedTotal, + routing, + ...probe, + }, + }; + } finally { + for (const tableId of [hostTableId, workTableId]) { + if (!tableId) { + continue; + } + try { + await permanentDeleteTable(baseId, tableId); + } catch (error) { + // Cleanup is the case's own housekeeping - the product did not fail. + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/runners/same-named-fk-base-duplicate.runner.ts b/framework/runners/same-named-fk-base-duplicate.runner.ts new file mode 100644 index 0000000..0339769 --- /dev/null +++ b/framework/runners/same-named-fk-base-duplicate.runner.ts @@ -0,0 +1,193 @@ +import { FieldType } from "@teable/core"; +import { + axios, + createBase as apiCreateBase, + getTableList as apiGetTableList, + permanentDeleteBase, + DUPLICATE_BASE, + urlBuilder, +} from "@teable/openapi"; +import { createTable } from "../../../utils/init-app"; +import { bugCheckpoint } from "../checkpoint"; +import { assertServedByV2 } from "../engine"; +import { fixtureDb } from "../fixture-db"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { SameNamedFkBaseDuplicateCaseConfig } from "../types"; + +// A base whose tables each carry a foreign key under the SAME name -> +// duplicate the base with its rows -> checkpoint: the copy is made, with every +// table in it. +// +// Postgres constraint names are unique per table, not per schema, so two +// tables in one base can each own a constraint called `fk___id` - and legacy +// bases do, from a self-referencing key on the row id column that no current +// code writes. Duplicating a base drops those keys, copies the rows, and puts +// the keys back. +// +// The step that listed them matched on the name and the schema and not on the +// table, so each table's list came back holding the other table's rows too. +// The drop phase then issued the same DROP twice for one table, the second one +// found nothing, and the whole duplicate died on a Postgres error naming a +// constraint that "does not exist" (42704) - reported from production as an +// unhandled rejection in the browser, with the base half-made. +// +// The keys are written with SQL because nothing a person can do produces them +// any more; they are what an old base has been carrying since before the +// naming changed. That is the same reason nobody could get out of this from +// the interface. + +const NAME_FIELD = "Name"; +const LEGACY_FK_NAME = "fk___id"; + +export const runSameNamedFkBaseDuplicateCase = async ( + bugCase: BugCaseFor<"same-named-fk-base-duplicate">, + context: BugRunContext, +): Promise => { + const config: SameNamedFkBaseDuplicateCaseConfig = bugCase.config; + const spaceId = globalThis.testConfig.spaceId; + const suffix = `${config.baseNamePrefix}-${context.runId}`; + let sourceBaseId = ""; + let copyId = ""; + + if (config.tableNames.length < 2) { + throw new Error( + "two tables at least - one table cannot collide with itself, and the collision is the bug", + ); + } + + try { + const source = await apiCreateBase({ spaceId, name: `${suffix}-source` }); + sourceBaseId = source.data.id; + + const tables = []; + for (const name of config.tableNames) { + tables.push( + await createTable(sourceBaseId, { + name, + fields: [ + { + name: NAME_FIELD, + type: FieldType.SingleLineText, + isPrimary: true, + }, + ], + records: [{ fields: { [NAME_FIELD]: config.rowTitle } }], + }), + ); + } + + // The state an old base carries: a self-referencing key on the row id + // column, under a name that was never made unique per schema. + const db = fixtureDb(context.app); + const placed: { schema: string; table: string }[] = []; + for (const table of tables) { + const physical = await db.physicalTable(table.id); + await db.execute( + `ALTER TABLE "${physical.schema}"."${physical.table}" ` + + `ADD CONSTRAINT "${LEGACY_FK_NAME}" FOREIGN KEY ("__id") ` + + `REFERENCES "${physical.schema}"."${physical.table}" ("__id") ON DELETE SET NULL`, + ); + placed.push(physical); + } + + // Fixture verification, outside the checkpoint: two tables really do hold + // one name between them. With only one, there is nothing to collide and + // the case would report on nothing. + const schema = placed[0]?.schema; + const holders = await db.query<{ count: number }[]>( + `SELECT COUNT(*)::int AS count + FROM pg_constraint con + JOIN pg_class rel ON rel.oid = con.conrelid + JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace + WHERE con.contype = 'f' AND nsp.nspname = $1 AND con.conname = $2`, + schema, + LEGACY_FK_NAME, + ); + const holderCount = holders[0]?.count ?? 0; + if (holderCount !== tables.length) { + throw new Error( + `${holderCount} table(s) in ${schema} carry a key named ${LEGACY_FK_NAME}, expected ${tables.length} - the fixture is not in place`, + ); + } + + const probe = await bugCheckpoint( + "a-base-whose-tables-share-a-key-name-can-be-copied", + async () => { + // Raw axios with the status open. Before the fix this request is + // refused, and the generated client throws a bare "Internal Server + // Error" and drops the response - which is exactly the part worth + // reading, because a 500 that turned out to be something else would + // make this case red for the wrong reason. + const duplicated = await axios.post( + urlBuilder(DUPLICATE_BASE, {}), + { + fromBaseId: sourceBaseId, + spaceId, + name: `${suffix}-copy`, + withRecords: true, + }, + { validateStatus: () => true }, + ); + if (duplicated.status < 200 || duplicated.status >= 300) { + throw new Error( + `duplicating the base answered ${duplicated.status}: ` + + (typeof duplicated.data === "string" + ? duplicated.data + : JSON.stringify(duplicated.data)), + ); + } + copyId = (duplicated.data as { id?: string })?.id ?? ""; + if (!copyId) { + throw new Error( + `duplicating the base produced no copy: ${JSON.stringify(duplicated.data)}`, + ); + } + const routing = assertServedByV2(duplicated.headers, { + operation: "POST /base/duplicate", + feature: "duplicateBase", + }); + + // And the copy is whole. A duplicate that answered 201 while losing a + // table would be the same interrupted copy behind a success. + const copied = await apiGetTableList(copyId); + const copiedNames = copied.data.map( + (table: { name: string }) => table.name, + ); + for (const name of config.tableNames) { + if (!copiedNames.includes(name)) { + throw new Error( + `the copy is missing the table ${JSON.stringify(name)} - it holds ${JSON.stringify(copiedNames)}`, + ); + } + } + return { routing, copiedNames }; + }, + ); + + return { + details: { + sourceBaseId, + copyId, + schema, + constraintName: LEGACY_FK_NAME, + ...probe, + }, + }; + } finally { + for (const id of [copyId, sourceBaseId]) { + if (!id) { + continue; + } + try { + await permanentDeleteBase(id); + } catch (error) { + // Cleanup is the case's own housekeeping - the product did not fail. + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (base ${id}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/types.ts b/framework/types.ts index 6fce10d..718343e 100644 --- a/framework/types.ts +++ b/framework/types.ts @@ -122,6 +122,8 @@ export interface BugCaseConfigByRunner { "rollup-create-compatibility": RollupCreateCompatibilityCaseConfig; "autonumber-string-filter": AutonumberStringFilterCaseConfig; "cross-base-conditional-base-id": CrossBaseConditionalBaseIdCaseConfig; + "or-filtered-rollup-scope": OrFilteredRollupScopeCaseConfig; + "same-named-fk-base-duplicate": SameNamedFkBaseDuplicateCaseConfig; } export type BugRunnerKind = keyof BugCaseConfigByRunner; @@ -936,6 +938,19 @@ export interface DuplicateSharedViewCaseConfig { baseId: "seed-base"; tableNamePrefix: string; rowTitle: string; + // Which question to ask of the copy. "copyHasItsOwnLink" is the older one - + // the duplicate must succeed and must not answer on the source's public + // address. "copyIsNotShared" is what the copy should carry instead: nothing. + assert: "copyHasItsOwnLink" | "copyIsNotShared"; + // Share rules set on the source view before duplicating. Only meaningful for + // "copyIsNotShared", where inheriting them is the point: a password the copy + // carries is a password that opens a table nobody chose to publish. Must + // include one, or the fixture check refuses to run. + shareMeta?: { + password?: string; + allowCopy?: boolean; + includeHiddenField?: boolean; + }; } // A row whose id body is not the 16 characters this version generates - what @@ -1983,3 +1998,35 @@ export interface CrossBaseConditionalBaseIdCaseConfig { // The category the single host row carries, and so the rows the columns read. matchedCategory: string; } + +// A total over linked rows narrowed with an "any of these" condition, which is +// written as OR and is where the total stopped respecting the link. +export interface OrFilteredRollupScopeCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + // Rows in the other table. `owner` says which host row they belong to, and + // only the linked host's rows are actually linked - the rest exist to be + // wrongly counted. The runner refuses a fixture missing any of the three + // kinds it needs; see the runner. + work: { name: string; owner: string; status: string; amount: number }[]; + // The statuses the condition selects, ORed together. At least two, or there + // is no OR to get wrong. + selectedStatuses: string[]; + // The host row joined to its own work. + linkedHost: string; + // The host row joined to nothing, which is the symptom the report leads with. + unlinkedHost: string; + settleTimeoutMs: number; + pollIntervalMs: number; +} + +// A base whose tables each carry a foreign key under one name, which is what an +// old base has been holding since before the naming changed. +export interface SameNamedFkBaseDuplicateCaseConfig { + baseNamePrefix: string; + // Two at least: one table cannot collide with itself, and the collision is + // the bug. The runner refuses fewer. + tableNames: string[]; + // The single row each table carries, so the copy has rows to move. + rowTitle: string; +} diff --git a/registry.ts b/registry.ts index baa716a..96fffb2 100644 --- a/registry.ts +++ b/registry.ts @@ -29,6 +29,9 @@ import rollupCreateCompatibilityCase from "./cases/field/a-total-its-source-cann import conditionalRollupCreateCompatibilityCase from "./cases/field/a-conditional-total-its-source-cannot-give.case"; import autonumberStringFilterCase from "./cases/filter/a-row-number-filter-typed-into-the-box.case"; import crossBaseConditionalBaseIdCase from "./cases/field/a-cross-base-conditional-column-keeps-its-base.case"; +import duplicatedTableStartsUnsharedCase from "./cases/table/a-duplicated-table-starts-unshared.case"; +import orFilteredRollupScopeCase from "./cases/lookup/an-any-of-these-total-stays-inside-its-link.case"; +import sameNamedFkBaseDuplicateCase from "./cases/base-share/copy-a-base-whose-tables-share-a-key-name.case"; import sparseBatchUpdateCase from "./cases/record/a-batch-write-leaves-what-it-did-not-mention.case"; import generatedFormulaColumnCase from "./cases/record/edit-a-cell-behind-a-generated-formula.case"; import legacyGeneratedAuditColumnCase from "./cases/record/add-a-row-to-a-legacy-table.case"; @@ -168,6 +171,9 @@ const cases = [ conditionalRollupCreateCompatibilityCase, autonumberStringFilterCase, crossBaseConditionalBaseIdCase, + duplicatedTableStartsUnsharedCase, + orFilteredRollupScopeCase, + sameNamedFkBaseDuplicateCase, sparseBatchUpdateCase, generatedFormulaColumnCase, legacyGeneratedAuditColumnCase, From 6af8630016b518f4a15500f930a91724094d617f Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 3 Sep 2026 14:56:15 +0800 Subject: [PATCH 17/22] Three summaries over linked rows that gave the wrong answer (#135) * Ask for the largest of a borrowed list and get a number T7099: a column only becomes a list by borrowing across a one-to-many, and a conditional total over such a column had been taught to look inside the list for sum and average but not for largest, smallest, all-of and any-of. Those four went straight at the stored list, which the database refuses - there is no largest of a list - so the column could not be made at all. Sum over the same source builds fine, which makes it look like something about the field rather than about the function. Asking for the total sits inside the checkpoint, because asking is what fails. Kept outside, the same refusal scores as "this case could not run here" rather than as the bug - which is what the first version of this runner did. Two things the case learned the hard way, both now written into it: The expected answers come from the list the product actually built, read back off the middle row, not from the rows this case seeded. Asserting against the seed is asserting against the case's own model of the product. Only the number half is covered. An unticked box does not reach a borrowed list at all - a pair of leaves, one ticked and one not, produces [true] - so all-of and any-of answer the same whether they work or not, and a case built on them would be green on every column. The runner takes only max and min. Reproduced on 46b7ae3cb: "Failed to backfill computed fields [max_of_the_borrowed_list]: function max(jsonb) does not exist", which is the commit message's own words. Also two triage-ledger rows: T7019, an incident about contention between replicas that one application against one database cannot reproduce, and T6988, whose fix's own e2e needs a stubbed snapshot loader and a hand-set doc version that this harness's over-the-wire subscription does not have. Co-Authored-By: Claude Opus 5 * Count what the bracket in the condition says T7080: "orders for this customer that are either unpaid or flagged" is one condition with a bracket in it, and the interface builds it as a group inside a group. The fast path answering this kind of column read the outer conditions and dropped the bracket, so it counted every row matching the customer. The count is wrong upwards and looks ordinary - real rows, right customer, wrong ones. Nothing marks the column, and reopening it still shows the condition in full, bracket included, so there is nothing to see. A second column runs beside it with the same reference match and no bracket. It goes through the same fast path and is correct on both sides of the fix, so the checkpoint reads it FIRST: if the flat one is wrong too, the reference match itself is broken and the failure says so instead of blaming the bracket. The settle loop waits on that control rather than on the column under test - waiting on the latter would be waiting for the bug to go away. The runner refuses three fixtures, each for a stated reason: no row the bracket excludes (a bracket that excludes nothing counts the same either way), no host counting anything (a column stuck on zero passes for the wrong reason), and no host whose reference matches nothing (that row is what says the reference match still applies). Reproduced on c3d0fb6ac, with the control staying correct throughout: host-a withBracket 2, expected 1, rowsMatchingTheReferenceAlone 2 host-b withBracket 2, expected 1, rowsMatchingTheReferenceAlone 2 Co-Authored-By: Claude Opus 5 * Give back the distinct choices in the order the rows are in T7044: two wrong answers from one summary of a choice column. "Todo" then "Done" came back as "Done, Todo" - sorted, not in the order of the rows - and when both children said "Todo", the count of distinct values answered 2, which is the number of rows. Neither reads as a fault. A reordered pair of words looks like a choice the product made, and 2 is a number somebody would act on. What makes it findable is the other summaries on the same row: join and compact are correct, so the row shows "Todo, Done" and "Done, Todo" side by side. Those two ride along as the control, and the checkpoint reads them first - if they disagree with the rows, the whole summary is broken and this is not the distinct-values bug. Both phases run even when the first found something, and the failure carries everything at once. On a pre-fix commit the order is already wrong in phase one, so a checkpoint that stopped there would never reach the count - which only becomes wrong once two children agree - and half the report would be asserted and never demonstrated. Measured on 1c2da4254, both faults in one red: at first, the distinct choices come back as ["Done","Todo"], expected ["Todo","Done"]; after the edit, the count of distinct choices reads 2, expected 1 - which is the number of linked rows, so it is counting rows with joined "Todo, Done" and compacted ["Todo","Done"] correct throughout. The runner refuses children whose choices are already in alphabetical order: a summary that sorted them instead of keeping row order would look correct. Co-Authored-By: Claude Opus 5 * Record that v1 still gives back the sorted answer The acceptance matrix answers red for the v1 reference on every column, develop included: the fix is v2-only, so anyone on the older engine sees both faults today rather than having seen them once. The v1 column is a reference and never gates a run, so this is reported, not enforced. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .../a-condition-with-a-bracket-in-it.case.ts | 42 +++ .../a-condition-with-a-bracket-in-it.md | 51 +++ ...t-choices-in-the-order-they-appear.case.ts | 31 ++ ...stinct-choices-in-the-order-they-appear.md | 75 ++++ .../the-largest-of-a-borrowed-list.case.ts | 36 ++ .../lookup/the-largest-of-a-borrowed-list.md | 69 ++++ docs/triage-ledger.md | 2 + framework/runner-registry.ts | 6 + .../runners/jsonb-lookup-aggregate.runner.ts | 332 +++++++++++++++++ .../nested-group-conditional-rollup.runner.ts | 302 ++++++++++++++++ .../select-rollup-unique-and-count.runner.ts | 341 ++++++++++++++++++ framework/types.ts | 67 ++++ registry.ts | 6 + 13 files changed, 1360 insertions(+) create mode 100644 cases/lookup/a-condition-with-a-bracket-in-it.case.ts create mode 100644 cases/lookup/a-condition-with-a-bracket-in-it.md create mode 100644 cases/lookup/distinct-choices-in-the-order-they-appear.case.ts create mode 100644 cases/lookup/distinct-choices-in-the-order-they-appear.md create mode 100644 cases/lookup/the-largest-of-a-borrowed-list.case.ts create mode 100644 cases/lookup/the-largest-of-a-borrowed-list.md create mode 100644 framework/runners/jsonb-lookup-aggregate.runner.ts create mode 100644 framework/runners/nested-group-conditional-rollup.runner.ts create mode 100644 framework/runners/select-rollup-unique-and-count.runner.ts diff --git a/cases/lookup/a-condition-with-a-bracket-in-it.case.ts b/cases/lookup/a-condition-with-a-bracket-in-it.case.ts new file mode 100644 index 0000000..9716562 --- /dev/null +++ b/cases/lookup/a-condition-with-a-bracket-in-it.case.ts @@ -0,0 +1,42 @@ +import { defineBugCase } from "../../framework/types"; + +// T7080: "orders for this customer that are either unpaid or flagged" is one +// condition with a bracket in it, and the interface builds it as a group inside +// a group. The fast path answering this kind of column read the outer +// conditions and dropped the bracket, so the column counted every row matching +// the customer. The count is wrong upwards and looks ordinary - real rows, right +// customer, wrong ones - nothing marks the column, and reopening it still shows +// the condition in full. +export default defineBugCase({ + id: "lookup/a-condition-with-a-bracket-in-it", + title: "A condition with a bracket in it counts what the bracket says", + runner: "nested-group-conditional-rollup", + timeoutMs: 300_000, + skipV1: + "conditional totals are a v2 column type - v1 has no field to ask this of", + bug: { + issue: "T7080", + status: "fixed", + sourceCommits: ["bfd2d978b"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-nested-or-rollup", + bracketFlagAValue: "no", + bracketFlagBValue: "yes", + flatFlagAValue: "yes", + sourceRows: [ + { name: "a-inside", matchKey: "A", flagA: "yes", flagB: "yes" }, + { name: "a-outside", matchKey: "A", flagA: "yes", flagB: "no" }, + { name: "b-inside", matchKey: "B", flagA: "no", flagB: "no" }, + { name: "b-outside", matchKey: "B", flagA: "yes", flagB: "no" }, + ], + hosts: [ + { name: "host-a", matchKey: "A" }, + { name: "host-b", matchKey: "B" }, + { name: "host-with-nothing", matchKey: "Z" }, + ], + settleTimeoutMs: 60_000, + pollIntervalMs: 500, + }, +}); diff --git a/cases/lookup/a-condition-with-a-bracket-in-it.md b/cases/lookup/a-condition-with-a-bracket-in-it.md new file mode 100644 index 0000000..bd91176 --- /dev/null +++ b/cases/lookup/a-condition-with-a-bracket-in-it.md @@ -0,0 +1,51 @@ +# lookup/a-condition-with-a-bracket-in-it + +**T7080** — fixed. On the `nested-group-conditional-rollup` runner. + +## What the user sees + +A column counts, for each customer row, the orders that match it **and** are +either unpaid or flagged for review. The count is too high: it is the number of +orders for that customer, full stop. The bracket had no effect. + +Nothing indicates this. The count is a real count of real rows belonging to the +right customer; the column is not marked; reopening it shows the condition +written out in full, bracket included. + +## Why + +"Either of these, within that" is a group inside a group. The fast path that +answers this kind of column read the outer conditions and dropped nested groups +on the floor, so what ran was the outer match alone. + +## What the checkpoint asserts + +Two columns are built side by side on the same reference match: one whose +condition has a bracket, one whose condition is flat. + +The **flat** one is checked first. It goes through the same fast path and is +correct on both sides of the fix, so if it is wrong the reference match itself is +broken and this case is about something else — the failure says so rather than +reporting the nested-group bug. + +The bracketed one is then checked per host row. The failure message carries, for +every host, what was counted, what should have been, and how many rows match the +reference alone — because a count equal to that last number is precisely the +bracket having been dropped, and saying so in the message saves the next reader +the arithmetic. + +## Why the fixture is shaped this way + +The runner refuses three kinds of fixture, each for a reason it states: + +- **no row that matches the reference but falls outside the bracket** — a bracket + that excludes nothing counts the same rows whether it is applied or dropped, + and the case would be green on both sides; +- **no host counting anything** — a column stuck on zero would satisfy the + assertion for the wrong reason; +- **no host whose reference matches nothing** — that row is what says the + reference match is still being applied at all. + +The settle loop waits on the **control** column reaching its answers, not the +bracketed one. Waiting on the column under test would mean waiting for the bug +to go away, which on a pre-fix commit is waiting for the timeout. diff --git a/cases/lookup/distinct-choices-in-the-order-they-appear.case.ts b/cases/lookup/distinct-choices-in-the-order-they-appear.case.ts new file mode 100644 index 0000000..35b4754 --- /dev/null +++ b/cases/lookup/distinct-choices-in-the-order-they-appear.case.ts @@ -0,0 +1,31 @@ +import { defineBugCase } from "../../framework/types"; + +// T7044: two wrong answers from one summary. A parent row summarising its +// children's choice column got "Todo" then "Done" back as "Done, Todo" - sorted, +// not in the order of the rows - so it disagreed with the plain list beside it. +// And when both children said "Todo", the count of distinct values answered 2: +// it was counting rows. Neither looks broken; 2 is the number of children, and +// a reordered pair of words reads as an arbitrary choice. +export default defineBugCase({ + id: "lookup/distinct-choices-in-the-order-they-appear", + title: "Distinct choices come back in the order the rows are in", + runner: "select-rollup-unique-and-count", + timeoutMs: 300_000, + bug: { + issue: "T7044", + status: "fixed", + sourceCommits: ["ebd9d7549"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-select-rollup-unique", + parentRowName: "the-parent", + children: [ + { name: "child-first", status: "Todo" }, + { name: "child-second", status: "Done" }, + ], + retarget: { childName: "child-second", status: "Todo" }, + settleTimeoutMs: 60_000, + pollIntervalMs: 500, + }, +}); diff --git a/cases/lookup/distinct-choices-in-the-order-they-appear.md b/cases/lookup/distinct-choices-in-the-order-they-appear.md new file mode 100644 index 0000000..4211bce --- /dev/null +++ b/cases/lookup/distinct-choices-in-the-order-they-appear.md @@ -0,0 +1,75 @@ +# lookup/distinct-choices-in-the-order-they-appear + +**T7044** — fixed. On the `select-rollup-unique-and-count` runner. + +## What the user sees + +A parent row summarises its children's choice column. Two things are wrong at +once: + +- the distinct values come back **sorted** rather than in the order of the rows: + children reading "Todo" then "Done" produce "Done, Todo"; +- when both children read "Todo", the count of distinct values answers **2**. + It is counting rows. + +Neither reads as a fault. A reordered pair of words looks like an arbitrary +choice the product made, and 2 is the number of children, so it is a number +somebody would act on. + +What makes it findable is the other summaries on the same row. Join and compact +over the same column are correct, so the row shows "Todo, Done" and "Done, Todo" +side by side. + +## What the checkpoint asserts + +Both halves, in two phases: + +1. as built — the distinct values are in row order, and there are as many as + there are distinct values; +2. after one child is edited so the two agree — the distinct values collapse to + one, and the count follows. + +The second phase needs a real edit, not a rewrite of the same value: a write that +changes nothing schedules nothing, and the case would be reading the first +computation twice. + +Both phases run even when the first found something, and the failure carries +everything at once. That is not tidiness: on a pre-fix commit the order is +already wrong in phase one, so a checkpoint that stopped there would never reach +the count — the fault that only appears once two children agree — and the second +half of the report would be asserted but never demonstrated. + +Join and compact are read on every check as the **control**. They take the same +path from the same column, so if they disagree with the rows the whole summary is +broken and the failure says so rather than blaming the distinct values. When the +count is wrong, the message also says whether the number it gave equals the +number of linked rows, because that is what "counting rows" looks like and it +saves the next reader the arithmetic. + +## Why the fixture is shaped this way + +The children's choices must not already be in alphabetical order, and the runner +refuses a fixture where they are: sorting "Done" then "Todo" produces "Done, +Todo", which is also the right answer, so a summary that sorted instead of +keeping row order would look correct. + +After the edit at least two children must agree, or counting rows and counting +distinct values give the same number and the second half proves nothing. + +## The v1 column + +v1 reproduces this on **every** column of the acceptance matrix, `develop` +included. The fix is v2-only, so anyone still on the older engine sees both +faults today: the distinct values sorted rather than in row order, and the count +counting rows. + +The v1 column never fails a run — it is a reference, not a gate — so this is +reported rather than enforced. It is also the clearest thing the v1 column has +said so far: not "v1 was affected too", but "v1 still is". + +## Its neighbour + +T7066 (`893d0ce20`) reports the same wrong order, reached differently — through +records created by API rather than by hand, where the first computation comes out +wrong and a later recompute repairs it. Whether this case also settles that one +is a question for a matrix run against its parent, not an assumption. diff --git a/cases/lookup/the-largest-of-a-borrowed-list.case.ts b/cases/lookup/the-largest-of-a-borrowed-list.case.ts new file mode 100644 index 0000000..ff6f2ae --- /dev/null +++ b/cases/lookup/the-largest-of-a-borrowed-list.case.ts @@ -0,0 +1,36 @@ +import { defineBugCase } from "../../framework/types"; + +// T7099: a conditional total asking for the largest or the smallest over a +// column that is itself a borrowed list. Sum and average had been taught +// to look inside those lists; these four had not, and went straight at the +// stored list, which Postgres refuses outright. The column then never produced +// anything - empty, with no explanation, on a field the interface offered to +// build. Sum on the same source works, which makes it look like the data is +// wrong rather than the function. +export default defineBugCase({ + id: "lookup/the-largest-of-a-borrowed-list", + title: "The largest of a borrowed list is a number, not a refusal", + runner: "jsonb-lookup-aggregate", + timeoutMs: 300_000, + skipV1: + "conditional totals are a v2 column type - v1 has no field to ask this of", + bug: { + issue: "T7099", + status: "fixed", + sourceCommits: ["281f6ae1a"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-jsonb-agg", + matchKey: "the-only-group", + middleRowName: "the-team", + hostRowName: "the-report", + leaves: [ + { name: "leaf-small", amount: 10 }, + { name: "leaf-large", amount: 30 }, + ], + aggregations: ["max", "min"], + settleTimeoutMs: 60_000, + pollIntervalMs: 500, + }, +}); diff --git a/cases/lookup/the-largest-of-a-borrowed-list.md b/cases/lookup/the-largest-of-a-borrowed-list.md new file mode 100644 index 0000000..72e3cbf --- /dev/null +++ b/cases/lookup/the-largest-of-a-borrowed-list.md @@ -0,0 +1,69 @@ +# lookup/the-largest-of-a-borrowed-list + +**T7099** — fixed. On the `jsonb-lookup-aggregate` runner. + +## What the user sees + +A report row is given a column totalling across the teams it matches, asking for +the **largest** amount. The column cannot be made: the request comes back +refused, with the database's own words about a function that does not exist. +Smallest behaves the same way. Sum and average over the very same source column +build fine, which makes it look like something about this particular field +rather than about the function. + +The field editor offered all four. + +## Why + +The team row borrows every task's amount, so that column holds a list rather +than one value — that is what borrowing across a one-to-many produces. Sum and +average had been taught to look inside such a list before adding up. Largest, +smallest, all-of and any-of had not: they were applied to the stored list +directly, and Postgres has no largest-of-a-list and will not read a list as a +yes/no. The computation failed and the column never produced anything. + +## What the checkpoint asserts + +Asking for the total is itself inside the checkpoint, because asking is what +fails — before the fix the create is refused outright. Building the chain the +column reads from is setup; asking the question is the observation. Kept the +other way round, the same failure would score as "this case could not run here" +rather than as the bug. + +Each requested total then reads its correct answer, **and** the product does not +mark any of those columns broken. Both directions matter: a column that reads +correctly while still flagged as broken, or one flagged fine while empty, are +each half a fix. The failure message carries both the values read and the broken +list, so a red column says which of the two happened. + +## Why the fixture is a chain of three tables + +Two will not do it. The source column has to hold a list, and a column only +becomes a list by borrowing across a one-to-many — so there is a leaf table with +the values, a middle table borrowing them, and a host table totalling across the +middle. A total taken straight off a plain number column takes a different path +and answers correctly on both sides of the fix. + +The fixture checks that the borrowed columns really do hold lists before going +on, because if they held single values the case would be watching the path that +already worked. + +The expected answers are worked out from the list the product actually built, +read back off the middle row — not from the leaf rows the case seeded. Those are +not the same thing, and asserting against the seed would be asserting against +this case's model of the product rather than against the product. The runner +then refuses a fixture whose borrowed amounts are all equal, since largest and +smallest could not be told apart. + +## Why the tickbox half is not here + +The same fix repaired all-of and any-of over borrowed tickboxes, and this case +deliberately does not cover them. An unticked box does not reach a borrowed list +at all: a pair of leaves, one ticked and one not, produces the borrowed list +`[true]` — measured, not assumed. All-of and any-of over that list both answer +true whether they work or not, so a case built on it would be green on every +column. + +Covering that half needs a source list that can hold `false`, which a borrowed +tickbox column does not appear to produce. The runner refuses the boolean +aggregations rather than asking a question it cannot tell the answer to. diff --git a/docs/triage-ledger.md b/docs/triage-ledger.md index 38bd804..eb3328a 100644 --- a/docs/triage-ledger.md +++ b/docs/triage-ledger.md @@ -140,6 +140,8 @@ The shape is gone; the runner is not kept. | `f160eea3b` | T7065 | Not taken while the fix is unshipped. A share-view scope bypass on the selection `*-by-id` endpoints, CVSS 8.1: the issue was still at "deployed to staging" when this batch was written, and a case here is a working public reproduction. It is a good case once it ships - the repro is a single request with a share header - so this row is a reminder, not a rejection. See CONTRIBUTING.md. | | `ae70b638b` | T7104 | The failure is a connection timeout inside a `table.update` schema operation that then dead-letters after three attempts. What the fix changes is how that timeout is settled - rollback rather than an unrepairable failure - and the lab has no way to make a connection time out on request. Same async-runner trap as T6768 and T6853. | | `8d5c0fe38` | T7067 | Selection aggregation was being answered by v1, where a date column met a cast v1 cannot do. The fix routes it to v2. That makes the pre-fix state "v1 answered", which `assertServedByV2` treats as the case being unable to run (💥) rather than as the bug - so the column that should be red is the one column the harness refuses to read. The observation is real and reachable; expressing it needs a runner allowed to assert that a request was **not** on v2, which does not exist here. | +| `9f5509f48` | T7019 | An incident, not a behaviour. Concurrent replicas UPSERTing the same five-minute query-observation window took transaction locks that held connections until the pool was exhausted; the fix hardens that write. What a case would have to reproduce is contention between replicas, and this harness runs one application against one database - a single writer never conflicts with itself. Belongs in the performance lab if anywhere. | +| `e3bb7671c` | T6988 | Not attempted, on the strength of the fix's own reproduction. The failure needs a client whose local `cmp_` doc was never created while the server snapshot already sits at generation 2 or higher, and the commit's e2e reaches that by stubbing the snapshot loader through a service hook **and** assigning `doc.version = 0` by hand. Neither is available here: the observation seam is a real subscription over the wire, which fetches the snapshot rather than replaying ops from zero. Reproducing it honestly means winning a race - subscribing to an empty doc and having the generation pass 1 before the create op arrives - which is the shape that produces cases green on every column. Worth revisiting if the realtime helper ever exposes the underlying doc. | ### The date comparison inside AND or OR diff --git a/framework/runner-registry.ts b/framework/runner-registry.ts index f56a283..dd2a273 100644 --- a/framework/runner-registry.ts +++ b/framework/runner-registry.ts @@ -114,6 +114,9 @@ import { runAutonumberStringFilterCase } from "./runners/autonumber-string-filte import { runCrossBaseConditionalBaseIdCase } from "./runners/cross-base-conditional-base-id.runner"; import { runOrFilteredRollupScopeCase } from "./runners/or-filtered-rollup-scope.runner"; import { runSameNamedFkBaseDuplicateCase } from "./runners/same-named-fk-base-duplicate.runner"; +import { runJsonbLookupAggregateCase } from "./runners/jsonb-lookup-aggregate.runner"; +import { runNestedGroupConditionalRollupCase } from "./runners/nested-group-conditional-rollup.runner"; +import { runSelectRollupUniqueAndCountCase } from "./runners/select-rollup-unique-and-count.runner"; import { runLookupOfRollupCreateCase } from "./runners/lookup-of-rollup-create.runner"; import type { BugCase, @@ -249,6 +252,9 @@ const runners: { [K in BugRunnerKind]: RunnerFn } = { "cross-base-conditional-base-id": runCrossBaseConditionalBaseIdCase, "or-filtered-rollup-scope": runOrFilteredRollupScopeCase, "same-named-fk-base-duplicate": runSameNamedFkBaseDuplicateCase, + "jsonb-lookup-aggregate": runJsonbLookupAggregateCase, + "nested-group-conditional-rollup": runNestedGroupConditionalRollupCase, + "select-rollup-unique-and-count": runSelectRollupUniqueAndCountCase, }; export const executeRegisteredRunner = ( diff --git a/framework/runners/jsonb-lookup-aggregate.runner.ts b/framework/runners/jsonb-lookup-aggregate.runner.ts new file mode 100644 index 0000000..867945f --- /dev/null +++ b/framework/runners/jsonb-lookup-aggregate.runner.ts @@ -0,0 +1,332 @@ +import { FieldKeyType, FieldType, Relationship } from "@teable/core"; +import { + getFields as apiGetFields, + getRecords as apiGetRecords, +} from "@teable/openapi"; +import { + createField, + createRecords, + createTable, + permanentDeleteTable, +} from "../../../utils/init-app"; +import { bugCheckpoint } from "../checkpoint"; +import { assertServedByV2 } from "../engine"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { JsonbLookupAggregateCaseConfig } from "../types"; + +// A conditional total asking for the LARGEST or the SMALLEST - over a column +// that is itself a borrowed list -> checkpoint: the totals read, and +// they read the right answers. +// +// Chains like this are ordinary. A team row borrows every task's amount from +// the task table, so that column holds a list rather than one value. A report +// row then matches its teams and asks for the largest amount across them. Sum +// and average had been taught to look inside those lists; largest, smallest, +// all-of and any-of had not, and went straight at the stored list. Postgres +// refuses that outright - there is no largest of a list - and the column never +// produced anything. +// +// Only the number half is asked here. The tickbox half of the same fix cannot +// be told apart through a borrowed list: an unticked box does not reach that +// list at all, measured as [true] for a pair of leaves ticked and unticked, so +// all-of and any-of return the same answer whether they work or not. +// +// What the user is left with is a column that stays empty with no explanation, +// on a field the interface offered to build. Sum on the same source works, +// which makes it look like the data is wrong rather than the function. +// +// The chain is three tables because two will not do it: the source column has +// to be a borrowed list, and a column only becomes a list by borrowing across a +// one-to-many. A total straight off a plain number column takes a different +// path and works on both sides of the fix. + +const NAME_FIELD = "Name"; +const AMOUNT_FIELD = "Amount"; +const LEAF_LINK_FIELD = "Leaves"; +const AMOUNT_LOOKUP_FIELD = "Amounts borrowed"; +const MATCH_FIELD = "MatchKey"; + +const sleep = (ms: number) => + new Promise((resolveSleep) => { + setTimeout(resolveSleep, ms); + }); + +export const runJsonbLookupAggregateCase = async ( + bugCase: BugCaseFor<"jsonb-lookup-aggregate">, + context: BugRunContext, +): Promise => { + const config: JsonbLookupAggregateCaseConfig = bugCase.config; + const baseId = globalThis.testConfig.baseId; + const suffix = `${config.tableNamePrefix}-${context.runId}`; + const createdTableIds: string[] = []; + + if (config.leaves.length < 2) { + throw new Error( + "at least two leaf rows, or the borrowed column holds one value and the aggregation has nothing to choose between", + ); + } + + try { + // The far end: the rows carrying the actual values. + const leaf = await createTable(baseId, { + name: `${suffix}-leaf`, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + { name: AMOUNT_FIELD, type: FieldType.Number }, + ], + records: config.leaves.map((row) => ({ + fields: { + [NAME_FIELD]: row.name, + [AMOUNT_FIELD]: row.amount, + }, + })), + }); + createdTableIds.unshift(leaf.id); + const leafAmountId = leaf.fields.find( + (field: { name: string }) => field.name === AMOUNT_FIELD, + )?.id as string; + + // The middle: one row borrowing every leaf value, so its borrowed columns + // hold lists rather than single values. + const middle = await createTable(baseId, { + name: `${suffix}-middle`, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + { name: MATCH_FIELD, type: FieldType.SingleLineText }, + ], + records: [], + }); + createdTableIds.unshift(middle.id); + const middleMatchId = middle.fields.find( + (field: { name: string }) => field.name === MATCH_FIELD, + )?.id as string; + const leafLink = await createField(middle.id, { + name: LEAF_LINK_FIELD, + type: FieldType.Link, + options: { + relationship: Relationship.OneMany, + foreignTableId: leaf.id, + }, + }); + await createRecords(middle.id, { + fieldKeyType: FieldKeyType.Id, + typecast: false, + records: [ + { + fields: { + [middle.fields[0].id]: config.middleRowName, + [middleMatchId]: config.matchKey, + [leafLink.id]: leaf.records.map((record: { id: string }) => ({ + id: record.id, + })), + }, + }, + ], + }); + const amountLookup = await createField(middle.id, { + name: AMOUNT_LOOKUP_FIELD, + type: FieldType.Number, + isLookup: true, + lookupOptions: { + foreignTableId: leaf.id, + linkFieldId: leafLink.id, + lookupFieldId: leafAmountId, + }, + }); + + // Fixture verification, outside the checkpoint: the borrowed columns really + // do hold lists. If they held one value each, the aggregations would take + // the ordinary path and answer correctly on both sides of the fix. + for (const borrowed of [amountLookup]) { + if ( + !(borrowed as { isMultipleCellValue?: boolean }).isMultipleCellValue + ) { + throw new Error( + `the borrowed column ${borrowed.name} does not hold a list - the fixture is not in place`, + ); + } + } + + // What the expected answers are worked out FROM: the lists the product + // actually built, read back off the middle row rather than assumed from the + // leaf rows. The two are not the same - a borrowed tickbox column does not + // necessarily carry an entry for every leaf - and a case that asserted + // against the leaves would be asserting against its own model of the + // product instead of against the product. + const middleRows = await apiGetRecords(middle.id, { + fieldKeyType: FieldKeyType.Id, + take: 1, + }); + const borrowedAmounts = middleRows.data.records[0]?.fields[ + amountLookup.id + ] as number[] | undefined; + if (!Array.isArray(borrowedAmounts) || borrowedAmounts.length < 2) { + throw new Error( + `the borrowed amounts read ${JSON.stringify(borrowedAmounts)} - the aggregation needs a list to choose between`, + ); + } + const expected: Record = { + max: Math.max(...borrowedAmounts), + min: Math.min(...borrowedAmounts), + }; + if (expected.max === expected.min) { + throw new Error( + `the borrowed amounts are all ${expected.max} - largest and smallest cannot be told apart`, + ); + } + + // The near end: a row matching the middle row and totalling across it. + const host = await createTable(baseId, { + name: `${suffix}-host`, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + { name: MATCH_FIELD, type: FieldType.SingleLineText }, + ], + records: [ + { + fields: { + [NAME_FIELD]: config.hostRowName, + [MATCH_FIELD]: config.matchKey, + }, + }, + ], + }); + createdTableIds.unshift(host.id); + const hostMatchId = host.fields.find( + (field: { name: string }) => field.name === MATCH_FIELD, + )?.id as string; + + const matchFilter = { + conjunction: "and", + filterSet: [ + { + fieldId: middleMatchId, + operator: "is", + value: { type: "field", fieldId: hostMatchId }, + }, + ], + }; + + const readHost = async () => { + const response = await apiGetRecords(host.id, { + fieldKeyType: FieldKeyType.Name, + take: 1, + }); + return { + headers: response.headers, + fields: response.data.records[0]?.fields ?? {}, + }; + }; + + // The engine assertion, on the read that derives the expected answers and + // on the same endpoint and feature the checkpoint reads through. Outside + // the checkpoint, so a v1 answer is the case failing to run rather than the + // bug. + const routing = assertServedByV2(middleRows.headers, { + operation: "GET /table/{tableId}/record", + feature: "getRecords", + }); + + const probe = await bugCheckpoint( + "the-largest-of-a-borrowed-list-is-a-number", + async () => { + // Asking for the total is INSIDE the checkpoint, because asking is what + // fails: before the fix the create refuses outright, with the database + // saying there is no largest of a list. Building the chain that column + // reads from is setup; asking the question is the observation. + const totals: { name: string; expression: string; expected: number }[] = + []; + for (const which of config.aggregations) { + const name = `${which} of the borrowed list`; + await createField(host.id, { + name, + type: FieldType.ConditionalRollup, + options: { + foreignTableId: middle.id, + lookupFieldId: amountLookup.id, + expression: `${which}({values})`, + filter: matchFilter, + }, + }); + totals.push({ + name, + expression: `${which}({values})`, + expected: expected[which], + }); + } + + // Waiting for the answers to arrive, not for the bug to appear: the + // loop leaves as soon as every total reads what it should. + const deadline = Date.now() + config.settleTimeoutMs; + let settled = await readHost(); + for (;;) { + const done = totals.every( + (total) => settled.fields[total.name] === total.expected, + ); + if (done || Date.now() >= deadline) { + break; + } + await sleep(config.pollIntervalMs); + settled = await readHost(); + } + + const observed = totals.map((total) => ({ + total: total.expression, + read: settled.fields[total.name] ?? null, + expected: total.expected, + })); + + // The columns' own state as well: a column the product marks broken is + // the honest half of this, and it says the failure is the function + // rather than the data. + const hostFields = await apiGetFields(host.id); + const broken = hostFields.data + .filter( + (field: { name: string; hasError?: boolean }) => + field.hasError && + totals.some((total) => total.name === field.name), + ) + .map((field: { name: string }) => field.name); + + const wrong = observed.filter((item) => item.read !== item.expected); + if (wrong.length > 0) { + throw new Error( + `the totals over a borrowed list read ${JSON.stringify(observed)}` + + (broken.length > 0 + ? `; the product marks these columns broken: ${JSON.stringify(broken)}` + : "; the product does not mark any of them broken"), + ); + } + if (broken.length > 0) { + throw new Error( + `the totals read correctly but the product marks ${JSON.stringify(broken)} broken`, + ); + } + return { observed }; + }, + ); + + return { + details: { + leafTableId: leaf.id, + middleTableId: middle.id, + hostTableId: host.id, + routing, + ...probe, + }, + }; + } finally { + for (const tableId of createdTableIds) { + try { + await permanentDeleteTable(baseId, tableId); + } catch (error) { + // Cleanup is the case's own housekeeping - the product did not fail. + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/runners/nested-group-conditional-rollup.runner.ts b/framework/runners/nested-group-conditional-rollup.runner.ts new file mode 100644 index 0000000..3fd4db0 --- /dev/null +++ b/framework/runners/nested-group-conditional-rollup.runner.ts @@ -0,0 +1,302 @@ +import { FieldKeyType, FieldType } from "@teable/core"; +import { getRecords as apiGetRecords } from "@teable/openapi"; +import { + createField, + createTable, + permanentDeleteTable, +} from "../../../utils/init-app"; +import { bugCheckpoint } from "../checkpoint"; +import { assertServedByV2 } from "../engine"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { NestedGroupConditionalRollupCaseConfig } from "../types"; + +// A conditional total whose condition holds a GROUP - match on a shared +// reference, and within that, either of two other things -> checkpoint: the +// count is of the rows the whole condition describes. +// +// "Orders for this customer that are either unpaid or flagged for review" is +// one condition with a bracket in it, and the interface builds it as a group +// inside a group. The fast path that answers this kind of column read the outer +// conditions and dropped the bracket entirely, so the column counted every row +// that matched the customer - the bracket might as well not have been typed. +// +// The count is wrong upwards and looks ordinary: it is a count of real rows for +// the right customer, just not the ones asked for. Nothing marks the column, and +// the condition is still displayed in full when the column is reopened, so +// there is nothing to see. +// +// A second column runs beside it with the same reference match and a FLAT +// condition. It is the control: it goes through the same fast path, and if it +// were wrong too then the reference match itself is broken and this case is +// about something else. + +const NAME_FIELD = "Name"; +const MATCH_FIELD = "MatchKey"; +const FLAG_A_FIELD = "FlagA"; +const FLAG_B_FIELD = "FlagB"; +const NESTED_COUNT_FIELD = "Count with the bracket"; +const FLAT_COUNT_FIELD = "Count without one"; + +const sleep = (ms: number) => + new Promise((resolveSleep) => { + setTimeout(resolveSleep, ms); + }); + +export const runNestedGroupConditionalRollupCase = async ( + bugCase: BugCaseFor<"nested-group-conditional-rollup">, + context: BugRunContext, +): Promise => { + const config: NestedGroupConditionalRollupCaseConfig = bugCase.config; + const baseId = globalThis.testConfig.baseId; + const suffix = `${config.tableNamePrefix}-${context.runId}`; + const createdTableIds: string[] = []; + + // The two conditions the runner builds, written once so the expected answers + // and the filters cannot drift apart. + const insideTheBracket = (row: { flagA: string; flagB: string }) => + row.flagA === config.bracketFlagAValue || + row.flagB === config.bracketFlagBValue; + const flatCondition = (row: { flagA: string }) => + row.flagA === config.flatFlagAValue; + + const expected = config.hosts.map((host) => { + const matched = config.sourceRows.filter( + (row) => row.matchKey === host.matchKey, + ); + return { + host: host.name, + matchedByReference: matched.length, + nested: matched.filter(insideTheBracket).length, + flat: matched.filter(flatCondition).length, + }; + }); + + // The guard that makes this case worth running. If the bracket never excludes + // anything, a build that dropped it entirely counts the same rows and the + // case is green on both sides of the fix. + if (!expected.some((row) => row.nested < row.matchedByReference)) { + throw new Error( + `no host has a row that matches the reference and falls outside the bracket: ${JSON.stringify(expected)}. ` + + "A condition whose bracket excludes nothing cannot tell a dropped bracket from an applied one", + ); + } + if (!expected.some((row) => row.nested > 0)) { + throw new Error( + "no host counts anything at all - a column stuck on zero would satisfy this case for the wrong reason", + ); + } + if (!expected.some((row) => row.matchedByReference === 0)) { + throw new Error( + "no host without matching rows - the row that should count nothing is what says the reference match still applies", + ); + } + + try { + const source = await createTable(baseId, { + name: `${suffix}-source`, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + { name: MATCH_FIELD, type: FieldType.SingleLineText }, + { name: FLAG_A_FIELD, type: FieldType.SingleLineText }, + { name: FLAG_B_FIELD, type: FieldType.SingleLineText }, + ], + records: config.sourceRows.map((row) => ({ + fields: { + [NAME_FIELD]: row.name, + [MATCH_FIELD]: row.matchKey, + [FLAG_A_FIELD]: row.flagA, + [FLAG_B_FIELD]: row.flagB, + }, + })), + }); + createdTableIds.unshift(source.id); + const fieldId = (name: string) => { + const found = source.fields.find( + (field: { name: string }) => field.name === name, + )?.id; + if (!found) { + throw new Error(`the source table has no ${name} column`); + } + return found as string; + }; + const sourceMatchId = fieldId(MATCH_FIELD); + const sourceFlagAId = fieldId(FLAG_A_FIELD); + const sourceFlagBId = fieldId(FLAG_B_FIELD); + const sourceNameId = fieldId(NAME_FIELD); + + const host = await createTable(baseId, { + name: `${suffix}-host`, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + { name: MATCH_FIELD, type: FieldType.SingleLineText }, + ], + records: config.hosts.map((row) => ({ + fields: { [NAME_FIELD]: row.name, [MATCH_FIELD]: row.matchKey }, + })), + }); + createdTableIds.unshift(host.id); + const hostMatchId = host.fields.find( + (field: { name: string }) => field.name === MATCH_FIELD, + )?.id as string; + + const referenceMatch = { + fieldId: sourceMatchId, + operator: "is", + value: { type: "field", fieldId: hostMatchId }, + }; + + // The column under test: the reference match, and inside it a bracket. + await createField(host.id, { + name: NESTED_COUNT_FIELD, + type: FieldType.ConditionalRollup, + options: { + foreignTableId: source.id, + lookupFieldId: sourceNameId, + expression: "countall({values})", + filter: { + conjunction: "and", + filterSet: [ + referenceMatch, + { + conjunction: "or", + filterSet: [ + { + fieldId: sourceFlagAId, + operator: "is", + value: config.bracketFlagAValue, + }, + { + fieldId: sourceFlagBId, + operator: "is", + value: config.bracketFlagBValue, + }, + ], + }, + ], + }, + }, + }); + + // The control, beside it: same reference match, no bracket. + await createField(host.id, { + name: FLAT_COUNT_FIELD, + type: FieldType.ConditionalRollup, + options: { + foreignTableId: source.id, + lookupFieldId: sourceNameId, + expression: "countall({values})", + filter: { + conjunction: "and", + filterSet: [ + referenceMatch, + { + fieldId: sourceFlagAId, + operator: "is", + value: config.flatFlagAValue, + }, + ], + }, + }, + }); + + const readHosts = async () => { + const response = await apiGetRecords(host.id, { + fieldKeyType: FieldKeyType.Name, + take: config.hosts.length, + }); + const byName = new Map( + response.data.records.map((record) => [ + String(record.fields[NAME_FIELD]), + record.fields, + ]), + ); + return { headers: response.headers, byName }; + }; + + // Settling on the CONTROL reaching its answers: that column is correct on + // both sides of the fix, so waiting for it is waiting for the computation + // to finish rather than for the bug to appear or disappear. + const deadline = Date.now() + config.settleTimeoutMs; + let settled = await readHosts(); + for (;;) { + const controlReady = expected.every( + (row) => + Number(settled.byName.get(row.host)?.[FLAT_COUNT_FIELD] ?? 0) === + row.flat, + ); + if (controlReady || Date.now() >= deadline) { + break; + } + await sleep(config.pollIntervalMs); + settled = await readHosts(); + } + + const routing = assertServedByV2(settled.headers, { + operation: "GET /table/{tableId}/record", + feature: "getRecords", + }); + + const probe = await bugCheckpoint( + "a-condition-with-a-bracket-in-it-counts-what-it-says", + async () => { + const observed = expected.map((row) => ({ + host: row.host, + withBracket: Number( + settled.byName.get(row.host)?.[NESTED_COUNT_FIELD] ?? 0, + ), + expectedWithBracket: row.nested, + withoutBracket: Number( + settled.byName.get(row.host)?.[FLAT_COUNT_FIELD] ?? 0, + ), + expectedWithoutBracket: row.flat, + rowsMatchingTheReferenceAlone: row.matchedByReference, + })); + + // The control first. If the flat condition is wrong too, the reference + // match itself is broken and the bracket is not what this is about. + const controlWrong = observed.filter( + (row) => row.withoutBracket !== row.expectedWithoutBracket, + ); + if (controlWrong.length > 0) { + throw new Error( + `the control column, which has no bracket, is wrong as well: ${JSON.stringify(observed)}. ` + + "The reference match itself is not working, so this is not the nested-group bug", + ); + } + + const wrong = observed.filter( + (row) => row.withBracket !== row.expectedWithBracket, + ); + if (wrong.length > 0) { + throw new Error( + `the column whose condition has a bracket counts ${JSON.stringify(observed)}. ` + + "A count equal to rowsMatchingTheReferenceAlone is the bracket having been dropped", + ); + } + return { observed }; + }, + ); + + return { + details: { + sourceTableId: source.id, + hostTableId: host.id, + routing, + ...probe, + }, + }; + } finally { + for (const tableId of createdTableIds) { + try { + await permanentDeleteTable(baseId, tableId); + } catch (error) { + // Cleanup is the case's own housekeeping - the product did not fail. + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/runners/select-rollup-unique-and-count.runner.ts b/framework/runners/select-rollup-unique-and-count.runner.ts new file mode 100644 index 0000000..306b7bf --- /dev/null +++ b/framework/runners/select-rollup-unique-and-count.runner.ts @@ -0,0 +1,341 @@ +import { Colors, FieldKeyType, FieldType, Relationship } from "@teable/core"; +import { + createRecords as apiCreateRecords, + getRecords as apiGetRecords, + updateRecord as apiUpdateRecord, +} from "@teable/openapi"; +import { + createField, + createTable, + permanentDeleteTable, +} from "../../../utils/init-app"; +import { bugCheckpoint } from "../checkpoint"; +import { assertServedByV2 } from "../engine"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { SelectRollupUniqueAndCountCaseConfig } from "../types"; + +// A parent row summarising a choice column across the children it is linked to +// -> checkpoint: the distinct values come back in the order they first appear, +// and the count is of distinct values. +// +// Two wrong answers from one column type. "Todo" then "Done" came back as +// "Done, Todo" - sorted, not in the order of the rows - so the summary +// disagreed with the list beside it, which was right. And when both children +// said "Todo", the count of distinct values answered 2: it was counting rows. +// +// Neither looks broken. A reordered list of two words reads as an arbitrary +// choice rather than a fault, and 2 is the number of children, so it is a +// number somebody can believe. What makes them findable at all is the other +// summaries over the same column - join and compact - which are correct, so the +// row shows "Todo, Done" and "Done, Todo" side by side. +// +// Those two ride along as the control. They take the same path from the same +// source, so if they are wrong too, this is not the distinct-values bug. + +const NAME_FIELD = "Name"; +const STATUS_FIELD = "Status"; +const LINK_FIELD = "Children"; +const JOIN_FIELD = "Joined"; +const COMPACT_FIELD = "Compacted"; +const UNIQUE_FIELD = "Distinct, in order"; +const COUNT_FIELD = "How many distinct"; + +const sleep = (ms: number) => + new Promise((resolveSleep) => { + setTimeout(resolveSleep, ms); + }); + +const firstAppearanceUnique = (values: string[]) => [...new Set(values)]; + +export const runSelectRollupUniqueAndCountCase = async ( + bugCase: BugCaseFor<"select-rollup-unique-and-count">, + context: BugRunContext, +): Promise => { + const config: SelectRollupUniqueAndCountCaseConfig = bugCase.config; + const baseId = globalThis.testConfig.baseId; + const suffix = `${config.tableNamePrefix}-${context.runId}`; + const createdTableIds: string[] = []; + + const initialStatuses = config.children.map((child) => child.status); + const initialUnique = firstAppearanceUnique(initialStatuses); + if (initialUnique.length < 2) { + throw new Error( + "the children need at least two different choices, or there is no order to get wrong", + ); + } + if ( + JSON.stringify(initialUnique) === JSON.stringify([...initialUnique].sort()) + ) { + throw new Error( + `the children's choices ${JSON.stringify(initialUnique)} are already in alphabetical order - ` + + "a summary that sorted them instead of keeping the row order would look correct", + ); + } + + const afterStatuses = config.children.map((child) => + child.name === config.retarget.childName + ? config.retarget.status + : child.status, + ); + const afterUnique = firstAppearanceUnique(afterStatuses); + if (afterUnique.length >= afterStatuses.length) { + throw new Error( + `after the edit the children hold ${JSON.stringify(afterStatuses)}, all different - ` + + "counting rows and counting distinct values would give the same answer", + ); + } + if ( + !config.children.some((child) => child.name === config.retarget.childName) + ) { + throw new Error( + `there is no child called ${JSON.stringify(config.retarget.childName)} to edit`, + ); + } + + const choices = [...new Set([...initialStatuses, config.retarget.status])]; + + try { + const children = await createTable(baseId, { + name: `${suffix}-children`, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + { + name: STATUS_FIELD, + type: FieldType.SingleSelect, + options: { + choices: choices.map((name) => ({ name, color: Colors.Blue })), + }, + }, + ], + records: config.children.map((child) => ({ + fields: { [NAME_FIELD]: child.name, [STATUS_FIELD]: child.status }, + })), + }); + createdTableIds.unshift(children.id); + const statusFieldId = children.fields.find( + (field: { name: string }) => field.name === STATUS_FIELD, + )?.id as string; + const childIdByName = new Map( + children.records.map( + (record: { id: string; fields: Record }) => [ + String(record.fields[NAME_FIELD]), + record.id, + ], + ), + ); + + const parent = await createTable(baseId, { + name: `${suffix}-parent`, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + ], + records: [], + }); + createdTableIds.unshift(parent.id); + const linkField = await createField(parent.id, { + name: LINK_FIELD, + type: FieldType.Link, + options: { + relationship: Relationship.OneMany, + foreignTableId: children.id, + }, + }); + + // The link is written in the children's declared order, which is the order + // the summary is supposed to keep. + await apiCreateRecords(parent.id, { + fieldKeyType: FieldKeyType.Name, + typecast: false, + records: [ + { + fields: { + [NAME_FIELD]: config.parentRowName, + [LINK_FIELD]: config.children.map((child) => ({ + id: childIdByName.get(child.name) as string, + })), + }, + }, + ], + }); + + const summary = async (name: string, expression: string) => + createField(parent.id, { + name, + type: FieldType.Rollup, + options: { expression }, + lookupOptions: { + foreignTableId: children.id, + linkFieldId: linkField.id, + lookupFieldId: statusFieldId, + }, + }); + await summary(JOIN_FIELD, "array_join({values})"); + await summary(COMPACT_FIELD, "array_compact({values})"); + await summary(UNIQUE_FIELD, "array_unique({values})"); + await summary(COUNT_FIELD, "count({values})"); + + const readParent = async () => { + const response = await apiGetRecords(parent.id, { + fieldKeyType: FieldKeyType.Name, + take: 1, + }); + return { + headers: response.headers, + fields: response.data.records[0]?.fields ?? {}, + }; + }; + + // Settling on the CONTROL, which is correct on both sides of the fix: + // waiting for the computation to finish rather than for the bug. + const settleOnControl = async (expectedJoined: string[]) => { + const deadline = Date.now() + config.settleTimeoutMs; + let seen = await readParent(); + for (;;) { + const joined = seen.fields[JOIN_FIELD]; + const asList = Array.isArray(joined) + ? joined.map(String) + : String(joined ?? "") + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + if ( + JSON.stringify(asList) === JSON.stringify(expectedJoined) || + Date.now() >= deadline + ) { + return seen; + } + await sleep(config.pollIntervalMs); + seen = await readParent(); + } + }; + + const settled = await settleOnControl(initialStatuses); + const routing = assertServedByV2(settled.headers, { + operation: "GET /table/{tableId}/record", + feature: "getRecords", + }); + + const asList = (value: unknown) => + Array.isArray(value) + ? value.map(String) + : String(value ?? "") + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + + const probe = await bugCheckpoint( + "distinct-choices-come-back-in-the-order-they-appear", + async () => { + const check = ( + seen: Record, + expectedUnique: string[], + when: string, + ) => { + const scene = { + when, + joined: seen[JOIN_FIELD] ?? null, + compacted: seen[COMPACT_FIELD] ?? null, + distinct: seen[UNIQUE_FIELD] ?? null, + howManyDistinct: seen[COUNT_FIELD] ?? null, + }; + + // The control first. Join and compact take the same path from the + // same column; if they are wrong, this is not the distinct-values bug. + const joined = asList(seen[JOIN_FIELD]); + const compacted = asList(seen[COMPACT_FIELD]); + const rowOrder = + when === "at first" ? initialStatuses : afterStatuses; + if ( + JSON.stringify(joined) !== JSON.stringify(rowOrder) || + JSON.stringify(compacted) !== JSON.stringify(rowOrder) + ) { + throw new Error( + `the summaries that are not under test disagree with the rows ${JSON.stringify(rowOrder)} ` + + `${when}: ${JSON.stringify(scene)}. The whole summary is wrong, not the distinct values`, + ); + } + + // Every wrong answer at once, rather than the first. The two halves + // of this report are separate faults in the same column, and a + // failure that stopped at the order would leave the count untested on + // exactly the commits where it is broken. + const problems: string[] = []; + + const distinct = asList(seen[UNIQUE_FIELD]); + if (JSON.stringify(distinct) !== JSON.stringify(expectedUnique)) { + problems.push( + `the distinct choices come back as ${JSON.stringify(distinct)}, ` + + `expected ${JSON.stringify(expectedUnique)} - the order the rows are in`, + ); + } + const howMany = Number(seen[COUNT_FIELD]); + if (howMany !== expectedUnique.length) { + problems.push( + `the count of distinct choices reads ${JSON.stringify(seen[COUNT_FIELD])}, ` + + `expected ${expectedUnique.length}` + + (howMany === rowOrder.length + ? " - which is the number of linked rows, so it is counting rows" + : ""), + ); + } + return { scene, problems }; + }; + + // Both phases run even if the first found something. The two halves of + // this report are separate faults in one column, and stopping at the + // order would leave the count undemonstrated on exactly the commits + // where it is broken - the order is wrong there first, and the count + // only becomes wrong once two children agree. + const first = check(settled.fields, initialUnique, "at first"); + + // The second half of the report: make two children agree, so the count + // of distinct values and the count of rows stop being the same number. + await apiUpdateRecord( + children.id, + childIdByName.get(config.retarget.childName) as string, + { + fieldKeyType: FieldKeyType.Id, + record: { fields: { [statusFieldId]: config.retarget.status } }, + }, + ); + const after = await settleOnControl(afterStatuses); + const second = check(after.fields, afterUnique, "after the edit"); + + const problems = [ + ...first.problems.map((problem) => `at first, ${problem}`), + ...second.problems.map((problem) => `after the edit, ${problem}`), + ]; + if (problems.length > 0) { + throw new Error( + `${problems.join("; ")}. The row read ${JSON.stringify(first.scene)} ` + + `and then ${JSON.stringify(second.scene)}`, + ); + } + + return { first: first.scene, second: second.scene }; + }, + ); + + return { + details: { + childrenTableId: children.id, + parentTableId: parent.id, + routing, + ...probe, + }, + }; + } finally { + for (const tableId of createdTableIds) { + try { + await permanentDeleteTable(baseId, tableId); + } catch (error) { + // Cleanup is the case's own housekeeping - the product did not fail. + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/types.ts b/framework/types.ts index 718343e..bf10da6 100644 --- a/framework/types.ts +++ b/framework/types.ts @@ -124,6 +124,9 @@ export interface BugCaseConfigByRunner { "cross-base-conditional-base-id": CrossBaseConditionalBaseIdCaseConfig; "or-filtered-rollup-scope": OrFilteredRollupScopeCaseConfig; "same-named-fk-base-duplicate": SameNamedFkBaseDuplicateCaseConfig; + "jsonb-lookup-aggregate": JsonbLookupAggregateCaseConfig; + "nested-group-conditional-rollup": NestedGroupConditionalRollupCaseConfig; + "select-rollup-unique-and-count": SelectRollupUniqueAndCountCaseConfig; } export type BugRunnerKind = keyof BugCaseConfigByRunner; @@ -2030,3 +2033,67 @@ export interface SameNamedFkBaseDuplicateCaseConfig { // The single row each table carries, so the copy has rows to move. rowTitle: string; } + +// A conditional total whose source column is itself a borrowed list, which is +// where largest/smallest/all-of/any-of went straight at the stored list. +export interface JsonbLookupAggregateCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + // The rows at the far end of the chain. Two at least, with amounts that + // differ - otherwise largest and smallest cannot be told apart. The runner + // checks that against the list the product actually built, not against these. + leaves: { name: string; amount: number }[]; + // The row in the middle table, which borrows every leaf value. + middleRowName: string; + // The row doing the totalling. + hostRowName: string; + // Written to both middle and host, so the condition selects the middle row. + matchKey: string; + // Which aggregations to ask for. The tickbox half of this fix ("and"/"or") + // is deliberately absent: an unticked box does not reach a borrowed list, so + // all-of and any-of answer the same whether they work or not. See the runner. + aggregations: ("max" | "min")[]; + settleTimeoutMs: number; + pollIntervalMs: number; +} + +// A conditional total whose condition has a bracket in it - "match the +// customer, and within that either of two other things". +export interface NestedGroupConditionalRollupCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + sourceRows: { + name: string; + matchKey: string; + flagA: string; + flagB: string; + }[]; + // The rows doing the counting. The runner refuses a fixture without a host + // whose reference matches nothing, without a host counting anything, or + // without a row the bracket excludes - see the runner for why each is needed. + hosts: { name: string; matchKey: string }[]; + // The bracket: FlagA is this OR FlagB is that. + bracketFlagAValue: string; + bracketFlagBValue: string; + // The control column beside it, with no bracket: FlagA is this. + flatFlagAValue: string; + settleTimeoutMs: number; + pollIntervalMs: number; +} + +// A summary of a choice column across linked children: the distinct values and +// how many there are. +export interface SelectRollupUniqueAndCountCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + // The children, in the order the link is written. Their choices must not + // already be in alphabetical order, or a summary that sorted them would look + // correct - the runner refuses that. + children: { name: string; status: string }[]; + parentRowName: string; + // The edit that makes two children agree, so counting rows and counting + // distinct values stop giving the same answer. + retarget: { childName: string; status: string }; + settleTimeoutMs: number; + pollIntervalMs: number; +} diff --git a/registry.ts b/registry.ts index 96fffb2..4c933ef 100644 --- a/registry.ts +++ b/registry.ts @@ -32,6 +32,9 @@ import crossBaseConditionalBaseIdCase from "./cases/field/a-cross-base-condition import duplicatedTableStartsUnsharedCase from "./cases/table/a-duplicated-table-starts-unshared.case"; import orFilteredRollupScopeCase from "./cases/lookup/an-any-of-these-total-stays-inside-its-link.case"; import sameNamedFkBaseDuplicateCase from "./cases/base-share/copy-a-base-whose-tables-share-a-key-name.case"; +import jsonbLookupAggregateCase from "./cases/lookup/the-largest-of-a-borrowed-list.case"; +import nestedGroupConditionalRollupCase from "./cases/lookup/a-condition-with-a-bracket-in-it.case"; +import selectRollupUniqueAndCountCase from "./cases/lookup/distinct-choices-in-the-order-they-appear.case"; import sparseBatchUpdateCase from "./cases/record/a-batch-write-leaves-what-it-did-not-mention.case"; import generatedFormulaColumnCase from "./cases/record/edit-a-cell-behind-a-generated-formula.case"; import legacyGeneratedAuditColumnCase from "./cases/record/add-a-row-to-a-legacy-table.case"; @@ -174,6 +177,9 @@ const cases = [ duplicatedTableStartsUnsharedCase, orFilteredRollupScopeCase, sameNamedFkBaseDuplicateCase, + jsonbLookupAggregateCase, + nestedGroupConditionalRollupCase, + selectRollupUniqueAndCountCase, sparseBatchUpdateCase, generatedFormulaColumnCase, legacyGeneratedAuditColumnCase, From 11838ef8487ac97b95c13a845410d86e8ff36f82 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 3 Sep 2026 15:49:19 +0800 Subject: [PATCH 18/22] A share link, a joined formula, and two records that share a name (#136) * Treat two linked records sharing a name as two records T7082: two records are the same record when they have the same id, not when they happen to be called the same thing - and nothing stops two rows sharing a name, because a name is a value someone typed. A summary of the distinct linked records compared what it displayed rather than what it held, so two different records both called "Same" collapsed into one and a real linked record left the answer. It leaves quietly. The column is not marked, the result is a plausible list of names, and the only way to notice is to count it against the summary beside it that keeps everything. Whatever reads the column next is short by one. The checkpoint asserts no list of names. It asserts that the distinct summary equals the keep-everything summary, because when every linked record is a different record those two ARE the same answer. That invariant holds whatever shape the cells come back in, which is not a hypothetical here: the issue was fixed twice and the second commit changed exactly that shape. Measured across all three states: d09c75728 keeps three {id,title} distinct ["Same","Other"] 692c2b4b5 keeps three {id,title} distinct ["Same","Same","Other"] develop keeps three titles distinct the same three titles The middle row is why both commits are named in sourceCommits: after the first, the identity is right and the two answers still disagree. A written-in list of names would have been "correct" there and rewritten by the second commit - a case following the product rather than holding it in place. Co-Authored-By: Claude Opus 5 * Write down why T7070 cannot be asked here The fixture builds and the write is refused, but in the wrong phase: the insert raises it, while the fix repairs the deferred propagate. The commit's own e2e reaches that second call site by draining an outbox inside the v2 test container; this harness runs the Nest application, where a small fixture computes inline and never gets there. Both link directions tried. The attempt also turned up something that is not T7070 and is not fixed: on develop, a base with a missing link key column refuses inserts into the table on the other side outright, before any propagation. Recorded under the table so the next pass does not rediscover it. Co-Authored-By: Claude Opus 5 * Write down two more that cannot be asked here T7066 was written and run twice against the fix's parent, green both times. Its precondition is that the summaries exist before the row does, so the answer is worked out during the write - built first as one call writing the row and its links, then as the two writes the report's own steps describe. Neither reproduced, and the T7044 sibling case is green on that parent too, measured, so it does not cover this one either. The shape stays behind the runner's whenTheRowIsWritten and alsoCheckAfterAnEdit config values so a third attempt does not start from nothing. T7047 reads through the v2 contract's own list endpoint rather than the public record API, and most of what it changes is performance-shaped - skipping count(*), paging by cursor instead of OFFSET - so much of it introduces the path it repairs. Co-Authored-By: Claude Opus 5 * Let a table whose formula joins people columns take a row T7024: "everyone involved, listed once, separated by commas" over seven people columns, written four functions deep. Each layer re-stated the whole of the layer inside it, so the statement the database was asked to plan grew with every one and reached megabytes. The row is recomputed inside the write, so nothing came back: the page spun and the gateway gave up. The table could not accept a row - not slowly, at all - and all a person could see was a timeout. Making the formula column is inside the checkpoint. What grew is the statement, and planning it is what fails, so it fails when the column is made as readily as when a row is added: on the fix's parent 42c9ba98a the case never reaches the write, because creating the column already answers Unexpected unit of work error: Error: Client has encountered a connection error and is not queryable which is the message from the customer's own backend log. Built with the column in setup - as this runner first was - that failure scores as "this case could not run here", the one verdict that hides the bug. The write carries its own time limit rather than being allowed to hang, for the same reason: a request that never answers would run out the whole case. The limit is generous on purpose. This is not a measurement of speed and does not belong in the performance lab - the difference asserted is between an answer and no answer. Seven people columns because that is where the report was filed and because the statement grew with the count; fewer may plan something large that still completes, which would be green on both sides. Co-Authored-By: Claude Opus 5 * Tell a share link that its database is away, not that we broke T6926: a space can be bound to a customer's own database, and that binding can be switched off - revoked credentials, a retired connection, a migration part way. The share link, the view and the permission are all still correct; there is simply nowhere to read from. What came back was an unhandled 500. Whoever holds a share link is usually outside the company, with no account, nothing else to look at and nobody to ask. A 500 tells them the product is broken and there is nothing to do about it. A 503 naming an unavailable database tells them - and anything watching the endpoint - that the same page will work later. The checkpoint asserts the status AND the code: a 503 that does not say why is indistinguishable from any other outage, and being distinguishable is the whole of the fix. A 200 is called out separately, because a share link answering normally while its database is away would be worse than the bug. The binding goes in with SQL - binding a space to another database is not part of this observation, and a switched-off connection is not something a request can ask for - and the fixture opens the link and requires a 200 first, or a 503 afterwards could just as well mean the share was never set up. Reproduced on aa1f9f883: 500, code internal_server_error, carrying the raw DataDbBindingNotReadyError from resolveSpaceDataDb, which is where the commit message says it came from. The separator in the T7024 case is now a plain comma. The customer's was an ideographic one and this repository is English-only; what grows the statement is the nesting, not the character, and the case was re-run on the fix's parent to confirm it still reproduces. Co-Authored-By: Claude Opus 5 * Declare what the v1 column can and cannot say about these two T6926 is skipped on v1, for a reason about this harness rather than the product: the case makes its own space and base, and case-base.ts unstamps only the base it manages, so a base born inside a runner is born on v2. The v1 run answered "requested of v1 but v2 answered (reason=new_base)" - the harness refusing to fabricate a reference column. Any runner that creates its own base inherits this. T7082 is red for v1 on every column, develop included: both fixes are v2-only, so a summary on the older engine still merges two records sharing a name and still loses one. Reported, not enforced - the second case here to say that about v1. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- ...-share-link-whose-database-is-away.case.ts | 27 ++ .../a-share-link-whose-database-is-away.md | 62 +++++ ...t-choices-in-the-order-they-appear.case.ts | 2 + ...ords-with-one-name-are-two-records.case.ts | 29 ++ ...o-records-with-one-name-are-two-records.md | 85 ++++++ ...-a-table-that-joins-people-columns.case.ts | 34 +++ ...ow-to-a-table-that-joins-people-columns.md | 69 +++++ docs/triage-ledger.md | 16 ++ framework/runner-registry.ts | 6 + .../link-rollup-unique-by-identity.runner.ts | 259 ++++++++++++++++++ .../nested-user-array-join-create.runner.ts | 248 +++++++++++++++++ .../select-rollup-unique-and-count.runner.ts | 70 ++++- .../share-view-unready-data-db.runner.ts | 210 ++++++++++++++ framework/types.ts | 61 ++++- registry.ts | 6 + 15 files changed, 1169 insertions(+), 15 deletions(-) create mode 100644 cases/base-share/a-share-link-whose-database-is-away.case.ts create mode 100644 cases/base-share/a-share-link-whose-database-is-away.md create mode 100644 cases/lookup/two-records-with-one-name-are-two-records.case.ts create mode 100644 cases/lookup/two-records-with-one-name-are-two-records.md create mode 100644 cases/record/add-a-row-to-a-table-that-joins-people-columns.case.ts create mode 100644 cases/record/add-a-row-to-a-table-that-joins-people-columns.md create mode 100644 framework/runners/link-rollup-unique-by-identity.runner.ts create mode 100644 framework/runners/nested-user-array-join-create.runner.ts create mode 100644 framework/runners/share-view-unready-data-db.runner.ts diff --git a/cases/base-share/a-share-link-whose-database-is-away.case.ts b/cases/base-share/a-share-link-whose-database-is-away.case.ts new file mode 100644 index 0000000..9426848 --- /dev/null +++ b/cases/base-share/a-share-link-whose-database-is-away.case.ts @@ -0,0 +1,27 @@ +import { defineBugCase } from "../../framework/types"; + +// T6926: a space can be bound to a customer's own database, and that binding can +// be switched off - revoked credentials, a retired connection, a migration part +// way. The share link, the view and the permission are all still correct; there +// is simply nowhere to read from. What came back was an unhandled 500. To +// whoever holds the link - usually somebody outside the company, with no account +// and nobody to ask - a 500 says the product is broken and there is nothing to +// do; a 503 naming an unavailable database says the same page will work later. +export default defineBugCase({ + id: "base-share/a-share-link-whose-database-is-away", + title: "A share link whose database is away says so", + runner: "share-view-unready-data-db", + timeoutMs: 180_000, + skipV1: + "the case makes its own space and base, and only the case base is unstamped - a base created inside a runner is born on v2, so the v1 column answers 'requested of v1 but v2 answered (reason=new_base)' rather than answering the question", + bug: { + issue: "T6926", + status: "fixed", + sourceCommits: ["bdcca3f24"], + }, + config: { + namePrefix: "e2e-lab-share-unready-db", + rowTitle: "a-row-behind-the-link", + encryptedUrlPlaceholder: "not-a-real-connection-string", + }, +}); diff --git a/cases/base-share/a-share-link-whose-database-is-away.md b/cases/base-share/a-share-link-whose-database-is-away.md new file mode 100644 index 0000000..c86115b --- /dev/null +++ b/cases/base-share/a-share-link-whose-database-is-away.md @@ -0,0 +1,62 @@ +# base-share/a-share-link-whose-database-is-away + +**T6926** — fixed. On the `share-view-unready-data-db` runner. + +## What the user sees + +Someone opens a share link. The space it belongs to is bound to a database whose +connection has been switched off — revoked credentials, a retired connection, a +migration part way through. The page fails with a 500. + +Everything about the share is still correct: the link, the view, the permission. +There is simply nowhere to read the rows from. + +The person holding the link is usually outside the company. They have no +account, no way to see anything else, and nobody to ask. A 500 tells them the +product is broken and there is nothing to do about it. A 503 naming an +unavailable database tells them, and anything watching the endpoint, that the +same page will work later. + +## Why + +Resolving which database a space reads from threw a plain error when the binding +was not usable. Nothing above it recognised that error, so it surfaced as an +unhandled 500 rather than as the outage it describes. + +## What the checkpoint asserts + +The status **and** the code. 503 alone would be indistinguishable from any other +outage, and being distinguishable is the whole of the fix — so the response must +also call itself `database_connection_unavailable`. + +A 200 is called out separately, because a share link that answered normally +while its database was away would be a different and worse problem than the one +this case is about. + +## Why the fixture is written with SQL + +Binding a space to another database is not part of this observation, and a +connection in the switched-off state is not something a request can ask for. +`fixture-db` writes the two rows; the observation stays on the public share +endpoint. + +Before the binding is written, the fixture opens the share link and requires a 200. Without that, a 503 afterwards could just as well mean the share was never +set up — and the case would pass while proving nothing. + +The space is created for this case alone. The binding under test is a property +of a space, and this must not touch the one every other case reads from. + +## The v1 column + +Skipped, for a reason about this harness rather than about the product. The case +makes its own space and base — the binding under test is a property of a space — +and `framework/case-base.ts` unstamps only the base it manages. A base created +inside a runner is born on v2, so a v1 run answers + +``` +POST /table/{tableId}/view/{viewId}/enable-share was requested of v1 +but v2 answered (reason=new_base) +``` + +which is the harness refusing to fabricate a reference column, not an answer +about v1. Any future runner that creates its own base inherits this. diff --git a/cases/lookup/distinct-choices-in-the-order-they-appear.case.ts b/cases/lookup/distinct-choices-in-the-order-they-appear.case.ts index 35b4754..9dc78fd 100644 --- a/cases/lookup/distinct-choices-in-the-order-they-appear.case.ts +++ b/cases/lookup/distinct-choices-in-the-order-they-appear.case.ts @@ -24,6 +24,8 @@ export default defineBugCase({ { name: "child-first", status: "Todo" }, { name: "child-second", status: "Done" }, ], + whenTheRowIsWritten: "beforeTheSummaries", + alsoCheckAfterAnEdit: true, retarget: { childName: "child-second", status: "Todo" }, settleTimeoutMs: 60_000, pollIntervalMs: 500, diff --git a/cases/lookup/two-records-with-one-name-are-two-records.case.ts b/cases/lookup/two-records-with-one-name-are-two-records.case.ts new file mode 100644 index 0000000..71fd2f8 --- /dev/null +++ b/cases/lookup/two-records-with-one-name-are-two-records.case.ts @@ -0,0 +1,29 @@ +import { defineBugCase } from "../../framework/types"; + +// T7082: two records are the same record when they have the same id, not when +// they happen to be called the same thing - and nothing stops two rows sharing a +// name. A summary of the distinct linked records compared what it displayed +// rather than what it had, so two different records both called "Same" +// collapsed into one and a real linked record left the answer. Nothing marks the +// column; the result is a plausible list of names; anything reading it +// afterwards is short by one. +export default defineBugCase({ + id: "lookup/two-records-with-one-name-are-two-records", + title: "Two linked records sharing a name are still two records", + runner: "link-rollup-unique-by-identity", + timeoutMs: 300_000, + bug: { + issue: "T7082", + status: "fixed", + sourceCommits: ["9e77be25f", "5820e4fa3"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-link-unique-identity", + parentRowName: "the-parent", + childNamePrefix: "child", + targetTitles: ["Same", "Same", "Other"], + settleTimeoutMs: 60_000, + pollIntervalMs: 500, + }, +}); diff --git a/cases/lookup/two-records-with-one-name-are-two-records.md b/cases/lookup/two-records-with-one-name-are-two-records.md new file mode 100644 index 0000000..bcd72ae --- /dev/null +++ b/cases/lookup/two-records-with-one-name-are-two-records.md @@ -0,0 +1,85 @@ +# lookup/two-records-with-one-name-are-two-records + +**T7082** — fixed. On the `link-rollup-unique-by-identity` runner. + +## What the user sees + +A row summarises the distinct linked records reached through its children. Two of +those records are different records that happen to be called the same thing — a +name is a value someone typed, and nothing stops two rows sharing one. + +The summary lists them once. A real linked record has left the answer. + +Nothing indicates it. The column is not marked, and what comes back is a +plausible list of names. The only way to notice is to count it against the +summary beside it that keeps everything. Whatever reads the column next — a +formula, a count, a filter, a report, an automation — is short by one and cannot +tell. + +## Why + +Uniqueness was decided by comparing what the summary displayed rather than what +it held. Two records with one name look identical that way. They are not +identical: they have different ids, which is what makes them two records. + +## What the checkpoint asserts + +Not a list of names. It asserts that the **distinct** summary equals the +**keep-everything** summary — because when every linked record is a different +record, those two are the same answer. + +That invariant is the point of the shape. It holds whatever these cells contain, +and what they contain has changed more than once: the same issue was fixed in two +commits, the second of which changed how link values are rendered. A case that +pinned an expected list of names would have been rewritten by that second commit +without the behaviour it guards having changed at all. + +The keep-everything summary is checked first, as the control: it must hold one +entry per linked record. If it does not, the chain never computed and the +comparison would be between two wrong answers. The settle loop waits on that +same column, which is correct on both sides of the fix — waiting on the column +under test would be waiting for the bug to go away. + +When the distinct summary is short, the failure says how many records left the +answer, because that number is the whole report. + +## The two commits, and what each stage looks like + +This issue was fixed twice, and the case tells all three states apart. Measured: + +| commit | the keep-everything summary | the distinct summary | +| ----------------------------- | --------------------------- | ---------------------------- | +| `d09c75728` (before both) | three `{id, title}` records | `["Same", "Other"]` | +| `692c2b4b5` (after the first) | three `{id, title}` records | `["Same", "Same", "Other"]` | +| `develop` | three title strings | the same three title strings | + +The first fix restored the record that had gone missing. The second changed what +these cells hold, so the two summaries stopped disagreeing about shape as well. +Both are named in `bug.sourceCommits`, and the middle row is why: after the first +commit the identity is right and the two answers still do not match. + +An expected list of names, written into the case, would have been "correct" on +the middle row and rewritten by the second commit — a case edited to follow the +product rather than to hold it in place. + +## Why the fixture is shaped this way + +Three tables. The summary's source has to be a **link** column — that is the +column type whose values carry an identity separate from what is displayed — so +there is a table of target records, a table of children each pointing at one +target, and a parent summarising across the children. + +At least two targets must share a name, and the runner refuses a fixture where +they do not: with every name different, merging by name and keeping by identity +give the same answer and the case would be green on both sides of the fix. + +## The v1 column + +v1 reproduces this on **every** column of the acceptance matrix, `develop` +included: both fixes are v2-only, so a summary on the older engine still merges +two records that share a name and still loses one of them. The v1 column is a +reference and never gates a run, so this is reported rather than enforced. + +That is the second case in this repository to say the same thing about v1 — see +`lookup/distinct-choices-in-the-order-they-appear`. Both are summaries over +linked rows. diff --git a/cases/record/add-a-row-to-a-table-that-joins-people-columns.case.ts b/cases/record/add-a-row-to-a-table-that-joins-people-columns.case.ts new file mode 100644 index 0000000..47e3e1e --- /dev/null +++ b/cases/record/add-a-row-to-a-table-that-joins-people-columns.case.ts @@ -0,0 +1,34 @@ +import { defineBugCase } from "../../framework/types"; + +// T7024: "everyone involved, listed once, separated by commas" over seven people +// columns, written four functions deep. Each layer re-stated the whole of the +// layer inside it, so the statement the database was asked to plan grew with +// every one, reaching megabytes. The row is recomputed inside the write, so +// nothing came back at all: the page spun and the gateway gave up. The table +// could not accept a row - not slowly, at all - and all a person could see was a +// timeout. +export default defineBugCase({ + id: "record/add-a-row-to-a-table-that-joins-people-columns", + title: "A row can be added to a table whose formula joins people columns", + runner: "nested-user-array-join-create", + timeoutMs: 300_000, + bug: { + issue: "T7024", + status: "fixed", + sourceCommits: ["2c57b7bd8"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-user-array-join", + peopleColumns: 7, + peopleColumnPrefix: "Trainer", + sessionRowName: "the-session", + campusValue: "the-campus", + noteRowName: "the-note-being-added", + // A plain separator. The customer's was an ideographic comma; what grows + // the statement is the nesting, not the character, and this repository is + // English-only. + separator: ", ", + writeBudgetMs: 60_000, + }, +}); diff --git a/cases/record/add-a-row-to-a-table-that-joins-people-columns.md b/cases/record/add-a-row-to-a-table-that-joins-people-columns.md new file mode 100644 index 0000000..60ffbb3 --- /dev/null +++ b/cases/record/add-a-row-to-a-table-that-joins-people-columns.md @@ -0,0 +1,69 @@ +# record/add-a-row-to-a-table-that-joins-people-columns + +**T7024** — fixed. On the `nested-user-array-join-create` runner. + +## What the user sees + +Adding a row to the table never finishes. The page spins and the gateway +eventually times out. Every attempt does the same thing. There is nothing else +to see — no error naming a column, no failed field, just a table that will not +take a row. + +## Why + +The table has several people columns and one formula meaning "everyone involved, +listed once, separated by commas": flatten the people columns into one list, drop +the empties, drop the repeats, join what is left. Four functions, each wrapping +the next. + +Each of those layers re-stated the whole of the layer inside it. The statement +the database was asked to plan therefore grew a layer at a time; at seven people +columns it reached megabytes. Because the row is recomputed inside the write, the +write never returned. + +## What the checkpoint asserts + +The formula column can be **made**, the write returns at all, and the table then +lists the row. + +Making the column is inside the checkpoint, not in setup, and that is not +tidiness. What grew a layer at a time is the statement, and planning it is what +fails — so it fails when the column is created as readily as when a row is +added. Measured: on the fix's parent the case never reaches the write, because +creating the column already answers + +``` +Unexpected unit of work error: Error: Client has encountered a connection error +and is not queryable +``` + +which is the message from the customer's own backend log. Built the other way +round, that failure lands in setup and scores as "this case could not run here" +— the one verdict that hides the bug. + +The reported symptom is the write, and the write is still asserted. It is the +second half of the same defect rather than a different one. + +The request carries its own time limit rather than being allowed to hang. A +request that never answers would run out the whole case and be scored as "this +case could not run here" — the one verdict that would hide the bug. Ending the +wait inside the checkpoint makes the silence the report. + +The limit is deliberately generous. This is not a measurement of speed and does +not belong in the performance lab: the difference being asserted is between an +answer and no answer. + +## Why the fixture is shaped this way + +Seven people columns, because that is where the report was filed and because the +statement grew with the count — fewer columns may plan a large statement that +still completes, which would make the case green on both sides. + +The borrowed column from a second table is part of the reported shape: it puts a +second computed column into the same write, which is what the customer's table +had. + +The people columns are all filled with the same person. The growth is in +planning the statement, not in the data, so what the cells contain does not have +to be elaborate — but they are filled rather than empty so the formula has +something real to work on. diff --git a/docs/triage-ledger.md b/docs/triage-ledger.md index eb3328a..b548f96 100644 --- a/docs/triage-ledger.md +++ b/docs/triage-ledger.md @@ -142,6 +142,9 @@ The shape is gone; the runner is not kept. | `8d5c0fe38` | T7067 | Selection aggregation was being answered by v1, where a date column met a cast v1 cannot do. The fix routes it to v2. That makes the pre-fix state "v1 answered", which `assertServedByV2` treats as the case being unable to run (💥) rather than as the bug - so the column that should be red is the one column the harness refuses to read. The observation is real and reachable; expressing it needs a runner allowed to assert that a request was **not** on v2, which does not exist here. | | `9f5509f48` | T7019 | An incident, not a behaviour. Concurrent replicas UPSERTing the same five-minute query-observation window took transaction locks that held connections until the pool was exhausted; the fix hardens that write. What a case would have to reproduce is contention between replicas, and this harness runs one application against one database - a single writer never conflicts with itself. Belongs in the performance lab if anywhere. | | `e3bb7671c` | T6988 | Not attempted, on the strength of the fix's own reproduction. The failure needs a client whose local `cmp_` doc was never created while the server snapshot already sits at generation 2 or higher, and the commit's e2e reaches that by stubbing the snapshot loader through a service hook **and** assigning `doc.version = 0` by hand. Neither is available here: the observation seam is a real subscription over the wire, which fetches the snapshot rather than replaying ops from zero. Reproducing it honestly means winning a race - subscribing to an empty doc and having the generation pass 1 before the create op arrives - which is the shape that produces cases green on every column. Worth revisiting if the realtime helper ever exposes the underlying doc. | +| `4b57c03da` | T7070 | Written and run. The fixture builds cleanly - a manyOne link with a column borrowed through it, then `fixture-db` drops the hidden `__fk_` column the link's own settings still name - and adding a row to the other table is refused. But the refusal is `Failed to insert record: column t.__fk_… does not exist`, raised during the insert, while the fix repairs `Failed to propagate dirty records`, raised by the deferred propagate. Two call sites. The commit's own e2e reaches the second by draining an outbox inside the v2 test container; this harness runs the Nest application, where the same small fixture computes inline and never gets there. Tried one-way and two-way links; both fail in the insert. Same trap as T6728. **The insert-path failure is still present on `develop`** - see the note below the table. | +| `893d0ce20` | T7066 | Written and run twice, green on the fix's parent both times. The report's precondition is that the summaries exist before the row does, so the answer is worked out during the write rather than filled in afterwards - built first by creating the row and its links in one call, then by creating the row and attaching the children as two writes, which is what the report's own steps describe. Neither reproduces. The sibling case `lookup/distinct-choices-in-the-order-they-appear` (T7044) is also **green** on this parent, measured, so it does not cover this either. The shape stays behind the `select-rollup-unique-and-count` runner's `whenTheRowIsWritten` and `alsoCheckAfterAnEdit` config values; a third attempt should start by finding what else the CLI path does that these two do not. | +| `4f35a4a64` | T7047 | The observation lives on the v2 contract's own list endpoint - `limit`/`cursor`/`includeTotal` - not on the public record API this lab reads through, and the lab's client does not speak it. What changed behind that endpoint is also performance-shaped: skip `count(*)` unless asked, page by cursor instead of OFFSET. Same reason as the T5268 row: much of the fix introduces the path it repairs, so there is no before to compare against. | ### The date comparison inside AND or OR @@ -517,3 +520,16 @@ it in prose is how the two drift apart. To see it: ```bash pnpm triage:covered ``` + +### T7070's neighbour, still open + +Rejecting the T7070 case turned up something that is not T7070. On `develop`, +a base holding a link whose hidden `__fk_` column is missing **cannot accept +rows into the table on the other side at all**: the insert itself is refused +with `column t.__fk_… does not exist`, before any propagation runs. Measured on +`8f3f6df16` and on `692c2b4b5`, with both one-way and two-way links. + +T7070 repaired the propagate path for exactly this state. The insert path was +not part of it and answers the same way it did before. Whether that is worth +its own report is a judgment for a person; it is recorded here so the next pass +does not spend the same afternoon rediscovering it. diff --git a/framework/runner-registry.ts b/framework/runner-registry.ts index dd2a273..99237d3 100644 --- a/framework/runner-registry.ts +++ b/framework/runner-registry.ts @@ -117,6 +117,9 @@ import { runSameNamedFkBaseDuplicateCase } from "./runners/same-named-fk-base-du import { runJsonbLookupAggregateCase } from "./runners/jsonb-lookup-aggregate.runner"; import { runNestedGroupConditionalRollupCase } from "./runners/nested-group-conditional-rollup.runner"; import { runSelectRollupUniqueAndCountCase } from "./runners/select-rollup-unique-and-count.runner"; +import { runLinkRollupUniqueByIdentityCase } from "./runners/link-rollup-unique-by-identity.runner"; +import { runNestedUserArrayJoinCreateCase } from "./runners/nested-user-array-join-create.runner"; +import { runShareViewUnreadyDataDbCase } from "./runners/share-view-unready-data-db.runner"; import { runLookupOfRollupCreateCase } from "./runners/lookup-of-rollup-create.runner"; import type { BugCase, @@ -255,6 +258,9 @@ const runners: { [K in BugRunnerKind]: RunnerFn } = { "jsonb-lookup-aggregate": runJsonbLookupAggregateCase, "nested-group-conditional-rollup": runNestedGroupConditionalRollupCase, "select-rollup-unique-and-count": runSelectRollupUniqueAndCountCase, + "link-rollup-unique-by-identity": runLinkRollupUniqueByIdentityCase, + "nested-user-array-join-create": runNestedUserArrayJoinCreateCase, + "share-view-unready-data-db": runShareViewUnreadyDataDbCase, }; export const executeRegisteredRunner = ( diff --git a/framework/runners/link-rollup-unique-by-identity.runner.ts b/framework/runners/link-rollup-unique-by-identity.runner.ts new file mode 100644 index 0000000..723144e --- /dev/null +++ b/framework/runners/link-rollup-unique-by-identity.runner.ts @@ -0,0 +1,259 @@ +import { FieldKeyType, FieldType, Relationship } from "@teable/core"; +import { + createRecords as apiCreateRecords, + getRecords as apiGetRecords, +} from "@teable/openapi"; +import { + createField, + createTable, + permanentDeleteTable, +} from "../../../utils/init-app"; +import { bugCheckpoint } from "../checkpoint"; +import { assertServedByV2 } from "../engine"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { LinkRollupUniqueByIdentityCaseConfig } from "../types"; + +// A summary listing the distinct linked records across a row's children -> +// checkpoint: it lists as many as there are, when every one of them is a +// different record. +// +// Two records are the same record when they have the same id. They are not the +// same record when they happen to be called the same thing - and nothing stops +// two rows sharing a name, because a name is a value someone typed. The summary +// compared what it displayed rather than what it had, so two different records +// both called "Same" collapsed into one and a real linked record left the +// answer. +// +// It leaves quietly. The column is not marked, the answer is a plausible list +// of names, and the only way to notice is to count against the summary beside +// it that keeps everything. Anything reading the column afterwards - a formula, +// a count, a filter, a report - is short by one and has no way to know. +// +// The case does not assert a list of names. It asserts that the distinct +// summary equals the keep-everything summary, because when every linked record +// is a different record those two ARE the same answer. That invariant holds +// whatever shape the values come back in, which matters here: what these cells +// contain has changed more than once. + +const NAME_FIELD = "Name"; +const TARGET_LINK_FIELD = "Target"; +const CHILD_LINK_FIELD = "Children"; +const COMPACT_FIELD = "Every linked record"; +const UNIQUE_FIELD = "The distinct ones"; + +const sleep = (ms: number) => + new Promise((resolveSleep) => { + setTimeout(resolveSleep, ms); + }); + +export const runLinkRollupUniqueByIdentityCase = async ( + bugCase: BugCaseFor<"link-rollup-unique-by-identity">, + context: BugRunContext, +): Promise => { + const config: LinkRollupUniqueByIdentityCaseConfig = bugCase.config; + const baseId = globalThis.testConfig.baseId; + const suffix = `${config.tableNamePrefix}-${context.runId}`; + const createdTableIds: string[] = []; + + const titles = config.targetTitles; + if (titles.length < 2) { + throw new Error( + "at least two linked records, or there is nothing to merge", + ); + } + if (new Set(titles).size === titles.length) { + throw new Error( + `the linked records are all called something different (${JSON.stringify(titles)}) - ` + + "with no two sharing a name, merging by name and keeping by identity give the same answer", + ); + } + + try { + // The records the summary is about. Two of them share a name and are + // nonetheless two records. + const targets = await createTable(baseId, { + name: `${suffix}-targets`, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + ], + records: titles.map((title) => ({ fields: { [NAME_FIELD]: title } })), + }); + createdTableIds.unshift(targets.id); + const targetIds = targets.records.map( + (record: { id: string }) => record.id, + ); + if (new Set(targetIds).size !== targetIds.length) { + throw new Error("the seeded records are not distinct records"); + } + + // One child per target, each pointing at its own. + const children = await createTable(baseId, { + name: `${suffix}-children`, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + ], + records: [], + }); + createdTableIds.unshift(children.id); + const targetLink = await createField(children.id, { + name: TARGET_LINK_FIELD, + type: FieldType.Link, + options: { + relationship: Relationship.ManyOne, + foreignTableId: targets.id, + }, + }); + const childRows = await apiCreateRecords(children.id, { + fieldKeyType: FieldKeyType.Id, + typecast: false, + records: targetIds.map((targetId: string, index: number) => ({ + fields: { + [children.fields[0].id]: `${config.childNamePrefix}-${index + 1}`, + [targetLink.id]: { id: targetId }, + }, + })), + }); + + // The row doing the summarising. + const parent = await createTable(baseId, { + name: `${suffix}-parent`, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + ], + records: [], + }); + createdTableIds.unshift(parent.id); + const childLink = await createField(parent.id, { + name: CHILD_LINK_FIELD, + type: FieldType.Link, + options: { + relationship: Relationship.OneMany, + foreignTableId: children.id, + }, + }); + await apiCreateRecords(parent.id, { + fieldKeyType: FieldKeyType.Id, + typecast: false, + records: [ + { + fields: { + [parent.fields[0].id]: config.parentRowName, + [childLink.id]: childRows.data.records.map( + (record: { id: string }) => ({ id: record.id }), + ), + }, + }, + ], + }); + + const summary = async (name: string, expression: string) => + createField(parent.id, { + name, + type: FieldType.Rollup, + options: { expression }, + lookupOptions: { + foreignTableId: children.id, + linkFieldId: childLink.id, + lookupFieldId: targetLink.id, + }, + }); + await summary(COMPACT_FIELD, "array_compact({values})"); + await summary(UNIQUE_FIELD, "array_unique({values})"); + + const readParent = async () => { + const response = await apiGetRecords(parent.id, { + fieldKeyType: FieldKeyType.Name, + take: 1, + }); + return { + headers: response.headers, + fields: response.data.records[0]?.fields ?? {}, + }; + }; + + const sizeOf = (value: unknown) => + Array.isArray(value) ? value.length : value == null ? 0 : 1; + + // Settling on the KEEP-EVERYTHING summary reaching one entry per linked + // record. That column is right on both sides of the fix, so waiting for it + // is waiting for the computation to finish rather than for the bug. + const deadline = Date.now() + config.settleTimeoutMs; + let settled = await readParent(); + for (;;) { + if ( + sizeOf(settled.fields[COMPACT_FIELD]) === titles.length || + Date.now() >= deadline + ) { + break; + } + await sleep(config.pollIntervalMs); + settled = await readParent(); + } + + const routing = assertServedByV2(settled.headers, { + operation: "GET /table/{tableId}/record", + feature: "getRecords", + }); + + const probe = await bugCheckpoint( + "a-summary-of-distinct-linked-records-keeps-all-of-them", + async () => { + const everything = settled.fields[COMPACT_FIELD]; + const distinct = settled.fields[UNIQUE_FIELD]; + const scene = { + everyLinkedRecord: everything ?? null, + theDistinctOnes: distinct ?? null, + linkedRecordsSeeded: titles.length, + namesSeeded: titles, + }; + + // The control: the summary that keeps everything has to hold one entry + // per linked record. If it does not, the chain never computed and the + // comparison below would be between two wrong answers. + if (sizeOf(everything) !== titles.length) { + throw new Error( + `the summary that keeps everything holds ${sizeOf(everything)} of ${titles.length} linked records: ` + + JSON.stringify(scene), + ); + } + + // The claim. Every linked record here IS a different record, so the + // distinct summary and the keep-everything summary are the same answer + // - whatever these cells happen to contain. + if (JSON.stringify(distinct) !== JSON.stringify(everything)) { + throw new Error( + `every linked record is a different record, so the distinct summary should match the one that ` + + `keeps everything, and it does not: ${JSON.stringify(scene)}` + + (sizeOf(distinct) < sizeOf(everything) + ? `. ${sizeOf(everything) - sizeOf(distinct)} record(s) left the answer - records sharing a name were treated as one record` + : ""), + ); + } + return { scene }; + }, + ); + + return { + details: { + targetsTableId: targets.id, + childrenTableId: children.id, + parentTableId: parent.id, + routing, + ...probe, + }, + }; + } finally { + for (const tableId of createdTableIds) { + try { + await permanentDeleteTable(baseId, tableId); + } catch (error) { + // Cleanup is the case's own housekeeping - the product did not fail. + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/runners/nested-user-array-join-create.runner.ts b/framework/runners/nested-user-array-join-create.runner.ts new file mode 100644 index 0000000..c939ed7 --- /dev/null +++ b/framework/runners/nested-user-array-join-create.runner.ts @@ -0,0 +1,248 @@ +import { FieldKeyType, FieldType, Relationship } from "@teable/core"; +import { + axios, + getRecords as apiGetRecords, + CREATE_RECORD, + urlBuilder, +} from "@teable/openapi"; +import { + createField, + createTable, + permanentDeleteTable, +} from "../../../utils/init-app"; +import { bugCheckpoint } from "../checkpoint"; +import { assertServedByV2 } from "../engine"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { NestedUserArrayJoinCreateCaseConfig } from "../types"; + +// A table with several people columns and one formula joining them together, +// wrapped four functions deep -> add a row -> checkpoint: the row is added. +// +// "Everyone involved, listed once, separated by commas" is what that formula +// says: flatten the people columns into one list, drop the empties, drop the +// repeats, join what is left. Each of those four steps re-stated the whole of +// the step inside it, so the statement the database was asked to plan grew with +// every layer. At seven people columns it reached megabytes. +// +// The row is recomputed inside the write, so nothing came back at all: the page +// spun and the gateway eventually gave up. The table could not accept a row - +// not slowly, at all - and the only thing a person could see was a timeout. +// +// So the checkpoint's question is simply whether the write returns. It carries +// its own time limit rather than letting the request hang, because a request +// that never answers would end the case as "could not run" instead of as the +// bug it is. + +const NAME_FIELD = "Name"; +const CAMPUS_FIELD = "Campus"; +const LINK_FIELD = "Session"; +const CAMPUS_LOOKUP_FIELD = "Campus, borrowed"; +const JOINED_FIELD = "Everyone involved"; + +export const runNestedUserArrayJoinCreateCase = async ( + bugCase: BugCaseFor<"nested-user-array-join-create">, + context: BugRunContext, +): Promise => { + const config: NestedUserArrayJoinCreateCaseConfig = bugCase.config; + const baseId = globalThis.testConfig.baseId; + const suffix = `${config.tableNamePrefix}-${context.runId}`; + const createdTableIds: string[] = []; + const person = { + id: globalThis.testConfig.userId, + title: globalThis.testConfig.userName, + }; + + if (config.peopleColumns < 2) { + throw new Error( + "at least two people columns, or there is nothing to flatten together", + ); + } + + try { + // The other table, and the column borrowed from it. The borrowed column is + // part of the reported shape: it is what puts a second computed column in + // the same write. + const sessions = await createTable(baseId, { + name: `${suffix}-sessions`, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + { name: CAMPUS_FIELD, type: FieldType.LongText }, + ], + records: [ + { + fields: { + [NAME_FIELD]: config.sessionRowName, + [CAMPUS_FIELD]: config.campusValue, + }, + }, + ], + }); + createdTableIds.unshift(sessions.id); + const campusFieldId = sessions.fields.find( + (field: { name: string }) => field.name === CAMPUS_FIELD, + )?.id as string; + + const notes = await createTable(baseId, { + name: `${suffix}-notes`, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + ], + records: [], + }); + createdTableIds.unshift(notes.id); + const notesNameId = notes.fields[0].id; + + const peopleFieldIds: string[] = []; + for (let index = 0; index < config.peopleColumns; index += 1) { + const field = await createField(notes.id, { + name: `${config.peopleColumnPrefix} ${index + 1}`, + type: FieldType.User, + options: { isMultiple: false, shouldNotify: false }, + }); + peopleFieldIds.push(field.id); + } + + const link = await createField(notes.id, { + name: LINK_FIELD, + type: FieldType.Link, + options: { + relationship: Relationship.OneOne, + foreignTableId: sessions.id, + }, + }); + await createField(notes.id, { + name: CAMPUS_LOOKUP_FIELD, + type: FieldType.LongText, + isLookup: true, + lookupOptions: { + foreignTableId: sessions.id, + linkFieldId: link.id, + lookupFieldId: campusFieldId, + }, + }); + + const flattenArgs = peopleFieldIds + .map((fieldId) => `{${fieldId}}`) + .join(", "); + const expression = `ARRAY_JOIN(ARRAY_UNIQUE(ARRAY_COMPACT(ARRAY_FLATTEN(${flattenArgs}))), "${config.separator}")`; + + // Fixture verification, outside the checkpoint: the table reads before + // anything is written to it, and the engine assertion rides on that read. + const before = await apiGetRecords(notes.id, { + fieldKeyType: FieldKeyType.Id, + take: 1, + }); + if (before.data.records.length !== 0) { + throw new Error("the table was expected to be empty before the write"); + } + const routing = assertServedByV2(before.headers, { + operation: "GET /table/{tableId}/record", + feature: "getRecords", + }); + + const probe = await bugCheckpoint( + "a-row-can-be-added-to-a-table-whose-formula-joins-people-columns", + async () => { + // The formula is made HERE, not in setup. What grew a layer at a time + // is the statement, and planning it is what fails - so it fails when the + // column is made as readily as when a row is added. Building it outside + // would score that first failure as "this case could not run here", + // which is the one verdict that hides the bug. + await createField(notes.id, { + name: JOINED_FIELD, + type: FieldType.Formula, + options: { expression }, + }); + + const startedAt = Date.now(); + // Raw axios with its own time limit. A request that never answers would + // run out the whole case and be reported as "could not run"; this way + // the wait ends here, inside the checkpoint, which is the report. + const response = await axios + .post( + urlBuilder(CREATE_RECORD, { tableId: notes.id }), + { + fieldKeyType: FieldKeyType.Id, + typecast: false, + records: [ + { + fields: Object.fromEntries([ + [notesNameId, config.noteRowName], + ...peopleFieldIds.map((fieldId) => [fieldId, person]), + ]), + }, + ], + }, + { + validateStatus: () => true, + timeout: config.writeBudgetMs, + }, + ) + .catch((error: { code?: string; message?: string }) => { + throw new Error( + `adding a row did not answer within ${config.writeBudgetMs}ms ` + + `(${error.code ?? "no code"}: ${error.message ?? "no message"}) - ` + + `the table cannot accept a row at all`, + ); + }); + const elapsedMs = Date.now() - startedAt; + + if (response.status < 200 || response.status >= 300) { + throw new Error( + `adding a row answered ${response.status} after ${elapsedMs}ms: ` + + (typeof response.data === "string" + ? response.data + : JSON.stringify(response.data)), + ); + } + const recordId = (response.data as { records?: { id?: string }[] }) + ?.records?.[0]?.id; + if (!recordId) { + throw new Error( + `adding a row returned no row after ${elapsedMs}ms: ${JSON.stringify(response.data)}`, + ); + } + + // And the table reads afterwards, with the row in it. + const after = await apiGetRecords(notes.id, { + fieldKeyType: FieldKeyType.Id, + take: 5, + }); + if (after.data.records.length !== 1) { + throw new Error( + `the write answered but the table lists ${after.data.records.length} rows`, + ); + } + return { + recordId, + elapsedMs, + joined: after.data.records[0]?.fields ?? {}, + }; + }, + ); + + return { + details: { + sessionsTableId: sessions.id, + notesTableId: notes.id, + peopleColumns: config.peopleColumns, + routing, + recordId: probe.recordId, + writeMs: probe.elapsedMs, + }, + }; + } finally { + for (const tableId of createdTableIds) { + try { + await permanentDeleteTable(baseId, tableId); + } catch (error) { + // Cleanup is the case's own housekeeping - the product did not fail. + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/runners/select-rollup-unique-and-count.runner.ts b/framework/runners/select-rollup-unique-and-count.runner.ts index 306b7bf..1b85a00 100644 --- a/framework/runners/select-rollup-unique-and-count.runner.ts +++ b/framework/runners/select-rollup-unique-and-count.runner.ts @@ -143,20 +143,29 @@ export const runSelectRollupUniqueAndCountCase = async ( // The link is written in the children's declared order, which is the order // the summary is supposed to keep. - await apiCreateRecords(parent.id, { - fieldKeyType: FieldKeyType.Name, - typecast: false, - records: [ - { - fields: { - [NAME_FIELD]: config.parentRowName, - [LINK_FIELD]: config.children.map((child) => ({ - id: childIdByName.get(child.name) as string, - })), + const writeTheRow = async () => + apiCreateRecords(parent.id, { + fieldKeyType: FieldKeyType.Name, + typecast: false, + records: [ + { + fields: { + [NAME_FIELD]: config.parentRowName, + [LINK_FIELD]: config.children.map((child) => ({ + id: childIdByName.get(child.name) as string, + })), + }, }, - }, - ], - }); + ], + }); + + // Which comes first, the row or the summaries. Adding a summary to a table + // that already holds rows fills it in as one job; writing a row into a table + // whose summaries already exist works them out as part of the write. Those + // are different paths and they have been wrong separately. + if (config.whenTheRowIsWritten === "beforeTheSummaries") { + await writeTheRow(); + } const summary = async (name: string, expression: string) => createField(parent.id, { @@ -174,6 +183,32 @@ export const runSelectRollupUniqueAndCountCase = async ( await summary(UNIQUE_FIELD, "array_unique({values})"); await summary(COUNT_FIELD, "count({values})"); + if (config.whenTheRowIsWritten === "afterTheSummaries") { + // The row first, then the links, as two writes. That is what a script + // does - create the parent, then attach the children - and it is the + // sequence the report follows. Writing both at once is a different path + // and is answered correctly on both sides of this fix. + const created = await apiCreateRecords(parent.id, { + fieldKeyType: FieldKeyType.Name, + typecast: false, + records: [{ fields: { [NAME_FIELD]: config.parentRowName } }], + }); + const parentRowId = created.data.records[0]?.id; + if (!parentRowId) { + throw new Error("the parent row was not created"); + } + await apiUpdateRecord(parent.id, parentRowId, { + fieldKeyType: FieldKeyType.Name, + record: { + fields: { + [LINK_FIELD]: config.children.map((child) => ({ + id: childIdByName.get(child.name) as string, + })), + }, + }, + }); + } + const readParent = async () => { const response = await apiGetRecords(parent.id, { fieldKeyType: FieldKeyType.Name, @@ -288,6 +323,15 @@ export const runSelectRollupUniqueAndCountCase = async ( // only becomes wrong once two children agree. const first = check(settled.fields, initialUnique, "at first"); + if (!config.alsoCheckAfterAnEdit) { + if (first.problems.length > 0) { + throw new Error( + `${first.problems.join("; ")}. The row read ${JSON.stringify(first.scene)}`, + ); + } + return { first: first.scene, second: null }; + } + // The second half of the report: make two children agree, so the count // of distinct values and the count of rows stop being the same number. await apiUpdateRecord( diff --git a/framework/runners/share-view-unready-data-db.runner.ts b/framework/runners/share-view-unready-data-db.runner.ts new file mode 100644 index 0000000..f099c80 --- /dev/null +++ b/framework/runners/share-view-unready-data-db.runner.ts @@ -0,0 +1,210 @@ +import { FieldType } from "@teable/core"; +import { + axios, + enableShareView as apiEnableShareView, + createBase as apiCreateBase, + createSpace as apiCreateSpace, + deleteSpace, + permanentDeleteSpace, + SHARE_VIEW_GET, + urlBuilder, +} from "@teable/openapi"; +import { createTable } from "../../../utils/init-app"; +import { bugCheckpoint } from "../checkpoint"; +import { assertServedByV2 } from "../engine"; +import { fixtureDb } from "../fixture-db"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { ShareViewUnreadyDataDbCaseConfig } from "../types"; + +// A shared view in a space whose own database is not available -> open the +// share link -> checkpoint: the page is told the database is unavailable, not +// that something went wrong. +// +// Spaces can be bound to a customer's own database. That binding can be turned +// off - revoked credentials, a connection retired, a migration part way - and +// the space then has nowhere to read from. Everything about the share is still +// correct: the link, the view, the permission. +// +// What came back was an unhandled 500. To whoever holds the link - typically +// somebody outside the company, with no account and no way to ask anyone - a +// 500 says the product is broken and there is nothing to do but try again. A +// 503 naming an unavailable database says the same page will work later, and it +// says the same thing to whatever is watching the endpoint. +// +// So the assertion is the status AND the code. A 503 that arrived without +// saying why would be indistinguishable from any other outage, and the point of +// the fix is that this one is distinguishable. +// +// The binding is written with SQL because the API to bind a space to another +// database is not part of this observation, and a disabled connection is not +// something a request can ask for. + +const NAME_FIELD = "Name"; +const UNAVAILABLE_CODE = "database_connection_unavailable"; + +export const runShareViewUnreadyDataDbCase = async ( + bugCase: BugCaseFor<"share-view-unready-data-db">, + context: BugRunContext, +): Promise => { + const config: ShareViewUnreadyDataDbCaseConfig = bugCase.config; + const suffix = `${config.namePrefix}-${context.runId}`; + let spaceId = ""; + let connectionId = ""; + const db = fixtureDb(context.app); + + try { + // Its own space: the binding under test is a property of a space, and this + // must not touch the one every other case reads from. + const space = await apiCreateSpace({ name: suffix }); + spaceId = space.data.id; + const base = await apiCreateBase({ spaceId, name: `${suffix}-base` }); + const table = await createTable(base.data.id, { + name: `${suffix}-table`, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + ], + records: [{ fields: { [NAME_FIELD]: config.rowTitle } }], + }); + const viewId = table.views?.[0]?.id; + if (!viewId) { + throw new Error(`the table ${table.id} has no view to share`); + } + + const shared = await apiEnableShareView({ tableId: table.id, viewId }); + const shareId = shared.data?.shareId; + if (!shareId) { + throw new Error( + `sharing the view returned no link: ${JSON.stringify(shared.data)}`, + ); + } + const routing = assertServedByV2(shared.headers, { + operation: "POST /table/{tableId}/view/{viewId}/enable-share", + feature: "enableViewShare", + }); + + // Fixture verification, outside the checkpoint: the link works while the + // space still reads from the ordinary place. Without this, a 503 later + // could just as well mean the share was never set up. + const beforeBinding = await axios.get( + urlBuilder(SHARE_VIEW_GET, { shareId }), + { validateStatus: () => true }, + ); + if (beforeBinding.status !== 200) { + throw new Error( + `the share link answers ${beforeBinding.status} before the space is bound anywhere: ` + + JSON.stringify(beforeBinding.data), + ); + } + + // The state: the space is bound to a database whose connection is switched + // off. Nothing about the share changes. + connectionId = `e2elab${context.runId}` + .replace(/[^a-zA-Z0-9]/g, "") + .slice(0, 24); + await db.execute( + `INSERT INTO "data_db_connection" + ("id", "encrypted_url", "url_fingerprint", "internal_schema", "status", "created_by", "created_time") + VALUES ($1, $2, $3, $4, 'disabled', 'e2e-lab', NOW())`, + connectionId, + config.encryptedUrlPlaceholder, + `e2e-lab-${context.runId}`, + "__teable_internal", + ); + await db.execute( + `INSERT INTO "space_data_db_binding" + ("id", "space_id", "data_db_connection_id", "mode", "state", "created_by", "created_time") + VALUES ($1, $2, $3, 'byodb', 'ready', 'e2e-lab', NOW())`, + `${connectionId}b`, + spaceId, + connectionId, + ); + + const bound = await db.query<{ count: number }[]>( + `SELECT COUNT(*)::int AS count FROM "space_data_db_binding" WHERE "space_id" = $1`, + spaceId, + ); + if ((bound[0]?.count ?? 0) !== 1) { + throw new Error( + `the space is bound to ${bound[0]?.count ?? 0} databases - the fixture is not in place`, + ); + } + + const probe = await bugCheckpoint( + "a-share-link-whose-database-is-away-says-so", + async () => { + const response = await axios.get( + urlBuilder(SHARE_VIEW_GET, { shareId }), + { validateStatus: () => true }, + ); + const body = + typeof response.data === "string" + ? response.data + : JSON.stringify(response.data ?? ""); + const code = (response.data as { code?: string })?.code; + + if (response.status === 200) { + throw new Error( + `the share link answered 200 while the space's database is switched off: ${body}`, + ); + } + if (response.status !== 503) { + throw new Error( + `the share link answered ${response.status}, expected 503 - to whoever holds this link, ` + + `anything else says the product is broken rather than that the page will work later. ` + + `The response was ${body}`, + ); + } + if (code !== UNAVAILABLE_CODE) { + throw new Error( + `the share link answered 503 but called it ${JSON.stringify(code)}, expected ` + + `${JSON.stringify(UNAVAILABLE_CODE)} - a 503 that does not say why is any other outage. ` + + `The response was ${body}`, + ); + } + return { status: response.status, code }; + }, + ); + + return { + details: { + spaceId, + tableId: table.id, + shareId, + routing, + ...probe, + }, + }; + } finally { + if (connectionId) { + try { + await db.execute( + `DELETE FROM "space_data_db_binding" WHERE "space_id" = $1`, + spaceId, + ); + await db.execute( + `DELETE FROM "data_db_connection" WHERE "id" = $1`, + connectionId, + ); + } catch (error) { + // Cleanup is the case's own housekeeping - the product did not fail. + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (binding ${connectionId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + if (spaceId) { + try { + await deleteSpace(spaceId); + await permanentDeleteSpace(spaceId); + } catch (error) { + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (space ${spaceId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/types.ts b/framework/types.ts index bf10da6..52da51c 100644 --- a/framework/types.ts +++ b/framework/types.ts @@ -127,6 +127,9 @@ export interface BugCaseConfigByRunner { "jsonb-lookup-aggregate": JsonbLookupAggregateCaseConfig; "nested-group-conditional-rollup": NestedGroupConditionalRollupCaseConfig; "select-rollup-unique-and-count": SelectRollupUniqueAndCountCaseConfig; + "link-rollup-unique-by-identity": LinkRollupUniqueByIdentityCaseConfig; + "nested-user-array-join-create": NestedUserArrayJoinCreateCaseConfig; + "share-view-unready-data-db": ShareViewUnreadyDataDbCaseConfig; } export type BugRunnerKind = keyof BugCaseConfigByRunner; @@ -2091,9 +2094,63 @@ export interface SelectRollupUniqueAndCountCaseConfig { // correct - the runner refuses that. children: { name: string; status: string }[]; parentRowName: string; - // The edit that makes two children agree, so counting rows and counting - // distinct values stop giving the same answer. + // Which comes first. "beforeTheSummaries" adds the summaries to a table that + // already holds the row; "afterTheSummaries" writes the row into a table whose + // summaries already exist. Filling a new summary in and working one out during + // a write are different paths, and they have been wrong separately. + whenTheRowIsWritten: "beforeTheSummaries" | "afterTheSummaries"; + // Whether to go on to the second half - editing a child so two agree, which is + // what tells counting rows from counting distinct values. A case about the + // first computation alone leaves it off, because the edit is a recompute and + // repairs what it is meant to observe. + alsoCheckAfterAnEdit: boolean; + // The edit that makes two children agree. Only used when the above is true. retarget: { childName: string; status: string }; settleTimeoutMs: number; pollIntervalMs: number; } + +// A summary of the distinct linked records across a row's children, where two of +// those records happen to be called the same thing. +export interface LinkRollupUniqueByIdentityCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + // The names of the linked records, one record each. At least two must repeat: + // with every name different, merging by name and keeping by identity give the + // same answer and the case proves nothing. The runner refuses that. + targetTitles: string[]; + childNamePrefix: string; + parentRowName: string; + settleTimeoutMs: number; + pollIntervalMs: number; +} + +// A table whose formula joins several people columns together, wrapped four +// functions deep - the shape whose statement grew a layer at a time. +export interface NestedUserArrayJoinCreateCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + // How many people columns the formula flattens. The statement grew with this + // number; the report was filed at seven. + peopleColumns: number; + peopleColumnPrefix: string; + sessionRowName: string; + campusValue: string; + noteRowName: string; + separator: string; + // How long the write may take before the case says the table cannot accept a + // row. Generous: this is not a measurement of speed, it is the difference + // between an answer and no answer. + writeBudgetMs: number; +} + +// A shared view in a space bound to a database whose connection is switched off. +export interface ShareViewUnreadyDataDbCaseConfig { + namePrefix: string; + rowTitle: string; + // Written into the connection row. It is never decrypted on this path - the + // connection is refused for being switched off before anything reads it - so + // this only has to be present, and saying so in the value keeps the next + // reader from looking for a real secret. + encryptedUrlPlaceholder: string; +} diff --git a/registry.ts b/registry.ts index 4c933ef..7aa516c 100644 --- a/registry.ts +++ b/registry.ts @@ -35,6 +35,9 @@ import sameNamedFkBaseDuplicateCase from "./cases/base-share/copy-a-base-whose-t import jsonbLookupAggregateCase from "./cases/lookup/the-largest-of-a-borrowed-list.case"; import nestedGroupConditionalRollupCase from "./cases/lookup/a-condition-with-a-bracket-in-it.case"; import selectRollupUniqueAndCountCase from "./cases/lookup/distinct-choices-in-the-order-they-appear.case"; +import linkRollupUniqueByIdentityCase from "./cases/lookup/two-records-with-one-name-are-two-records.case"; +import nestedUserArrayJoinCreateCase from "./cases/record/add-a-row-to-a-table-that-joins-people-columns.case"; +import shareViewUnreadyDataDbCase from "./cases/base-share/a-share-link-whose-database-is-away.case"; import sparseBatchUpdateCase from "./cases/record/a-batch-write-leaves-what-it-did-not-mention.case"; import generatedFormulaColumnCase from "./cases/record/edit-a-cell-behind-a-generated-formula.case"; import legacyGeneratedAuditColumnCase from "./cases/record/add-a-row-to-a-legacy-table.case"; @@ -180,6 +183,9 @@ const cases = [ jsonbLookupAggregateCase, nestedGroupConditionalRollupCase, selectRollupUniqueAndCountCase, + linkRollupUniqueByIdentityCase, + nestedUserArrayJoinCreateCase, + shareViewUnreadyDataDbCase, sparseBatchUpdateCase, generatedFormulaColumnCase, legacyGeneratedAuditColumnCase, From 276b6d7f9533bec80425e930dae9af872a6e38ef Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 3 Sep 2026 16:36:57 +0800 Subject: [PATCH 19/22] The authority-matrix fixture, and three cases that needed digging (#137) * Make a column that picks by case and ends in linked records T6980: "cost depends on where the cost comes from" - a manually entered figure for some rows, a different figure for others, and otherwise whatever is linked. The first two answers are numbers; the last is a list of linked records, stored as a document rather than as a number. The step merging the branches compared only the ones with a case attached. Those agreed - both numbers - so it never looked at what the otherwise branch held. The database was then asked to choose between numbers and a document in one expression and refused, killing the column and the schema change it was part of. Nothing in the interface says the last branch is a different kind of thing. Two number branches, and the runner refuses fewer: their agreeing with each other is exactly what stopped the merge looking further, and with one branch there is nothing to agree with. The linked column is checked to hold a list before the checkpoint - holding one value it would be the same kind of thing as the numbers and there would be nothing to reconcile. The link is many-to-many because every row needs the same list, which one-to-many will not allow. Making the column is inside the checkpoint, since reconciling the branches happens while it is built. Reproduced on f44a82cf8: Failed to backfill computed fields [Cost]: CASE types jsonb and double precision cannot be matched The rows falling to the otherwise branch are read but not pinned to a value: what a list of linked records renders as has changed before, and pinning it would tie this case to something it is not about. Also a ledger row for T7105, which is cost rather than behaviour - a request for 27 columns selected all 235, and answered with the same 27 either way. Co-Authored-By: Claude Opus 5 * Write down two more dead-letter fixes this harness cannot reach T6711 and T6904 both put their observation inside the background schema-operation runner: whether a leftover table.import is marked dead or repaired, and whether a computed task planned against a still-provisioning table is dead-lettered or retried. Neither difference reaches an HTTP response. The T7070 attempt already established by measurement that a small fixture here computes inline and the deferred path is not reached, so these are recorded against that finding rather than re-derived. Co-Authored-By: Claude Opus 5 * Make a second undo retry the step that failed, not the one before T7038: undo moved the place it had walked back to BEFORE carrying out the step, and never moved it back when the step failed. A failed undo therefore still counted as done, so the next press started from behind it and reversed the step before - one the person had not asked to reverse. Here that step is the row's creation, so pressing undo twice deletes a row nobody asked to delete. The failed undo is not the problem. It is honest and visible. The second press is the one that quietly takes something else away, and that is what the checkpoint reads: the row still there, and still holding the value the failed step could not put back. Measured, first press then second: f44a82cf8 failed "must have a unique value" fulfilled one row left develop failed "must have a unique value" failed, same message both rows The first press is identical on both sides. The whole difference is the second. A step fails to reverse for an ordinary reason: the column does not allow duplicates, the value was changed away, and another row has taken the old value since. That row is written on a DIFFERENT window id - the stack is keyed by it, and writing it on the same one would put it on the history this case walks back through. This case covers one of the six risks the report lists. The other five are concurrency and crash shapes that a single client against one process cannot show, and the doc says so rather than letting the issue id imply coverage. Written down in the doc because it cost a run: every write has to carry the window id, the generated client takes no per-call headers, and an undo with nothing on the stack answers "empty" - which the first version of the fixture check read as "not fulfilled" and let through, green on a pre-fix commit against an empty stack. The writes now go through raw axios and "empty" is rejected by name. Co-Authored-By: Claude Opus 5 * Write down T7057, and where the permission family actually stands T7057 changes which fields a substring index covers, not what a search returns: an all-field search over an uncovered field falls back to the unindexed path and answers the same. Same reading as T6821. The other half of this is a note rather than a rejection. Four uncovered fixes - T6997, T7025, T7027 and T6944 - all wait behind one piece of setup that does not exist here yet: the authority matrix on, a role that makes a field unreadable, and a second user holding it. None of them is blocked by the harness; the matrix is driven through public endpoints and a second signed-in user comes from the same test utils runners already import. The note records the endpoints and the worked example so the next attempt does not go looking for them, and says which of the four is the best first customer. Co-Authored-By: Claude Opus 5 * Build the authority-matrix fixture, and show a grid its reader cannot open T6944: under the authority matrix a role can withhold one column, and the rest of the table stays readable - that is the point of withholding a column rather than the table. But a view remembers what it is grouped by and the page sends that grouping with every request for rows, so asked to group by a column the reader may not see, the server refused the request outright. The person got not a view without its grouping but a view with NO ROWS, and a message naming neither the column nor the grouping. An administrator opening the same view sees everything, which is the worst shape a support conversation can have. Reproduced on 12407c409 with the customer's own words: 400 {"message":"Group references a field that is not readable", "code":"validation_error"} Most of this commit is framework/authority-matrix.ts, which stands up the three things together - the matrix enabled on a base, a role that withholds something, a second signed-in person holding that role. Three more reported bugs need exactly that, so it lives beside fixture-db rather than inside this runner, and is setup-only for the same reason: asking for it inside a checkpoint throws. The URLs are literals, not imports from the enterprise client, because a case runs against revisions weeks apart and a moved export would break it everywhere instead of failing honestly on the commit that moved it. sourceCommits names a4c8c3396 alone. Two commits carry this issue id and the case is red on BOTH sides of the earlier one - measured on 12407c409 and on 7bc91231d, which is after it - so f70f0d508 is recorded in the ledger as the half this case does not settle rather than claimed here. The control is the same request without the grouping, which must return every row: it says the person can read this table, so the refusal is about the grouping and not about them. The fixture also requires the withheld column to be absent from what comes back, or grouping by it would be an ordinary request. Co-Authored-By: Claude Opus 5 * Attribute the grouped-grid fix to the commit that actually made it The acceptance matrix caught a wrong claim. The case was written naming a4c8c3396, one of the two commits carrying T6944's id, and the matrix answered red on f44a82cf8 - which is after it. Bisected one commit at a time: red on 12407c409, 7bc91231d, f44a82cf8 and 8fd1e28b9; green at 2ae77481c, which carries T6997's id, "evaluate v2 reads over masked values". So sourceCommits names 2ae77481c while bug.issue stays T6944, because T6944 is what a person reported and this is that person's symptom. Both T6944 commits are now ledger rows for the half this case does not settle, with the measurements that say so. The doc records the mistake as well as the correction: anything a case claims about which commit fixed what has to come from a column, not from an issue id. Also noted that T6980 is red for v1 on every column including develop - the third case here to find the older engine still carrying a fix that only landed on v2. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .../a-column-that-picks-by-case.case.ts | 34 +++ cases/formula/a-column-that-picks-by-case.md | 62 ++++ ...-second-undo-after-one-that-failed.case.ts | 28 ++ .../a-second-undo-after-one-that-failed.md | 75 +++++ ...rouped-by-a-column-you-cannot-read.case.ts | 32 +++ ...rid-grouped-by-a-column-you-cannot-read.md | 91 ++++++ docs/triage-ledger.md | 30 ++ framework/authority-matrix.ts | 196 +++++++++++++ framework/runner-registry.ts | 6 + .../group-on-an-unreadable-column.runner.ts | 210 ++++++++++++++ .../switch-mixed-branch-storage.runner.ts | 264 ++++++++++++++++++ .../undo-cursor-after-a-failed-undo.runner.ts | 249 +++++++++++++++++ framework/types.ts | 41 +++ registry.ts | 6 + 14 files changed, 1324 insertions(+) create mode 100644 cases/formula/a-column-that-picks-by-case.case.ts create mode 100644 cases/formula/a-column-that-picks-by-case.md create mode 100644 cases/undo/a-second-undo-after-one-that-failed.case.ts create mode 100644 cases/undo/a-second-undo-after-one-that-failed.md create mode 100644 cases/view/a-grid-grouped-by-a-column-you-cannot-read.case.ts create mode 100644 cases/view/a-grid-grouped-by-a-column-you-cannot-read.md create mode 100644 framework/authority-matrix.ts create mode 100644 framework/runners/group-on-an-unreadable-column.runner.ts create mode 100644 framework/runners/switch-mixed-branch-storage.runner.ts create mode 100644 framework/runners/undo-cursor-after-a-failed-undo.runner.ts diff --git a/cases/formula/a-column-that-picks-by-case.case.ts b/cases/formula/a-column-that-picks-by-case.case.ts new file mode 100644 index 0000000..2aa6f87 --- /dev/null +++ b/cases/formula/a-column-that-picks-by-case.case.ts @@ -0,0 +1,34 @@ +import { defineBugCase } from "../../framework/types"; + +// T6980: "cost depends on where the cost comes from" - a manually entered figure +// for some rows, a different figure for others, and otherwise whatever is +// linked. The first two answers are numbers; the last is a list of linked +// records, stored as a document rather than as a number. The step merging the +// branches compared only the ones with a case attached, and those agreed, so it +// never looked at what the otherwise branch held. The database was then asked to +// choose between numbers and a document in one expression and refused, killing +// the column and the schema change it was part of. +export default defineBugCase({ + id: "formula/a-column-that-picks-by-case", + title: "A column that picks by case, ending in linked records, can be made", + runner: "switch-mixed-branch-storage", + timeoutMs: 300_000, + bug: { + issue: "T6980", + status: "fixed", + sourceCommits: ["fd0be31ad"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-switch-mixed", + numberBranches: [ + { choice: "Manual", column: "Manual cost", value: 11 }, + { choice: "Current", column: "Current cost", value: 22 }, + ], + otherwiseChoice: "Unmapped", + linkedRows: [ + { name: "price-one", price: 100 }, + { name: "price-two", price: 200 }, + ], + }, +}); diff --git a/cases/formula/a-column-that-picks-by-case.md b/cases/formula/a-column-that-picks-by-case.md new file mode 100644 index 0000000..5d82ff3 --- /dev/null +++ b/cases/formula/a-column-that-picks-by-case.md @@ -0,0 +1,62 @@ +# formula/a-column-that-picks-by-case + +**T6980** — fixed. On the `switch-mixed-branch-storage` runner. + +## What the user sees + +A column that picks its value by case cannot be created. The rule is ordinary — +"cost depends on where the cost comes from": a manually entered figure for some +rows, a different figure for others, and otherwise whatever is linked. The field +editor offers every part of it. Saving fails, and the schema change it was part +of dies with it. + +## Why + +The first two answers are numbers. The last is a list of linked records, which +is stored as a document rather than as a number. + +The step that merges the branches together compared only the branches with a +case attached. Those agreed — both numbers — so it never looked at what the +otherwise branch held. The database was then asked to choose between numbers and +a document in a single expression, and refused outright. + +Nothing in the interface says the last branch is a different kind of thing from +the others. + +## What the checkpoint asserts + +The column can be made, is not immediately marked broken, and reads the right +number on the rows whose case has a number behind it. + +Making the column is inside the checkpoint. Reconciling the branches happens +while it is built, so that is when the refusal happens; built in setup, the same +refusal would score as "this case could not run here" — the one verdict that +hides the bug. + +The rows falling to the otherwise branch are read but not pinned to a value. What +a list of linked records renders as has changed before (see +`lookup/two-records-with-one-name-are-two-records`), and a case that pinned it +would be rewritten by a change that did not touch this behaviour. What matters +here is that the column exists and the numbered cases are right. + +## Why the fixture is shaped this way + +**Two** number branches, and the runner refuses fewer. The branches with a case +attached have to agree with each other — that agreement is exactly what stopped +the merge from looking any further. With one branch there is nothing to agree +with, and the merge may reach the otherwise branch on its own. + +The linked column must hold a **list**, checked before the checkpoint: holding a +single value it would be the same kind of thing as the numbers, and there would +be nothing to reconcile. + +## The v1 column + +v1 reproduces this on **every** column of the acceptance matrix, `develop` +included. The fix is v2-only, so on the older engine a column of this shape still +cannot be made. Reported rather than enforced — the v1 column is a reference and +never gates a run. + +That is the third case here to say the same thing about v1; the others are +`lookup/distinct-choices-in-the-order-they-appear` and +`lookup/two-records-with-one-name-are-two-records`. diff --git a/cases/undo/a-second-undo-after-one-that-failed.case.ts b/cases/undo/a-second-undo-after-one-that-failed.case.ts new file mode 100644 index 0000000..1eeb8c1 --- /dev/null +++ b/cases/undo/a-second-undo-after-one-that-failed.case.ts @@ -0,0 +1,28 @@ +import { defineBugCase } from "../../framework/types"; + +// T7038: undo walks backwards through what you did, and the place it has walked +// back to was moved BEFORE the step was carried out and never moved back when +// the step failed. A failed undo therefore still counted as done, so the next +// press skipped over it and reversed the step before - one the person had not +// asked to reverse. The failed undo itself is honest and visible; the second +// press is the part that quietly takes something else away. +export default defineBugCase({ + id: "undo/a-second-undo-after-one-that-failed", + title: + "A second undo after one that failed retries it, and reaches no further", + runner: "undo-cursor-after-a-failed-undo", + timeoutMs: 180_000, + bug: { + issue: "T7038", + status: "fixed", + sourceCommits: ["130d82efd"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-undo-cursor", + rowName: "the-row-nobody-asked-to-delete", + otherRowName: "the-row-that-took-the-value", + originalCode: "code-first", + changedCode: "code-second", + }, +}); diff --git a/cases/undo/a-second-undo-after-one-that-failed.md b/cases/undo/a-second-undo-after-one-that-failed.md new file mode 100644 index 0000000..7370a7d --- /dev/null +++ b/cases/undo/a-second-undo-after-one-that-failed.md @@ -0,0 +1,75 @@ +# undo/a-second-undo-after-one-that-failed + +**T7038** — fixed. On the `undo-cursor-after-a-failed-undo` runner. + +## What the user sees + +Undo cannot carry out a step — for an ordinary reason, and it says so. Press undo +again, and instead of trying that step once more it reverses the step _before_ +it: in this fixture, the creation of the row. A row disappears that nobody asked +to delete. + +The failed undo is not the problem. It is honest and it is visible. The second +press is the one that quietly takes something else away. + +## Why + +Undo moved the place it had walked back to **before** carrying out the step, and +never moved it back when the step failed. A failed undo therefore still counted +as done, and the next press started from behind it. + +## What the checkpoint asserts + +That the second press retries the same step and reaches no further: the row is +still there, and it still holds the value the failed undo could not put back. + +Both halves matter. The row surviving says undo did not reach past the failed +step; the value being unchanged says the step genuinely still has not been +carried out, rather than having quietly succeeded on the second try for some +other reason. + +## What the two presses answer, measured + +| | first press | second press | rows left | +| ---------------------------- | ------------------------------------ | -------------------------- | -------------------------------- | +| `f44a82cf8` (before the fix) | `failed`, "must have a unique value" | `fulfilled` | only the row that took the value | +| `develop` | `failed`, "must have a unique value" | `failed`, the same message | both | + +The first press is identical on both sides — it is honest either way. The whole +difference is the second one. + +## Why the fixture is shaped this way + +A step fails to reverse here because the column does not allow duplicates: the +row's value was changed away from `code-first`, another row has taken +`code-first` since, and putting the old value back would now collide. Nothing is +wrong with the data or with either request. + +The row that takes the value is written on a **different window id**. The undo +stack is keyed by that id, so writing it on the same one would put it on the +history this case walks back through, and the case would be undoing a different +sequence than it describes. + +Before the checkpoint, the fixture requires that the first press really did fail +and that both rows are still present. A first press that succeeded would leave no +failed step for the second to skip, and the case would be reporting on nothing. + +## What this case does not cover + +The report lists six risks. This case covers one: a failed step still counting as +done. The others are two requests undoing at once, two appends racing, undo +racing with append, a crash between writes, and a partly-successful batch. A +single client against one process cannot show any of those, and nothing here +should be read as guarding them. + +## A trap this case fell into first + +Every write here has to carry the window id, because the undo stack is keyed by +it. The generated client takes no per-call headers, so the first version of this +runner passed them where they were quietly ignored — and undo then answered +`{"status":"empty"}`, which the fixture check read as "not fulfilled" and let +through. The case was green on a pre-fix commit while asserting against an empty +stack. + +Both halves are now closed: the writes go through raw axios, and an `empty` +first press is rejected by name as a fixture that never reached the stack. diff --git a/cases/view/a-grid-grouped-by-a-column-you-cannot-read.case.ts b/cases/view/a-grid-grouped-by-a-column-you-cannot-read.case.ts new file mode 100644 index 0000000..0a183d1 --- /dev/null +++ b/cases/view/a-grid-grouped-by-a-column-you-cannot-read.case.ts @@ -0,0 +1,32 @@ +import { defineBugCase } from "../../framework/types"; + +// T6944: under the authority matrix a role can withhold a single column, and the +// rest of the table stays readable - that is the point of withholding one column +// rather than the table. But a view remembers what it is grouped by and the page +// sends that grouping with every request for rows, so asked to group by a column +// the reader may not see, the server refused the request outright. What the +// person got was not a view without its grouping but a view with no rows at all +// and a message about a data validation error, naming neither the column nor the +// grouping. An administrator opening the same view sees everything. +export default defineBugCase({ + id: "view/a-grid-grouped-by-a-column-you-cannot-read", + title: "A grid grouped by a column you cannot read still shows its rows", + runner: "group-on-an-unreadable-column", + timeoutMs: 300_000, + skipV1: + "the case builds its own base for the authority matrix, and only the case base is unstamped - a base created inside a runner is born on v2, so v1 cannot be asked this", + bug: { + issue: "T6944", + status: "fixed", + sourceCommits: ["2ae77481c"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-group-unreadable", + rows: [ + { name: "first-deal", stage: "open", cost: 10 }, + { name: "second-deal", stage: "won", cost: 20 }, + { name: "third-deal", stage: "open", cost: 30 }, + ], + }, +}); diff --git a/cases/view/a-grid-grouped-by-a-column-you-cannot-read.md b/cases/view/a-grid-grouped-by-a-column-you-cannot-read.md new file mode 100644 index 0000000..a65bc2c --- /dev/null +++ b/cases/view/a-grid-grouped-by-a-column-you-cannot-read.md @@ -0,0 +1,91 @@ +# view/a-grid-grouped-by-a-column-you-cannot-read + +**T6944** — fixed. On the `group-on-an-unreadable-column` runner. + +## What the user sees + +Someone whose role withholds one column opens a grid that happens to be grouped +by that column. The grid loads **no rows at all**, with a message about a data +validation error. + +Withholding one column is supposed to leave the rest of the table readable — +that is the point of withholding a column rather than the table. What they get +instead is a view that shows them nothing. + +Nothing in the message names the column, and nothing suggests the grouping is +the thing to change. An administrator opening the same view sees everything, +which is the worst possible shape for a support conversation: the person who can +help cannot see the problem. + +## Why + +A view remembers what it is grouped by, and the page sends that grouping with +every request for rows. The server had two minds about a grouping it could not +honour: a grouping it resolved from the view itself was quietly narrowed to +readable columns, while a grouping that arrived on the request was refused. The +page sends the view's own grouping as if the person had typed it, so the refusal +is what ran — and because it is the grid's own request for rows that fails, the +result is no rows rather than no grouping. + +## What the checkpoint asserts + +The grouped request answers, and answers with every row the person is allowed to +see. + +Both halves. A 200 carrying nothing would be the same empty grid with a friendlier +status. + +## Why the fixture is shaped this way + +Before the checkpoint, the same request is made **without** the grouping, and it +must return every row. That is the control: it says this person can read this +table, so a refusal afterwards is about the grouping rather than about them. + +The fixture also requires that the withheld column really is absent from what +comes back. If the role were not withholding it, grouping by it would be an +ordinary request and the case would be reporting on nothing. + +Exactly one column is withheld, and no rows are. This case is about a withheld +**column**; a role that also hid rows would make "every row the person is allowed +to see" a moving target. + +## Which commit this settles, and which it does not + +Not the one the issue id points at. Measured, one commit at a time: + +| commit | the grouped request | +| ---------------------------------------- | ---------------------------------------------------- | +| `12407c409` — before `f70f0d508` (T6944) | 400, "Group references a field that is not readable" | +| `7bc91231d` — before `a4c8c3396` (T6944) | the same 400 | +| `f44a82cf8` — after both T6944 commits | the same 400 | +| `8fd1e28b9` — before `2ae77481c` (T6997) | the same 400 | +| `2ae77481c` | 200, every row | + +So `bug.sourceCommits` names `2ae77481c`, which carries **T6997**'s id — "evaluate +v2 reads over masked values" — while `bug.issue` stays T6944, because T6944 is +what a person reported and this is that person's symptom. + +Both commits carrying the T6944 id leave this path exactly as it was. They are +recorded in `docs/triage-ledger.md` as halves this case does not settle, rather +than claimed here. + +The first attribution in this case was wrong and the acceptance matrix caught it: +the case was written claiming `a4c8c3396`, and the matrix answered red on a +column **after** that commit. Anything a case claims about which commit fixed +what has to come from a column, not from an issue id. + +## The fixture behind it + +The authority matrix, a role that withholds something, and a second signed-in +person holding that role all have to stand up together, and three other reported +bugs need the same three things. That setup lives in +`framework/authority-matrix.ts` rather than in this runner, and is setup-only for +the same reason `framework/fixture-db.ts` is: asking for it inside a checkpoint +throws. The restricted person's own requests are the observation. + +## The v1 column + +Skipped, and for the harness rather than the product: the case builds its own +base, and `framework/case-base.ts` unstamps only the base it manages, so a base +born inside a runner is born on v2. Same limitation as +`base-share/a-share-link-whose-database-is-away`. diff --git a/docs/triage-ledger.md b/docs/triage-ledger.md index b548f96..377f361 100644 --- a/docs/triage-ledger.md +++ b/docs/triage-ledger.md @@ -145,6 +145,12 @@ The shape is gone; the runner is not kept. | `4b57c03da` | T7070 | Written and run. The fixture builds cleanly - a manyOne link with a column borrowed through it, then `fixture-db` drops the hidden `__fk_` column the link's own settings still name - and adding a row to the other table is refused. But the refusal is `Failed to insert record: column t.__fk_… does not exist`, raised during the insert, while the fix repairs `Failed to propagate dirty records`, raised by the deferred propagate. Two call sites. The commit's own e2e reaches the second by draining an outbox inside the v2 test container; this harness runs the Nest application, where the same small fixture computes inline and never gets there. Tried one-way and two-way links; both fail in the insert. Same trap as T6728. **The insert-path failure is still present on `develop`** - see the note below the table. | | `893d0ce20` | T7066 | Written and run twice, green on the fix's parent both times. The report's precondition is that the summaries exist before the row does, so the answer is worked out during the write rather than filled in afterwards - built first by creating the row and its links in one call, then by creating the row and attaching the children as two writes, which is what the report's own steps describe. Neither reproduces. The sibling case `lookup/distinct-choices-in-the-order-they-appear` (T7044) is also **green** on this parent, measured, so it does not cover this either. The shape stays behind the `select-rollup-unique-and-count` runner's `whenTheRowIsWritten` and `alsoCheckAfterAnEdit` config values; a third attempt should start by finding what else the CLI path does that these two do not. | | `4f35a4a64` | T7047 | The observation lives on the v2 contract's own list endpoint - `limit`/`cursor`/`includeTotal` - not on the public record API this lab reads through, and the lab's client does not speak it. What changed behind that endpoint is also performance-shaped: skip `count(*)` unless asked, page by cursor instead of OFFSET. Same reason as the T5268 row: much of the fix introduces the path it repairs, so there is no before to compare against. | +| `a4e2a0a55` | T7105 | Cost, not behaviour. Field masks unioned every readable field into the SQL projection, so a request for 27 columns still selected all 235. What the request answers with does not change - the same fields come back either way - so there is nothing for a case here to tell apart. It is the product-side follow-up to a 503 incident about wide-table polling, and belongs in the performance lab if anywhere. | +| `719079af1` | T6711 | The observation is a schema operation's own terminal classification - whether a leftover `table.import` is marked dead or repaired - which lives in the background runner and never reaches an HTTP response. The lab has no seam on that: the T7070 attempt established by measurement that a small fixture here computes inline and the deferred path is not reached. Same family as T6768 and T6853. | +| `64b6446061` | T6904 | Same seam. A computed task planned against a table whose `provision_state` is still `pending` was dead-lettered as an obsolete plan instead of retried; the fix changes how the worker classifies that. Both the trigger (a table caught mid-provision by a background stage) and the observation (the task's failure classification) are inside the worker. Nothing a request answers differs. | +| `e770dd1ac` | T7057 | Index coverage, not results. Substring search documents and the trigram indexes behind them are narrowed to text-shaped fields, and an all-field search over an uncovered field falls back to the unindexed path rather than answering differently. What a search returns is the same on both sides; what changes is whether an index can serve it. Performance lab, if anywhere - same reading as T6821. | +| `f70f0d508` | T6944 | Neither commit carrying this issue id fixes the path `view/a-grid-grouped-by-a-column-you-cannot-read` observes. That case is red on `12407c409` (before this commit), on `7bc91231d` (after it), and on `f44a82cf8` (after both), and turns green only at `2ae77481c` — which carries T6997. This one narrows a grouping the server resolves from the view itself; the case exercises a grouping that arrives on the request, which is what the grid actually sends. Reaching the other path needs a request carrying no grouping while the view carries one, and the record endpoint the lab reads through does not obviously offer that. | +| `a4c8c3396` | T6944 | Same reading, same measurements: the case is red on `f44a82cf8`, which is after this commit. It aligns the group metadata a view reports with the permissions applied to it, which is what the settings screen reads, not what the grid's request for rows goes through. | ### The date comparison inside AND or OR @@ -533,3 +539,27 @@ T7070 repaired the propagate path for exactly this state. The insert path was not part of it and answers the same way it did before. Whether that is worth its own report is a judgment for a person; it is recorded here so the next pass does not spend the same afternoon rediscovering it. + +### The permission-matrix family is reachable, and nobody has built the fixture yet + +Four uncovered fixes wait behind one piece of setup that does not exist here +yet: `2ae77481c5`/T6997 (v2 reads over masked values), `68b7d74f05`/T7025 +(archiving gated by the matrix for restricted collaborators), `6235527b4c`/T7027 +(references to permission-filtered nodes), and `a4c8c3396b`+`f70f0d5083`/T6944 +(a grid view whose group field the reader cannot see returns no records at all, +with "Group references a field that is not readable"). + +None of them is blocked by the harness. The matrix is driven entirely through +public endpoints — `PATCH /api/base/:baseId/authority-matrix/status` to turn it +on, `GET /api/base/:baseId/authority-matrix`, `PUT +/api/base/:baseId/authority-matrix/:id` to shape a role — and a second signed-in +user comes from `test/utils/axios-instance/new-user`, which runners can import +the same way they import `init-app`. `enterprise/backend-ee/test/authority/` is +the worked example. + +What is missing is a fixture that puts those together: matrix on, a role that +makes one field unreadable, a second user holding that role. That is a bigger +piece of setup than any case here has needed, and it is worth building once +rather than four times. T6944 is the best first customer — its symptom is the +whole view returning nothing, which is unmistakable — and T7025 should wait +either way, since it was still on staging when this was written. diff --git a/framework/authority-matrix.ts b/framework/authority-matrix.ts new file mode 100644 index 0000000..e09b453 --- /dev/null +++ b/framework/authority-matrix.ts @@ -0,0 +1,196 @@ +import { Role } from "@teable/core"; +import { + axios, + createBase as apiCreateBase, + createSpace as apiCreateSpace, + deleteSpace, + permanentDeleteSpace, + EMAIL_SPACE_INVITATION, + urlBuilder, + USER_ME, +} from "@teable/openapi"; +import { createNewUserAxios } from "../../utils/axios-instance/new-user"; +import { isInsideCheckpoint } from "./checkpoint"; + +/** + * A base with the authority matrix on, and a signed-in person it restricts. + * + * Several reported bugs are only visible to somebody the matrix limits: a + * column they may not read, a row outside their filter, an action their role + * withholds. Every one of them needs the same three things standing up together + * - the matrix enabled on a base, a role that withholds something, and a second + * person holding that role - and none of it is state the ordinary test user can + * observe, because the person who owns a base is not restricted by its matrix. + * + * That setup is bigger than any single case wants to carry, and building it + * four times would be four chances to build it subtly differently. So it lives + * here, once. + * + * SETUP ONLY, like framework/fixture-db.ts and for the same reason: the + * restricted person's own requests are the observation, but standing them up is + * not. Asking for this inside a `bugCheckpoint()` throws. + * + * Everything goes through public endpoints - the same ones the product's own + * settings screens call - so nothing here depends on internals that move. The + * URL strings are literals rather than imports from the enterprise client + * package, because a case runs against teable-ee revisions weeks apart and a + * moved export would break the case everywhere instead of failing honestly on + * the one commit that moved it. + */ + +const UPDATE_AUTHORITY_MATRIX_STATUS = "/base/{baseId}/authority-matrix/status"; +const ADD_AUTHORITY_MATRIX_ROLE = "/base/{baseId}/authority-matrix-role"; +const UPDATE_AUTHORITY_MATRIX_ROLE_USER = + "/base/{baseId}/authority-matrix-role/{authorityMatrixRoleId}/user"; + +// What a role withholds, per table. The shape the product's own role editor +// posts: actions withheld across the table, rows the role can see at all, and +// per-column withholding. +export interface RestrictedTableRule { + tableId: string; + // e.g. ["record|delete"]. Withheld across the whole table. + disabledActions?: string[]; + // Rows the role may see. Omitted means every row. + recordFilter?: { + conjunction: "and" | "or"; + filterSet: { fieldId: string; operator: string; value: unknown }[]; + }; + // Columns the role may not read, write or fill in. + fieldRecordPermission?: { + fieldId: string; + disabledActions: string[]; + }[]; +} + +// The signed-in client, taken from the helper that makes it rather than from a +// bare "axios" import: the type checker stubs this repository's cross-repo +// imports by name, and a package it has no stub for fails the check. +type SignedInClient = Awaited>; + +export interface RestrictedPerson { + // Signed in as the restricted person. Their requests are the observation. + axios: SignedInClient; + userId: string; + email: string; + spaceId: string; + baseId: string; + roleId: string; + // Tears down the space, the base and everything in them. + cleanUp: () => Promise; +} + +// One address for the whole lab. The person is identified by it across runs; +// what they are allowed to do is a property of the role in a base, and every +// case builds its own base, so nothing is shared between cases but the name. +const RESTRICTED_EMAIL = "e2e-lab-restricted-reader@example.com"; +const RESTRICTED_PASSWORD = "12345678a"; + +/** + * Stand up a base with the matrix on and a second person restricted by it. + * + * `buildTables` is called with the new base id, as the OWNER, and returns the + * rules for the restricted person's role. Tables have to exist before a role + * can withhold anything in them, which is why it is a callback rather than an + * argument. + */ +export const withRestrictedPerson = async (options: { + namePrefix: string; + runId: string; + buildTables: (baseId: string) => Promise; +}): Promise => { + if (isInsideCheckpoint()) { + throw new Error( + "the authority matrix is fixture, not observation: build it before bugCheckpoint(), " + + "and make only the restricted person's requests inside it", + ); + } + + const suffix = `${options.namePrefix}-${options.runId}`; + let spaceId = ""; + + const cleanUp = async () => { + if (!spaceId) { + return; + } + await deleteSpace(spaceId); + await permanentDeleteSpace(spaceId); + }; + + try { + // The owner's own space and base. It must not be the seed base: turning the + // matrix on changes what every other case reading that base can see. + const space = await apiCreateSpace({ name: suffix }); + spaceId = space.data.id; + const base = await apiCreateBase({ spaceId, name: `${suffix}-base` }); + const baseId = base.data.id; + + // The second person. Signing up is idempotent - the helper signs in when + // the address is taken - so runs share an identity and nothing else. + const personAxios = await createNewUserAxios({ + email: RESTRICTED_EMAIL, + password: RESTRICTED_PASSWORD, + }); + const userId = (await personAxios.get(USER_ME)).data.id as string; + + // Into the space as an ordinary editor. Not an administrator of the matrix: + // an administrator is exempt from it, and this whole fixture exists to + // produce somebody who is not. + await axios.post(urlBuilder(EMAIL_SPACE_INVITATION, { spaceId }), { + role: Role.Editor, + emails: [RESTRICTED_EMAIL], + }); + + await axios.patch(urlBuilder(UPDATE_AUTHORITY_MATRIX_STATUS, { baseId }), { + enabled: true, + }); + + const tables = await options.buildTables(baseId); + if (tables.length === 0) { + throw new Error( + "a role that withholds nothing restricts nobody - build at least one table rule", + ); + } + + const role = await axios.post( + urlBuilder(ADD_AUTHORITY_MATRIX_ROLE, { baseId }), + { + name: `${suffix}-role`, + enabled: true, + tables: tables.map((rule) => ({ + enabled: true, + tableId: rule.tableId, + disabledActions: rule.disabledActions ?? [], + ...(rule.recordFilter ? { recordFilter: rule.recordFilter } : {}), + fieldRecordPermission: rule.fieldRecordPermission ?? [], + })), + }, + ); + const roleId = (role.data as { id?: string })?.id; + if (!roleId) { + throw new Error( + `adding the role returned no role: ${JSON.stringify(role.data)}`, + ); + } + + await axios.patch( + urlBuilder(UPDATE_AUTHORITY_MATRIX_ROLE_USER, { + baseId, + authorityMatrixRoleId: roleId, + }), + { userIds: [userId] }, + ); + + return { + axios: personAxios, + userId, + email: RESTRICTED_EMAIL, + spaceId, + baseId, + roleId, + cleanUp, + }; + } catch (error) { + await cleanUp().catch(() => undefined); + throw error; + } +}; diff --git a/framework/runner-registry.ts b/framework/runner-registry.ts index 99237d3..5575bb9 100644 --- a/framework/runner-registry.ts +++ b/framework/runner-registry.ts @@ -120,6 +120,9 @@ import { runSelectRollupUniqueAndCountCase } from "./runners/select-rollup-uniqu import { runLinkRollupUniqueByIdentityCase } from "./runners/link-rollup-unique-by-identity.runner"; import { runNestedUserArrayJoinCreateCase } from "./runners/nested-user-array-join-create.runner"; import { runShareViewUnreadyDataDbCase } from "./runners/share-view-unready-data-db.runner"; +import { runSwitchMixedBranchStorageCase } from "./runners/switch-mixed-branch-storage.runner"; +import { runUndoCursorAfterAFailedUndoCase } from "./runners/undo-cursor-after-a-failed-undo.runner"; +import { runGroupOnAnUnreadableColumnCase } from "./runners/group-on-an-unreadable-column.runner"; import { runLookupOfRollupCreateCase } from "./runners/lookup-of-rollup-create.runner"; import type { BugCase, @@ -261,6 +264,9 @@ const runners: { [K in BugRunnerKind]: RunnerFn } = { "link-rollup-unique-by-identity": runLinkRollupUniqueByIdentityCase, "nested-user-array-join-create": runNestedUserArrayJoinCreateCase, "share-view-unready-data-db": runShareViewUnreadyDataDbCase, + "switch-mixed-branch-storage": runSwitchMixedBranchStorageCase, + "undo-cursor-after-a-failed-undo": runUndoCursorAfterAFailedUndoCase, + "group-on-an-unreadable-column": runGroupOnAnUnreadableColumnCase, }; export const executeRegisteredRunner = ( diff --git a/framework/runners/group-on-an-unreadable-column.runner.ts b/framework/runners/group-on-an-unreadable-column.runner.ts new file mode 100644 index 0000000..bafaba2 --- /dev/null +++ b/framework/runners/group-on-an-unreadable-column.runner.ts @@ -0,0 +1,210 @@ +import { FieldKeyType, FieldType, SortFunc } from "@teable/core"; +import { GET_RECORDS_URL, urlBuilder } from "@teable/openapi"; +import { createTable, permanentDeleteTable } from "../../../utils/init-app"; +import { withRestrictedPerson } from "../authority-matrix"; +import { bugCheckpoint } from "../checkpoint"; +import { assertServedByV2 } from "../engine"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { GroupOnAnUnreadableColumnCaseConfig } from "../types"; + +// A grid grouped by a column the person opening it may not read -> open it -> +// checkpoint: the rows come back. +// +// Under the authority matrix a role can withhold a single column. The rest of +// the table is still theirs to read - that is the whole point of withholding +// one column rather than the table. +// +// But a view remembers what it is grouped by, and the page sends that grouping +// with every request for rows. Asked to group by a column the reader may not +// see, the server refused the request outright, so what the person got was not +// a view without its grouping - it was a view with NO ROWS AT ALL and a message +// about a data validation error. Nothing in it names the column, and nothing +// suggests the grouping is the thing to change. An administrator opening the +// same view sees everything, which is the worst possible shape for a support +// conversation. +// +// The same request without the grouping is read first, outside the checkpoint. +// That is the control: it says the person can read this table, so a refusal +// afterwards is about the grouping and not about them. + +const NAME_FIELD = "Name"; +const OPEN_FIELD = "Stage"; +const WITHHELD_FIELD = "Owner cost"; + +export const runGroupOnAnUnreadableColumnCase = async ( + bugCase: BugCaseFor<"group-on-an-unreadable-column">, + context: BugRunContext, +): Promise => { + const config: GroupOnAnUnreadableColumnCaseConfig = bugCase.config; + const suffix = `${config.tableNamePrefix}-${context.runId}`; + let person: Awaited> | undefined; + let tableId = ""; + let withheldFieldId = ""; + let openFieldId = ""; + + if (config.rows.length < 2) { + throw new Error( + "at least two rows, or a request that returns nothing looks the same as one that returns everything", + ); + } + + try { + person = await withRestrictedPerson({ + namePrefix: config.tableNamePrefix, + runId: context.runId, + buildTables: async (baseId) => { + const table = await createTable(baseId, { + name: suffix, + fields: [ + { + name: NAME_FIELD, + type: FieldType.SingleLineText, + isPrimary: true, + }, + { name: OPEN_FIELD, type: FieldType.SingleLineText }, + { name: WITHHELD_FIELD, type: FieldType.Number }, + ], + records: config.rows.map((row) => ({ + fields: { + [NAME_FIELD]: row.name, + [OPEN_FIELD]: row.stage, + [WITHHELD_FIELD]: row.cost, + }, + })), + }); + tableId = table.id; + withheldFieldId = table.fields.find( + (field: { name: string }) => field.name === WITHHELD_FIELD, + )?.id as string; + openFieldId = table.fields.find( + (field: { name: string }) => field.name === OPEN_FIELD, + )?.id as string; + if (!withheldFieldId || !openFieldId) { + throw new Error("the table is not in place"); + } + + // One column withheld, and only one. Everything else stays readable, so + // the person can open the table at all. + return [ + { + tableId: table.id, + fieldRecordPermission: [ + { + fieldId: withheldFieldId, + disabledActions: [ + "record|read", + "record|update", + "record|create", + ], + }, + ], + }, + ]; + }, + }); + + const readAs = async (groupBy?: unknown) => + person!.axios.get(urlBuilder(GET_RECORDS_URL, { tableId }), { + params: { + fieldKeyType: FieldKeyType.Id, + take: config.rows.length, + ...(groupBy ? { groupBy: JSON.stringify(groupBy) } : {}), + }, + validateStatus: () => true, + }); + + // Fixture verification, outside the checkpoint. Two things have to be true + // before the grouped request means anything: the person can read the table, + // and the withheld column really is withheld from them. Without the second, + // grouping by it would be an ordinary request and the case would report on + // nothing. + const plain = await readAs(); + if (plain.status !== 200) { + throw new Error( + `the restricted person cannot read the table at all (${plain.status}): ${JSON.stringify(plain.data)}`, + ); + } + const plainRows = + (plain.data as { records?: { fields: Record }[] }) + ?.records ?? []; + if (plainRows.length !== config.rows.length) { + throw new Error( + `the restricted person sees ${plainRows.length} of ${config.rows.length} rows - ` + + "this case is about a withheld column, not withheld rows", + ); + } + if (plainRows.some((row) => row.fields[withheldFieldId] !== undefined)) { + throw new Error( + `the withheld column came back to the restricted person: ${JSON.stringify(plainRows[0]?.fields)} - ` + + "the role is not withholding it, so grouping by it is an ordinary request", + ); + } + const routing = assertServedByV2(plain.headers, { + operation: "GET /table/{tableId}/record", + feature: "getRecords", + }); + + const probe = await bugCheckpoint( + "a-grid-grouped-by-a-column-you-cannot-read-still-shows-its-rows", + async () => { + const grouped = await readAs([ + { fieldId: withheldFieldId, order: SortFunc.Asc }, + ]); + const body = + typeof grouped.data === "string" + ? grouped.data + : JSON.stringify(grouped.data ?? ""); + + if (grouped.status !== 200) { + throw new Error( + `opening the grid grouped by a column the person may not read answered ${grouped.status}, ` + + `so the whole view has no rows rather than no grouping: ${body}`, + ); + } + const rows = + (grouped.data as { records?: { id: string }[] })?.records ?? []; + if (rows.length !== config.rows.length) { + throw new Error( + `the grouped request answered 200 but returned ${rows.length} of ${config.rows.length} rows: ${body}`, + ); + } + return { rows: rows.length }; + }, + ); + + return { + details: { + baseId: person.baseId, + tableId, + withheldFieldId, + roleId: person.roleId, + routing, + ...probe, + }, + }; + } finally { + if (tableId && person) { + try { + await permanentDeleteTable(person.baseId, tableId); + } catch (error) { + // Cleanup is the case's own housekeeping - the product did not fail. + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + if (person) { + try { + await person.cleanUp(); + } catch (error) { + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (space ${person.spaceId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/runners/switch-mixed-branch-storage.runner.ts b/framework/runners/switch-mixed-branch-storage.runner.ts new file mode 100644 index 0000000..a4243d6 --- /dev/null +++ b/framework/runners/switch-mixed-branch-storage.runner.ts @@ -0,0 +1,264 @@ +import { Colors, FieldKeyType, FieldType, Relationship } from "@teable/core"; +import { + createRecords as apiCreateRecords, + getFields as apiGetFields, + getRecords as apiGetRecords, +} from "@teable/openapi"; +import { + createField, + createTable, + permanentDeleteTable, +} from "../../../utils/init-app"; +import { bugCheckpoint } from "../checkpoint"; +import { assertServedByV2 } from "../engine"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { SwitchMixedBranchStorageCaseConfig } from "../types"; + +// A column that picks its value by case - this number for one kind of row, that +// number for another, and otherwise the linked records -> checkpoint: the column +// can be made, and it reads what each case says. +// +// Written out, the rule is "cost depends on where the cost comes from": a +// manually entered figure for some rows, a different figure for others, and for +// everything else whatever is linked. The first two answers are numbers. The +// last is a list of linked records, which is stored as a document rather than as +// a number. +// +// The step that merges the branches together compared only the ones with a +// case attached, and those agreed - both numbers - so it never looked at what +// the otherwise branch held. The database was then asked to choose between +// numbers and a document in one expression and refused outright, which killed +// the whole column: it could not be created, and the schema change it was part +// of died with it. +// +// The interface offers all of this. Nothing about the formula is unusual, and +// nothing says the last branch is a different kind of thing from the others. + +const NAME_FIELD = "Name"; +const PRICE_FIELD = "Price"; +const BASIS_FIELD = "Cost basis"; +const LINK_FIELD = "Prices"; +const SWITCH_FIELD = "Cost"; + +export const runSwitchMixedBranchStorageCase = async ( + bugCase: BugCaseFor<"switch-mixed-branch-storage">, + context: BugRunContext, +): Promise => { + const config: SwitchMixedBranchStorageCaseConfig = bugCase.config; + const baseId = globalThis.testConfig.baseId; + const suffix = `${config.tableNamePrefix}-${context.runId}`; + const createdTableIds: string[] = []; + + if (config.numberBranches.length < 2) { + throw new Error( + "two number branches at least - the branches with a case attached have to agree with each other, " + + "or the step that merges them would have looked at the otherwise branch anyway", + ); + } + const basisChoices = [ + ...config.numberBranches.map((branch) => branch.choice), + config.otherwiseChoice, + ]; + if (new Set(basisChoices).size !== basisChoices.length) { + throw new Error( + `the cases are not distinct: ${JSON.stringify(basisChoices)}`, + ); + } + + try { + // The linked table. Its rows are what the otherwise branch reads, and a + // many-valued link means that branch holds a list rather than one value - + // which is what makes it a different kind of thing from the numbers. + const prices = await createTable(baseId, { + name: `${suffix}-prices`, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + { name: PRICE_FIELD, type: FieldType.Number }, + ], + records: config.linkedRows.map((row) => ({ + fields: { [NAME_FIELD]: row.name, [PRICE_FIELD]: row.price }, + })), + }); + createdTableIds.unshift(prices.id); + const priceRowIds = prices.records.map( + (record: { id: string }) => record.id, + ); + + const services = await createTable(baseId, { + name: `${suffix}-services`, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + { + name: BASIS_FIELD, + type: FieldType.SingleSelect, + options: { + choices: basisChoices.map((name) => ({ name, color: Colors.Blue })), + }, + }, + ...config.numberBranches.map((branch) => ({ + name: branch.column, + type: FieldType.Number, + })), + ], + records: [], + }); + createdTableIds.unshift(services.id); + const fieldId = (name: string) => { + const found = services.fields.find( + (field: { name: string }) => field.name === name, + )?.id; + if (!found) { + throw new Error(`the services table has no ${name} column`); + } + return found as string; + }; + const basisId = fieldId(BASIS_FIELD); + const numberIds = config.numberBranches.map((branch) => + fieldId(branch.column), + ); + + const link = await createField(services.id, { + name: LINK_FIELD, + type: FieldType.Link, + options: { + // Many-to-many so every row can hold the same list. One-to-many gives + // each linked record a single parent, and the fixture needs several + // rows - one per case - all reading a list. + relationship: Relationship.ManyMany, + foreignTableId: prices.id, + }, + }); + + // One row per case, each linked to the priced rows so the otherwise branch + // has something to read. + const rows = [ + ...config.numberBranches.map((branch, index) => ({ + name: `row-${branch.choice}`, + basis: branch.choice, + expected: branch.value, + index, + })), + { + name: `row-${config.otherwiseChoice}`, + basis: config.otherwiseChoice, + expected: null, + index: -1, + }, + ]; + await apiCreateRecords(services.id, { + fieldKeyType: FieldKeyType.Id, + typecast: false, + records: rows.map((row) => ({ + fields: { + [services.fields[0].id]: row.name, + [basisId]: row.basis, + ...Object.fromEntries( + config.numberBranches.map((branch, index) => [ + numberIds[index], + branch.value, + ]), + ), + [link.id]: priceRowIds.map((id: string) => ({ id })), + }, + })), + }); + + // Fixture verification, outside the checkpoint: the linked column really + // holds a list. Holding one value, it would be the same kind of thing as + // the numbers and there would be nothing to reconcile. + const seeded = await apiGetRecords(services.id, { + fieldKeyType: FieldKeyType.Id, + take: rows.length, + }); + const linkCell = seeded.data.records[0]?.fields[link.id]; + if (!Array.isArray(linkCell) || linkCell.length < 2) { + throw new Error( + `the linked column holds ${JSON.stringify(linkCell)} - the otherwise branch needs a list`, + ); + } + const routing = assertServedByV2(seeded.headers, { + operation: "GET /table/{tableId}/record", + feature: "getRecords", + }); + + const cases = config.numberBranches + .map((branch, index) => `"${branch.choice}", {${numberIds[index]}}`) + .join(", "); + const expression = `SWITCH({${basisId}}, ${cases}, {${link.id}})`; + + const probe = await bugCheckpoint( + "a-column-that-picks-by-case-can-be-made", + async () => { + // The column is made HERE. Reconciling the branches happens while it is + // built, so the refusal happens then - building it in setup would score + // that as "this case could not run here" instead of as the bug. + const made = await createField(services.id, { + name: SWITCH_FIELD, + type: FieldType.Formula, + options: { expression }, + }); + + const listed = await apiGetFields(services.id); + const back = listed.data.find( + (field: { id: string }) => field.id === made.id, + ) as { hasError?: boolean } | undefined; + if (back?.hasError) { + throw new Error( + `the column was created and immediately marked broken: ${expression}`, + ); + } + + // And it reads what each case says, at least where the answer is a + // number. A column that exists and computes nothing is the same outage + // one step later. + const after = await apiGetRecords(services.id, { + fieldKeyType: FieldKeyType.Id, + take: rows.length, + }); + const byName = new Map( + after.data.records.map((record) => [ + String(record.fields[services.fields[0].id]), + record.fields[made.id] ?? null, + ]), + ); + const scene = Object.fromEntries(byName); + for (const row of rows) { + if (row.expected === null) { + continue; + } + if (Number(byName.get(row.name)) !== row.expected) { + throw new Error( + `the row whose case is ${JSON.stringify(row.basis)} reads ` + + `${JSON.stringify(byName.get(row.name))}, expected ${row.expected}. ` + + `The column reads ${JSON.stringify(scene)}`, + ); + } + } + return { fieldId: made.id, scene }; + }, + ); + + return { + details: { + pricesTableId: prices.id, + servicesTableId: services.id, + expression, + routing, + ...probe, + }, + }; + } finally { + for (const tableId of createdTableIds) { + try { + await permanentDeleteTable(baseId, tableId); + } catch (error) { + // Cleanup is the case's own housekeeping - the product did not fail. + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/runners/undo-cursor-after-a-failed-undo.runner.ts b/framework/runners/undo-cursor-after-a-failed-undo.runner.ts new file mode 100644 index 0000000..96d45c4 --- /dev/null +++ b/framework/runners/undo-cursor-after-a-failed-undo.runner.ts @@ -0,0 +1,249 @@ +import { FieldKeyType, FieldType } from "@teable/core"; +import { + axios, + getRecords as apiGetRecords, + CREATE_RECORD, + OPERATION_UNDO, + UPDATE_RECORD, + urlBuilder, +} from "@teable/openapi"; +import { + createField, + createTable, + permanentDeleteTable, +} from "../../../utils/init-app"; +import { bugCheckpoint } from "../checkpoint"; +import { assertServedByV2 } from "../engine"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { UndoCursorAfterAFailedUndoCaseConfig } from "../types"; + +// An undo that cannot be carried out -> press undo again -> checkpoint: the +// second press tries the same step again, and does not reach past it. +// +// Undo walks backwards through what you did. The place it has walked back to +// was moved BEFORE the step was carried out, and never moved back when the step +// failed - so a failed undo still counted as done. The next press therefore +// skipped over it and undid the step before, which is one the person had not +// asked to reverse. +// +// A step can fail to reverse for ordinary reasons. Here it is a column that does +// not allow duplicates: a value was changed away from something, somebody else's +// row has taken that value since, and putting the old one back would now +// collide. Nothing is wrong with the data or the request. +// +// What makes this bad is not the failed undo - that is honest, and the person +// can see it. It is the second press, which quietly reverses something else. In +// this fixture the step before is the row's creation, so pressing undo twice +// deletes a row nobody asked to delete. +// +// Concurrency is out of scope. The report also lists two requests undoing at +// once, two appends racing, and a crash between writes; a single client against +// one process cannot show any of those, and this case does not claim to. + +const NAME_FIELD = "Name"; +const CODE_FIELD = "Code"; + +export const runUndoCursorAfterAFailedUndoCase = async ( + bugCase: BugCaseFor<"undo-cursor-after-a-failed-undo">, + context: BugRunContext, +): Promise => { + const config: UndoCursorAfterAFailedUndoCaseConfig = bugCase.config; + const baseId = globalThis.testConfig.baseId; + const suffix = `${config.tableNamePrefix}-${context.runId}`; + // The stack is keyed by this, so everything meant to be on it must carry the + // same one - and the row that creates the collision must NOT, or it lands on + // the stack too and the case is undoing a different history. + const windowId = `e2e-lab-undo-cursor-${context.runId}`; + const otherWindowId = `${windowId}-someone-else`; + let tableId = ""; + + if (config.originalCode === config.changedCode) { + throw new Error( + "the value has to actually change, or there is nothing for undo to put back", + ); + } + + try { + const table = await createTable(baseId, { + name: suffix, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + ], + records: [], + }); + tableId = table.id; + const nameFieldId = table.fields[0].id; + + // A column that does not allow duplicates. This is what makes putting the + // old value back impossible later. + const codeField = await createField(table.id, { + name: CODE_FIELD, + type: FieldType.SingleLineText, + unique: true, + }); + + const readRows = async () => { + const response = await apiGetRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + take: 20, + }); + return { + headers: response.headers, + rows: response.data.records.map((record) => ({ + id: record.id, + name: String(record.fields[nameFieldId] ?? ""), + code: record.fields[codeField.id] ?? null, + })), + }; + }; + + // Every write goes through raw axios so it can carry the window id. The + // generated client takes no per-call headers, and a write without the id + // simply does not reach the stack - which reads as an empty stack later, + // not as an error. + const writeAs = async (onWindow: string, name: string, code: string) => { + const response = await axios.post( + urlBuilder(CREATE_RECORD, { tableId: table.id }), + { + fieldKeyType: FieldKeyType.Id, + typecast: false, + records: [{ fields: { [nameFieldId]: name, [codeField.id]: code } }], + }, + { + headers: { "x-window-id": onWindow }, + validateStatus: () => true, + }, + ); + if (response.status < 200 || response.status >= 300) { + throw new Error( + `writing ${JSON.stringify(name)} answered ${response.status}: ${JSON.stringify(response.data)}`, + ); + } + return (response.data as { records?: { id?: string }[] })?.records?.[0] + ?.id; + }; + + // The step before: the row is created. This is what a second press reaches + // if the first one is wrongly counted as done. + const rowId = await writeAs(windowId, config.rowName, config.originalCode); + if (!rowId) { + throw new Error("the row was not created"); + } + + // The step under test: its value is changed away from the original. + const changed = await axios.patch( + urlBuilder(UPDATE_RECORD, { tableId: table.id, recordId: rowId }), + { + fieldKeyType: FieldKeyType.Id, + record: { fields: { [codeField.id]: config.changedCode } }, + }, + { headers: { "x-window-id": windowId }, validateStatus: () => true }, + ); + if (changed.status < 200 || changed.status >= 300) { + throw new Error( + `changing the value answered ${changed.status}: ${JSON.stringify(changed.data)}`, + ); + } + + // Somebody else takes the value that was let go. On another window, so it + // is not on the stack this case walks back through. + await writeAs(otherWindowId, config.otherRowName, config.originalCode); + + const seeded = await readRows(); + if (seeded.rows.length !== 2) { + throw new Error( + `the table holds ${seeded.rows.length} rows, expected 2 - the fixture is not in place`, + ); + } + const routing = assertServedByV2(seeded.headers, { + operation: "GET /table/{tableId}/record", + feature: "getRecords", + }); + + const pressUndo = async () => + axios.post(urlBuilder(OPERATION_UNDO, { tableId: table.id }), undefined, { + headers: { "x-window-id": windowId }, + validateStatus: () => true, + }); + + // Fixture verification, outside the checkpoint: the first press really + // cannot be carried out. If it succeeded, there would be no failed step for + // the second press to skip and the case would be reporting on nothing. + const first = await pressUndo(); + const firstStatus = (first.data as { status?: string })?.status; + if ( + first.status >= 200 && + first.status < 300 && + firstStatus === "fulfilled" + ) { + throw new Error( + `undo put the old value back even though another row holds it - the fixture is not in place: ` + + JSON.stringify(first.data), + ); + } + const afterFirst = await readRows(); + if (afterFirst.rows.length !== 2) { + throw new Error( + `the failed undo left ${afterFirst.rows.length} rows, expected both still there: ` + + JSON.stringify(afterFirst.rows), + ); + } + + const probe = await bugCheckpoint( + "a-second-undo-after-a-failed-one-does-not-reach-past-it", + async () => { + const second = await pressUndo(); + const rows = (await readRows()).rows; + const scene = { + firstUndo: { status: first.status, body: first.data }, + secondUndo: { status: second.status, body: second.data }, + rows, + }; + + const row = rows.find((candidate) => candidate.id === rowId); + if (!row) { + throw new Error( + `pressing undo twice deleted ${JSON.stringify(config.rowName)}, which nobody asked to delete: ` + + `the second press reached past the step that could not be carried out and reversed the row's creation. ` + + JSON.stringify(scene), + ); + } + if (String(row.code) !== config.changedCode) { + throw new Error( + `${JSON.stringify(config.rowName)} reads ${JSON.stringify(row.code)}, expected ` + + `${JSON.stringify(config.changedCode)} - the step that could not be carried out is still not carried out. ` + + JSON.stringify(scene), + ); + } + return { + rows, + firstUndo: scene.firstUndo, + secondUndo: scene.secondUndo, + }; + }, + ); + + return { + details: { + tableId: table.id, + rowId, + windowId, + routing, + ...probe, + }, + }; + } finally { + if (tableId) { + try { + await permanentDeleteTable(baseId, tableId); + } catch (error) { + // Cleanup is the case's own housekeeping - the product did not fail. + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/types.ts b/framework/types.ts index 52da51c..ffca643 100644 --- a/framework/types.ts +++ b/framework/types.ts @@ -130,6 +130,9 @@ export interface BugCaseConfigByRunner { "link-rollup-unique-by-identity": LinkRollupUniqueByIdentityCaseConfig; "nested-user-array-join-create": NestedUserArrayJoinCreateCaseConfig; "share-view-unready-data-db": ShareViewUnreadyDataDbCaseConfig; + "switch-mixed-branch-storage": SwitchMixedBranchStorageCaseConfig; + "undo-cursor-after-a-failed-undo": UndoCursorAfterAFailedUndoCaseConfig; + "group-on-an-unreadable-column": GroupOnAnUnreadableColumnCaseConfig; } export type BugRunnerKind = keyof BugCaseConfigByRunner; @@ -2154,3 +2157,41 @@ export interface ShareViewUnreadyDataDbCaseConfig { // reader from looking for a real secret. encryptedUrlPlaceholder: string; } + +// A column that picks its value by case, where the branches with a case attached +// are numbers and the otherwise branch is a list of linked records. +export interface SwitchMixedBranchStorageCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + // The cases with a number behind them. Two at least: they have to agree with + // each other, or the step that merges the branches would have looked at the + // otherwise branch anyway. + numberBranches: { choice: string; column: string; value: number }[]; + // The case that falls through to the linked records. + otherwiseChoice: string; + // Rows in the linked table. At least two, so the linked column holds a list. + linkedRows: { name: string; price: number }[]; +} + +// An undo that cannot be carried out, followed by a second press. +export interface UndoCursorAfterAFailedUndoCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + rowName: string; + // The row that takes the let-go value, so putting it back would collide. It is + // written on a different window, or it lands on the stack this case walks. + otherRowName: string; + // The value the row starts with and the value it is changed to. They have to + // differ, or there is nothing for undo to put back. + originalCode: string; + changedCode: string; +} + +// A grid grouped by a column the person opening it may not read. +export interface GroupOnAnUnreadableColumnCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + // Two rows at least: a request that returns nothing must not look like one + // that returns everything. + rows: { name: string; stage: string; cost: number }[]; +} diff --git a/registry.ts b/registry.ts index 7aa516c..1abe4ad 100644 --- a/registry.ts +++ b/registry.ts @@ -38,6 +38,9 @@ import selectRollupUniqueAndCountCase from "./cases/lookup/distinct-choices-in-t import linkRollupUniqueByIdentityCase from "./cases/lookup/two-records-with-one-name-are-two-records.case"; import nestedUserArrayJoinCreateCase from "./cases/record/add-a-row-to-a-table-that-joins-people-columns.case"; import shareViewUnreadyDataDbCase from "./cases/base-share/a-share-link-whose-database-is-away.case"; +import switchMixedBranchStorageCase from "./cases/formula/a-column-that-picks-by-case.case"; +import undoCursorAfterAFailedUndoCase from "./cases/undo/a-second-undo-after-one-that-failed.case"; +import groupOnAnUnreadableColumnCase from "./cases/view/a-grid-grouped-by-a-column-you-cannot-read.case"; import sparseBatchUpdateCase from "./cases/record/a-batch-write-leaves-what-it-did-not-mention.case"; import generatedFormulaColumnCase from "./cases/record/edit-a-cell-behind-a-generated-formula.case"; import legacyGeneratedAuditColumnCase from "./cases/record/add-a-row-to-a-legacy-table.case"; @@ -186,6 +189,9 @@ const cases = [ linkRollupUniqueByIdentityCase, nestedUserArrayJoinCreateCase, shareViewUnreadyDataDbCase, + switchMixedBranchStorageCase, + undoCursorAfterAFailedUndoCase, + groupOnAnUnreadableColumnCase, sparseBatchUpdateCase, generatedFormulaColumnCase, legacyGeneratedAuditColumnCase, From 401754ffc01df69a22d3394d43d883bc969912f5 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 3 Sep 2026 17:33:30 +0800 Subject: [PATCH 20/22] Record eleven candidates examined and put down (#138) No cases in this change - only what was learned by failing to write them. Each row carries the shapes tried and what they measured, so the next pass spends its time somewhere else. Structural, and unlikely to change: T6893 and T6694 move handlers onto v2, so their pre-fix state is "v1 answered", which assertServedByV2 reads as the case being unable to run; T6895 replaces a timing-out POST with a stream, so no request both sides answer; T7105 is cost rather than behaviour; T7057 changes index coverage, not results; T7035 and T7059 are browser fixes. Inside the background runner, where nothing this harness reads can see the difference: T6711, T6904 and T6982. T6982 was written and run twice, and turned up that a table carrying provision_state = 'pending' is not out of reach here - the assumption both shapes rested on. Written and run, and still not reproduced: T7061 (twice - whether a computed plan splits into stages is the planner's decision and a case cannot ask for a split), T6925 (a third shape, which found that a formula mixing a date branch and a text branch is accepted, unflagged, and computes nothing on develop), and T6500 (twice - the field-conversion path T6925 named as untried, now tried). Two rows note fixes that are ready to write the day they ship: T7027, whose permission-filtered response still carries ids of what was filtered away, and the server-side half of 38d0e067e, which closes two comment permission paths that are currently open. The fixture both need already exists. Co-authored-by: Claude Opus 5 --- docs/triage-ledger.md | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/triage-ledger.md b/docs/triage-ledger.md index 377f361..894b784 100644 --- a/docs/triage-ledger.md +++ b/docs/triage-ledger.md @@ -117,7 +117,8 @@ The shape is gone; the runner is not kept. | `b90f13537` | T3810 | Written and run: a file uploaded into a cell reaches a watching page carrying its temporary address on the fix's parent too (run 32696695384), so both columns look the same. The single-upload path is evidently decorated already; the fix also touches the batch-create and batch-update projections, which need an attachment token to reach and were not tried. | | `384d2dad1` | T3531 | Written in two shapes and run twice, green on both columns each time: filling a two-way link in from the far side already reaches the near side, and clearing it from there already clears it. Many-to-many in run 32697213211, one-many in run 32697577570. | | `2d93fbef4` | T3303 | Written and run: a formula comparing a number column against blank already answers per row on the fix's parent, empty and zero included (run 32698802701). The half that was broken is the v1 generated-column conversion in `sql-conversion.visitor.ts`, which the lab does not exercise - the same file as the T5496 row above. | -| `7829d83c6` | T6925 | Written in two shapes and run twice, green on both columns each time: an overdue column added over existing rows computes on the fix's parent, whether written as a bare yes/no comparison (run 32705428574) or as an IF() returning two words (run 32704974280). The commit's own reproduction goes through the computed backfill a **field conversion** runs - `table.update` - not the pass that fills a newly created column, and that path was not tried. | +| `7829d83c6` | T6925 | Written in **three** shapes now. The first two were green on both columns: an overdue column added over existing rows computes on the fix's parent, whether written as a bare yes/no comparison (run 32705428574) or as an IF() returning two words (run 32704974280). The third went at the actual cause named in the commit - an `IF()` whose branches are a **date** and a word, so the column is typed as text and every branch is trimmed - and it does not express the behaviour either: on `develop` that column is created without error and then computes **nothing at all**, for the row taking the date and for the row taking the word alike, so there is no correct answer for a pre-fix column to differ from. The path the commit's own reproduction uses is still untried: the computed backfill a **field conversion** runs (`table.update`), not the pass that fills a newly created column. | +| `6ee7f96c4` | T6500 | Written and run twice, green on the fix's parent both times. This is the **field-conversion** backfill the T6925 row names as untried, so that path has now been tried: a table with a number column, rows, and a formula column reading it, then the number column converted to text inside the checkpoint, and in the second shape converted back again - which is what the report describes people doing. Neither direction reproduces `operator does not exist: double precision = text`. Something narrower decides whether the stored column and the freshly computed value end up different kinds; a formula that simply echoes the column is not it. The production reports name six computed fields across two tables in one base, so the shape may need a chain rather than one formula. | | `d36e266aa` | T6912 | Written in two shapes and run twice, green on both columns each time. A payroll chain - rate rows rolling up into an employee's highest rate, a payroll line borrowing that rate and the employee's site, a view filtered on the borrowed site - built entirely through ordinary requests opens on the fix's parent (run 32708030924). The same chain with the borrowed total's rule stripped the way the T6911 case strips it also opens (run 32709591507). The commit's own reproduction is a stored column shape neither of those two produce; what distinguishes it is not established. The already-shipped T6911 case was also run against this parent on its own and stayed green (run 32705941080). | | `6c0970d52` | T6509 | Written in two shapes and run twice, green on both columns each time: a link cell pointing at a row whose name is blank, saved a second time unchanged, comes back without an empty name and can be written straight back. First shape run 32825087075; second - the link naming the column it shows, and the unnamed row written as explicitly having no name - run 32825483798. The commit's own reproduction goes through the v2 contract's own record endpoints rather than the public ones, and what the two send differently is not established. | | `d28589d10` | T6734 | Written in two shapes and run twice, green on both columns each time: a date borrowed across a one-to-one link arrives on the fix's parent, both when the borrowing column is added next to a link that already exists (run 32836154719) and when the host's own date column is converted into a borrowed one (run 32836945426). The commit's own reproduction drains the computed queue between each step; what the two do differently is not established. | @@ -148,9 +149,17 @@ The shape is gone; the runner is not kept. | `a4e2a0a55` | T7105 | Cost, not behaviour. Field masks unioned every readable field into the SQL projection, so a request for 27 columns still selected all 235. What the request answers with does not change - the same fields come back either way - so there is nothing for a case here to tell apart. It is the product-side follow-up to a 503 incident about wide-table polling, and belongs in the performance lab if anywhere. | | `719079af1` | T6711 | The observation is a schema operation's own terminal classification - whether a leftover `table.import` is marked dead or repaired - which lives in the background runner and never reaches an HTTP response. The lab has no seam on that: the T7070 attempt established by measurement that a small fixture here computes inline and the deferred path is not reached. Same family as T6768 and T6853. | | `64b6446061` | T6904 | Same seam. A computed task planned against a table whose `provision_state` is still `pending` was dead-lettered as an obsolete plan instead of retried; the fix changes how the worker classifies that. Both the trigger (a table caught mid-provision by a background stage) and the observation (the task's failure classification) are inside the worker. Nothing a request answers differs. | +| `023b657cd` | T6982 | Written and run twice, green on the fix's parent both times. A settings change was made, the job it recorded was rewritten by `fixture-db` into the interrupted shape the commit describes (pending, `metadata_pending`, no `last_error`, old enough to claim) and `table_meta.provision_state` set to `pending`. First shape asked that the table be out of reach and then come back; second asked only that it read within two minutes. **On both `28a55d9ac` and `develop` the table read immediately anyway**, so there is nothing to tell apart. Whatever else is true, a table carrying `provision_state = 'pending'` was not out of reach in this environment - which is the assumption both shapes were built on. What the fix changes is whether the keeper repairs the job or marks it dead, and that lives in the job's own row; the commit's own e2e reads it through Prisma and drives the runner in process. Same seam as T6711 and T6904. | +| `1f33ae31c` | T7061 | Written and run twice, green on the fix's parent both times. The chain is the reported one: a conditional lookup matching on a shared reference, a formula joining what it borrows, and a matching row added on the other side inside the checkpoint, with the fixture starting empty so the arrival is the trigger. First shape was lookup plus one formula; second added three more formula steps after it, because the commit says the fault needs a stage that runs the lookup edge **while the parent plan still has leftover formula steps**. Neither reproduced. Whether a plan splits into stages at all is the planner's decision - `d74a81ab1` explicitly keeps small chains in one stage - and a case cannot ask for a split from outside. Reaching this needs a chain long or wide enough that the planner splits it, which is a size nobody has established from the public API. | +| `38d0e067e` | T7035 | The half of this commit that fixes T7035 is in the browser: the comment panel renders the union of a paged cache and a locally patched list, the delete patch was undone by the stale page putting the comment straight back, and the fix drops it from the paged cache too. Nothing the server answers changes. The same commit's T7034 half **is** covered, by `record/comment-on-a-row-your-role-lets-you-see`. | +| `38d0e067e` | T7059 | Also in the browser, and further out: Enter posted a comment while an image was still uploading, so the placeholder went out with no url. The fix holds the composer until the upload lands and makes the progress visible. There is no request to observe - the wrong one was sent on purpose. | +| `55c73a01d` | T6893 | A migration, not a repair: it moves the remaining table REST handlers onto the v2 dual path and stops them reading v1 services. The pre-fix state is therefore "v1 answered", which `assertServedByV2` treats as the case being unable to run rather than as the bug - the column that should be red is the one the harness refuses to read. Same reading as T7067. | +| `41e9ae6de` | T6694 | Same shape, same reason: duplicate reads are moved onto v2. Before it, v1 answers. | +| `60f2045cf` | T6895 | The observation lives on an endpoint this commit introduces. A single POST timed out at the gateway on large workbooks, and the fix replaces it with a stream that reports committed rows as they land - so there is no request both sides answer, and the pre-fix side answers nothing at all on the input that makes the difference. The one behavioural half that is not new - other sheets keep importing after a row cap - still reaches it through the stream. Same reading as T5268. | | `e770dd1ac` | T7057 | Index coverage, not results. Substring search documents and the trigram indexes behind them are narrowed to text-shaped fields, and an all-field search over an uncovered field falls back to the unindexed path rather than answering differently. What a search returns is the same on both sides; what changes is whether an index can serve it. Performance lab, if anywhere - same reading as T6821. | | `f70f0d508` | T6944 | Neither commit carrying this issue id fixes the path `view/a-grid-grouped-by-a-column-you-cannot-read` observes. That case is red on `12407c409` (before this commit), on `7bc91231d` (after it), and on `f44a82cf8` (after both), and turns green only at `2ae77481c` — which carries T6997. This one narrows a grouping the server resolves from the view itself; the case exercises a grouping that arrives on the request, which is what the grid actually sends. Reaching the other path needs a request carrying no grouping while the view carries one, and the record endpoint the lab reads through does not obviously offer that. | | `a4c8c3396` | T6944 | Same reading, same measurements: the case is red on `f44a82cf8`, which is after this commit. It aligns the group metadata a view reports with the permissions applied to it, which is what the settings screen reads, not what the grid's request for rows goes through. | +| `6235527b4` | T7027 | Not taken while the fix is unshipped. A folder's `children` still lists the ids of resources the caller may not read, so a permission-filtered response carries names of things the reader was filtered away from; the reported symptom is a console error and an empty folder. The issue was still at "deployed to staging" when this was written, and a `status: open` case here would be a public reproduction of an unshipped disclosure. Same call as T7065. The fixture it needs now exists (`framework/authority-matrix.ts`), so this is a reminder rather than a rejection: it is ready to write the day it ships. | ### The date comparison inside AND or OR @@ -563,3 +572,29 @@ piece of setup than any case here has needed, and it is worth building once rather than four times. T6944 is the best first customer — its symptom is the whole view returning nothing, which is unmistakable — and T7025 should wait either way, since it was still on staging when this was written. + +### One server-side half of `38d0e067e` is waiting for its release + +Alongside the two browser fixes above, that commit tightens two server checks: +deleting your own comment now needs `record|comment` as editing it does, and the +per-record comment count now needs `record|read` so the matrix row scope covers +it. Both are paths that were open and are now closed, which is a case shape this +repository can express and `framework/authority-matrix.ts` can already build. + +It is not written yet for the same reason as T7065 and T7027: the issues were +still at "deployed to staging" when this was read, and a case here would be a +public reproduction of an unenforced permission that has not shipped. Worth +writing the day it does — the count one especially, since a count that ignores +the row scope reports on rows the reader cannot open. + +### Something noticed while failing to reproduce T6925 + +On `develop`, a formula written as `IF({a checkbox}, {a date}, "a word")` is +accepted, is not marked as having an error, and computes nothing — no date on the +row whose checkbox is ticked, and not even the word on the row whose checkbox is +not. Measured while trying the third shape above, on a two-row table. + +That is not T6925 and it is not claimed to be a fault here; a formula mixing a +date branch with a text branch may simply not be a supported thing to write. But +a column that is accepted, is not flagged, and answers nothing is worth somebody +looking at, and the next person to try this shape will hit it immediately. From 577ba761f2de394eb85b7db5f7a520123fa8235d Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 3 Sep 2026 17:44:03 +0800 Subject: [PATCH 21/22] A view that says both things, and two roles that were not listened to (#139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Let a role that grants archiving actually grant it T7025: giving somebody an authority-matrix role also puts them in the base, and puts them in as a Viewer. A Viewer, by their base role alone, may not archive anything; the role says they may. Two gates read those two answers and the base role was checked first, so the answer was always the Viewer's and the role's grant never got a hearing. The refusal named neither the role that grants the action nor the thing that withholds it, while the settings screen showed everything correctly configured - because it was. Reproduced on 8fd1e28b9: 403 {"message":"not allowed to operate record|archive on tbl…", "code":"restricted_resource"} The person arrives THROUGH THE ROLE ALONE, with no invitation to the space first. That is the whole shape - it is what makes them a Viewer. Invited as an editor, their base role would permit archiving on its own, the gate that reads it first would answer correctly by accident, and the case would be green on both sides. That is a new `join` option on framework/authority-matrix.ts: how somebody gets into a space is not a detail here, it is the bug. The checkpoint also asks to archive a row the role does NOT reach, and requires a refusal. That half never goes red - being refused everything is also being refused this - and it is not there to catch the reported bug. It is there because the cheapest wrong fix is to stop checking, and that fix passes the first half. The report describes a grouped and sorted view; the fix says that was incidental and the fixture leaves it out. Also a ledger row for T7027: its fix is unshipped and its symptom is a permission-filtered response still carrying ids of what was filtered away. Same call as T7065 - the fixture it needs now exists, so it is ready to write the day it ships. Co-Authored-By: Claude Opus 5 * Write down that T6982's repair is not visible from outside Written and run twice, green on the fix's parent both times. A real settings change was made and the job it recorded was rewritten by fixture-db into the interrupted shape the commit describes - pending, metadata_pending, no last_error, old enough for the keeper to claim - with table_meta.provision_state set to pending alongside it. The first shape asked that the table go out of reach and then come back. The second dropped that and asked only that it read within two minutes. On both 28a55d9ac and develop the table read immediately either way, so neither shape tells the two sides apart. The first shape was also wrong in kind, and the ledger row says so: requiring the symptom before observing it lets a fixture confirm only itself. The second verifies what it wrote instead. Worth knowing on its own: a table carrying provision_state = 'pending' was not out of reach in this environment, which is the assumption both shapes rested on. What the fix changes - whether the keeper repairs the job or marks it dead - lives in the job's own row, and the commit's e2e reads it through Prisma while driving the runner in process. Same seam as T6711 and T6904. Co-Authored-By: Claude Opus 5 * Write down four more that cannot be asked here, and T7061 T6893 and T6694 move handlers onto v2, so their pre-fix state is "v1 answered", which assertServedByV2 reads as the case being unable to run rather than as the bug - same as T7067. T6895 replaces a POST that timed out at the gateway with a stream, so there is no request both sides answer. T7061 was written and run twice, green on the fix's parent both times: first with a conditional lookup and one formula, then with three more formula steps after it, because the commit says the fault needs a stage that runs the lookup edge while the plan still has formula steps left over. Whether a plan splits into stages at all is the planner's decision - d74a81ab1 explicitly keeps small chains in one stage - and a case cannot ask for a split from outside. Co-Authored-By: Claude Opus 5 * Let a role that permits commenting actually permit it T7034: the same fault as its sibling, one action over. Giving somebody an authority-matrix role puts them in the base as a Viewer, and a Viewer by their base role alone may not comment; the role says they may. Commenting was gated on the base role alone, so the grant never reached the write. The person can see the record, open it and read the thread, and cannot add to it - told only that the resource is restricted, which names nothing they could change, because from the settings screen nothing is wrong. Reproduced on 855da66cf: 403 {"message":"not allowed to operate record|comment on tbl…", "code":"restricted_resource"} The checkpoint reads the thread back as well as the status: a write that answered and left nothing behind is the same silence with a friendlier status. It then comments on a row the role does NOT reach and requires a refusal - that half never goes red, and it is there because the same change also had to bound commenting by the role's row conditions, which the base-role path never applied at all, so a fix that stopped checking would pass the first half. Second customer for framework/authority-matrix.ts, and the shape it shares with record/archive-a-row-your-role-says-you-may is now recorded in both docs: a matrix grant that never reaches the write because a base role answered first has been found twice, in archiving and in commenting. Co-Authored-By: Claude Opus 5 * Add a third failed shape to T6925, and what it turned up The ledger already carried T6925 with two shapes tried and green, and named the untried path: the computed backfill a field conversion runs, not the pass that fills a newly created column. This third shape went at the cause the commit names - an IF() whose branches are a date and a word, so the column is typed as text and every branch is trimmed - and it does not express the behaviour either. On develop that column is created without error and computes nothing at all: no date on the row whose checkbox is ticked, and not even the word on the row whose checkbox is not. With no correct answer on the fixed side, there is nothing for a pre-fix column to differ from. The conversion path is still untried. That last observation is written under the table as its own note. It is not T6925 and is not claimed as a fault - mixing a date branch with a text branch may simply not be supported - but a formula that is accepted, is not flagged, and answers nothing is worth somebody looking at, and the next person to try this shape will hit it in the first minute. Two things this cost, both worth remembering: check:source-commits caught that T6925 was already a ledger row before the case could ship, and an edit to the runner silently did not apply because prettier had rewrapped the line it matched on - the run afterwards was against the previous file. Co-Authored-By: Claude Opus 5 * Try the conversion path T6925 named, and write down that it is still not it T6500 is the field-conversion backfill the T6925 ledger row records as untried, so this tries it: a number column with rows and a formula reading it, converted to text inside the checkpoint, then in a second shape converted back again - which is what the report describes people doing. Green on the fix's parent both times. Neither direction reproduces "operator does not exist: double precision = text". Something narrower decides whether the stored column and the freshly computed value end up different kinds, and a formula that simply echoes the column is not it. The production reports name six computed fields across two tables in one base, so the shape may need a chain rather than one formula. Co-Authored-By: Claude Opus 5 * Read a view that says both things about a column T6597: which columns a view shows has been recorded two ways over this product's life - an older note saying whether a column is SHOWN, and the current one saying whether it is HIDDEN. Views made long enough ago carry both, and no request writes that shape any more; it is simply what is in the table. Read back, the two were passed through side by side, and what a view says about a column is checked on the way out. So the request for the table's views failed - every view at once, not one column in one of them. Reproduced on c9aef116b: 500 {"message":"Invalid View projection","domainCode":"view.invalid_projection", "issues":[{"code":"unrecognized_keys","keys":["visible"]}]} The checkpoint asks for the views at all, and then that the entry has been settled into one answer - older note gone, current one kept, order and width intact. The entry carries those two so a settled entry can be told from an emptied one: dropping it whole would satisfy "the older note is gone" while losing what the view needs. The stored notes go in with SQL because nothing writes that shape any more, which is also why a base carrying it cannot get out from the interface, and the fixture reads them back first to require the older key is really there. The feature name in the engine assertion was a guess and the assertion refused it: GET /table/{tableId}/view answers as getViews, not createTable. That is the check doing what it is for - a case pinned to the wrong feature watches code its bug does not live in. Co-Authored-By: Claude Opus 5 * Record that v1 answers this one the other way round v1 is red on every column including develop, and not for v2's reason: it does not fail the request, it answers 200 and hands back the entry exactly as stored, both notes included. So on the older engine this data never caused an outage and was never settled either. The two engines fail this case in opposite directions, which is worth saying before anyone reads the v1 column as "v1 was affected too". Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- ...chive-a-row-your-role-says-you-may.case.ts | 31 +++ .../archive-a-row-your-role-says-you-may.md | 64 +++++ ...nt-on-a-row-your-role-lets-you-see.case.ts | 32 +++ ...comment-on-a-row-your-role-lets-you-see.md | 59 +++++ ...at-says-both-things-about-a-column.case.ts | 27 ++ ...ew-that-says-both-things-about-a-column.md | 67 +++++ framework/authority-matrix.ts | 23 +- framework/runner-registry.ts | 6 + .../archive-granted-by-the-matrix.runner.ts | 226 +++++++++++++++++ .../comment-granted-by-the-matrix.runner.ts | 233 ++++++++++++++++++ ...egacy-column-visibility-metadata.runner.ts | 165 +++++++++++++ framework/types.ts | 36 +++ registry.ts | 6 + 13 files changed, 970 insertions(+), 5 deletions(-) create mode 100644 cases/record/archive-a-row-your-role-says-you-may.case.ts create mode 100644 cases/record/archive-a-row-your-role-says-you-may.md create mode 100644 cases/record/comment-on-a-row-your-role-lets-you-see.case.ts create mode 100644 cases/record/comment-on-a-row-your-role-lets-you-see.md create mode 100644 cases/view/a-view-that-says-both-things-about-a-column.case.ts create mode 100644 cases/view/a-view-that-says-both-things-about-a-column.md create mode 100644 framework/runners/archive-granted-by-the-matrix.runner.ts create mode 100644 framework/runners/comment-granted-by-the-matrix.runner.ts create mode 100644 framework/runners/legacy-column-visibility-metadata.runner.ts diff --git a/cases/record/archive-a-row-your-role-says-you-may.case.ts b/cases/record/archive-a-row-your-role-says-you-may.case.ts new file mode 100644 index 0000000..8b958ce --- /dev/null +++ b/cases/record/archive-a-row-your-role-says-you-may.case.ts @@ -0,0 +1,31 @@ +import { defineBugCase } from "../../framework/types"; + +// T7025: giving somebody an authority-matrix role also puts them in the base, +// and puts them in as a Viewer. A Viewer, by their base role alone, may not +// archive anything; the role says they may. Two gates read those two answers and +// the wrong one went first, so the answer was always the Viewer's. The person was +// refused an action their role had been given, and the refusal named neither the +// role that grants it nor the base role that withholds it - while the settings +// screen showed everything correctly configured, because it was. +export default defineBugCase({ + id: "record/archive-a-row-your-role-says-you-may", + title: "A role that grants archiving lets them archive", + runner: "archive-granted-by-the-matrix", + timeoutMs: 300_000, + skipV1: + "the case builds its own base for the authority matrix, and only the case base is unstamped - a base created inside a runner is born on v2, so v1 cannot be asked this", + bug: { + issue: "T7025", + status: "fixed", + sourceCommits: ["68b7d74f0"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-matrix-archive", + allowedTeam: "theirs", + rows: [ + { name: "row-they-may-reach", team: "theirs" }, + { name: "row-they-may-not-reach", team: "somebody-elses" }, + ], + }, +}); diff --git a/cases/record/archive-a-row-your-role-says-you-may.md b/cases/record/archive-a-row-your-role-says-you-may.md new file mode 100644 index 0000000..d18fa03 --- /dev/null +++ b/cases/record/archive-a-row-your-role-says-you-may.md @@ -0,0 +1,64 @@ +# record/archive-a-row-your-role-says-you-may + +**T7025** — fixed. On the `archive-granted-by-the-matrix` runner. + +## What the user sees + +Somebody's role grants them archiving. They select a record they are allowed to +see and archive it. They are told they do not have permission. + +The settings screen shows the role, shows archiving granted, shows the record +inside their reach. Everything looks correctly configured, because it is. The +refusal names neither the role that grants the action nor the thing that +withholds it, so there is nothing to change and nothing to look at. + +## Why + +Giving somebody a role in the authority matrix also puts them in the base, and it +puts them in as a **Viewer**. A Viewer, by their base role alone, may not archive +anything. + +Two gates read those two answers — the base role, and the matrix. The base role +was checked first, so the answer was always the Viewer's, and the role's grant +never got a hearing. + +The report describes this happening in a grouped and sorted view. That was +incidental; the fix says so, and this fixture leaves it out. + +## What the checkpoint asserts + +Two things: + +- archiving a row the role reaches **succeeds**, and the response names the row + it archived; +- archiving a row the role does **not** reach is still refused. + +The second half never goes red — being refused everything is also being refused +this — and it is not there to catch the reported bug. It is there because the +cheapest wrong fix is to stop checking, and that fix would pass the first half. + +## Why the fixture is shaped this way + +The person arrives **through the role alone**, with no invitation to the space +first. That is the whole shape: it is what makes them a Viewer. Invited as an +editor, their base role would permit archiving on its own, the gate that reads it +first would answer correctly by accident, and the case would be green on both +sides of the fix. + +Before the checkpoint, the fixture requires that the person sees exactly the rows +their role scopes them to. Seeing none, a refusal afterwards would be about +reaching the table at all rather than about archiving; seeing all of them, the +row scope is not in force and the second half of the checkpoint proves nothing. + +## The fixture behind it + +`framework/authority-matrix.ts`, shared with +`view/a-grid-grouped-by-a-column-you-cannot-read`. This case added the `join` +option to it: how somebody gets into the space is not a detail here, it is the +bug. + +## The v1 column + +Skipped, for the harness rather than the product: the case builds its own base, +and `framework/case-base.ts` unstamps only the base it manages, so a base born +inside a runner is born on v2. diff --git a/cases/record/comment-on-a-row-your-role-lets-you-see.case.ts b/cases/record/comment-on-a-row-your-role-lets-you-see.case.ts new file mode 100644 index 0000000..acc5ec0 --- /dev/null +++ b/cases/record/comment-on-a-row-your-role-lets-you-see.case.ts @@ -0,0 +1,32 @@ +import { defineBugCase } from "../../framework/types"; + +// T7034: giving somebody an authority-matrix role also puts them in the base, as +// a Viewer, and a Viewer by their base role alone may not comment. The role says +// they may. Commenting was gated on the base role alone, so the grant never +// reached the write: the person can see the record, open it and read the thread, +// and cannot add to it, told only that the resource is restricted. The same +// change also had to bound commenting by the role's row conditions, which the +// base-role path never applied. +export default defineBugCase({ + id: "record/comment-on-a-row-your-role-lets-you-see", + title: "A role that lets them comment lets them comment", + runner: "comment-granted-by-the-matrix", + timeoutMs: 300_000, + skipV1: + "the case builds its own base for the authority matrix, and only the case base is unstamped - a base created inside a runner is born on v2, so v1 cannot be asked this", + bug: { + issue: "T7034", + status: "fixed", + sourceCommits: ["38d0e067e"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-matrix-comment", + allowedTeam: "theirs", + rows: [ + { name: "row-they-may-reach", team: "theirs" }, + { name: "row-they-may-not-reach", team: "somebody-elses" }, + ], + commentText: "a-comment-their-role-permits", + }, +}); diff --git a/cases/record/comment-on-a-row-your-role-lets-you-see.md b/cases/record/comment-on-a-row-your-role-lets-you-see.md new file mode 100644 index 0000000..9ea27e5 --- /dev/null +++ b/cases/record/comment-on-a-row-your-role-lets-you-see.md @@ -0,0 +1,59 @@ +# record/comment-on-a-row-your-role-lets-you-see + +**T7034** — fixed. On the `comment-granted-by-the-matrix` runner. + +## What the user sees + +Their role says they may comment on records. They open a record they are allowed +to see, type a comment, and are told the resource is restricted and they do not +have permission. + +They can see the record. They can open it. They can read the thread. They cannot +add to it, and the message names nothing they could change — because from the +settings screen nothing is wrong. + +## Why + +Giving somebody a role in the authority matrix also puts them in the base, and +puts them in as a **Viewer**. A Viewer, by their base role alone, may not +comment. + +Commenting was gated on the base role alone, so the role's grant never reached +the write. + +## What the checkpoint asserts + +The comment is accepted **and** it is in the thread afterwards. A write that +answered and left nothing behind would be the same silence with a friendlier +status. + +Then: commenting on a row the role does **not** reach is still refused. That half +never goes red — being refused everywhere is also being refused there — and it is +not there to catch the reported bug. The same change had to bound commenting by +the role's row conditions, which the base-role path never applied at all, so a +fix that simply stopped checking would pass the first half and fail this one. + +## Why the fixture is shaped this way + +The person arrives **through the role alone**, with no invitation to the space +first. That is what makes them a Viewer, and the Viewer's base role is the thing +that was answering. Invited as an editor, their base role would permit commenting +on its own and the case would be green on both sides. + +Before the checkpoint, the fixture requires that they see exactly the rows their +role scopes them to — seeing none, a refusal afterwards would be about reaching +the table; seeing all, the row scope is not in force and the second half proves +nothing. + +## Its sibling + +`record/archive-a-row-your-role-says-you-may` (T7025) is the same fault on a +different action, fixed separately. Both stand on +`framework/authority-matrix.ts`; the shape they share — a matrix grant that never +reaches the write because a base role answered first — has now been found twice, +in archiving and in commenting. + +## The v1 column + +Skipped, for the harness rather than the product: the case builds its own base, +and `framework/case-base.ts` unstamps only the base it manages. diff --git a/cases/view/a-view-that-says-both-things-about-a-column.case.ts b/cases/view/a-view-that-says-both-things-about-a-column.case.ts new file mode 100644 index 0000000..0821e7a --- /dev/null +++ b/cases/view/a-view-that-says-both-things-about-a-column.case.ts @@ -0,0 +1,27 @@ +import { defineBugCase } from "../../framework/types"; + +// T6597: which columns a view shows has been recorded two ways over this +// product's life - an older note saying whether a column is SHOWN, and the +// current one saying whether it is HIDDEN. Views made long enough ago carry both, +// and nothing writes that shape any more; it is simply what is in the table. Read +// back, the two were passed through side by side, and what a view says about a +// column is checked on the way out - so the request for the table's views failed, +// which is every view at once rather than one column in one of them. +export default defineBugCase({ + id: "view/a-view-that-says-both-things-about-a-column", + title: "A view carrying both notes about a column still reads", + runner: "legacy-column-visibility-metadata", + timeoutMs: 180_000, + bug: { + issue: "T6597", + status: "fixed", + sourceCommits: ["bded2fd80"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-legacy-column-meta", + rowTitle: "a-row-in-the-table", + order: 1, + width: 241, + }, +}); diff --git a/cases/view/a-view-that-says-both-things-about-a-column.md b/cases/view/a-view-that-says-both-things-about-a-column.md new file mode 100644 index 0000000..0b1f176 --- /dev/null +++ b/cases/view/a-view-that-says-both-things-about-a-column.md @@ -0,0 +1,67 @@ +# view/a-view-that-says-both-things-about-a-column + +**T6597** — fixed. On the `legacy-column-visibility-metadata` runner. + +## What the user sees + +A table whose views will not load. + +The base is old enough that one of its views records a column's visibility the +way this product used to. Nothing anyone did caused it; nothing they can do +undoes it. + +## Why + +Which columns a view shows has been recorded two ways over this product's life: +an older note saying whether a column is **shown**, and the current one saying +whether it is **hidden**. Views made long enough ago carry both, and no request +writes that shape any more. + +Read back, the two were passed through side by side. What a view says about a +column is checked on the way out, and an entry carrying a note nobody expects any +more does not pass that check — so the request for the table's views failed. That +is every view at once, not one column in one of them. + +## What the checkpoint asserts + +That the views come back at all, and that the entry has been settled into one +answer: the older note gone, the current one kept, and the rest of the entry +intact. + +Both halves. A response that came back carrying both notes would hand the +contradiction to whatever reads it next, which is where this started. + +## Why the fixture is written with SQL + +Nothing writes that shape any more — which is exactly why the bases carrying it +cannot get out of it from the interface. `fixture-db` writes the stored notes; +the observation stays on the public view-list endpoint. + +Before the checkpoint, the fixture reads the stored notes back and requires the +older key to actually be there. Without it there is nothing unexpected to read +and the case would report on nothing. + +The entry also carries an order and a width, so a "settled" entry can be told +from an emptied one: dropping the whole entry would satisfy "the older note is +gone" without keeping anything the view needs. + +## The v1 column + +v1 is red on every column of the acceptance matrix, `develop` included, and for a +different reason from v2's. It does not fail the request — it answers 200 and +hands back the entry exactly as stored: + +``` +{"order":1,"visible":true,"hidden":false,"width":241} +``` + +So on the older engine this data never caused an outage and was never settled +either; both notes are still passed through today. The two engines fail this case +in opposite directions, which is worth knowing before anyone reads the v1 column +as "v1 was affected too". + +Reported, not enforced — the v1 column is a reference and never gates a run. It +is the fourth case here to find a v2-only fix leaving the older engine as it was; +the others are `lookup/distinct-choices-in-the-order-they-appear`, +`lookup/two-records-with-one-name-are-two-records` and +`formula/a-column-that-picks-by-case`. diff --git a/framework/authority-matrix.ts b/framework/authority-matrix.ts index e09b453..6f3d7fa 100644 --- a/framework/authority-matrix.ts +++ b/framework/authority-matrix.ts @@ -68,6 +68,8 @@ export interface RestrictedTableRule { type SignedInClient = Awaited>; export interface RestrictedPerson { + // How they got in, carried through so a case can say so in its report. + join: "editor" | "throughTheRoleAlone"; // Signed in as the restricted person. Their requests are the observation. axios: SignedInClient; userId: string; @@ -97,6 +99,13 @@ export const withRestrictedPerson = async (options: { namePrefix: string; runId: string; buildTables: (baseId: string) => Promise; + // How the person gets into the space. "editor" invites them first, which is + // the ordinary shape: somebody already working in the space, further limited + // by a role. "throughTheRoleAlone" invites nobody - being given the role is + // what joins them, and it joins them as a Viewer. That difference is not + // cosmetic: a Viewer's base role withholds things a role may grant, and bugs + // have lived exactly in the gap between the two. + join?: "editor" | "throughTheRoleAlone"; }): Promise => { if (isInsideCheckpoint()) { throw new Error( @@ -132,13 +141,16 @@ export const withRestrictedPerson = async (options: { }); const userId = (await personAxios.get(USER_ME)).data.id as string; - // Into the space as an ordinary editor. Not an administrator of the matrix: + // Into the space as an ordinary editor, unless the case wants the person to + // arrive through the role alone. Never as an administrator of the matrix: // an administrator is exempt from it, and this whole fixture exists to // produce somebody who is not. - await axios.post(urlBuilder(EMAIL_SPACE_INVITATION, { spaceId }), { - role: Role.Editor, - emails: [RESTRICTED_EMAIL], - }); + if ((options.join ?? "editor") === "editor") { + await axios.post(urlBuilder(EMAIL_SPACE_INVITATION, { spaceId }), { + role: Role.Editor, + emails: [RESTRICTED_EMAIL], + }); + } await axios.patch(urlBuilder(UPDATE_AUTHORITY_MATRIX_STATUS, { baseId }), { enabled: true, @@ -182,6 +194,7 @@ export const withRestrictedPerson = async (options: { return { axios: personAxios, + join: options.join ?? "editor", userId, email: RESTRICTED_EMAIL, spaceId, diff --git a/framework/runner-registry.ts b/framework/runner-registry.ts index 5575bb9..f34d75b 100644 --- a/framework/runner-registry.ts +++ b/framework/runner-registry.ts @@ -123,6 +123,9 @@ import { runShareViewUnreadyDataDbCase } from "./runners/share-view-unready-data import { runSwitchMixedBranchStorageCase } from "./runners/switch-mixed-branch-storage.runner"; import { runUndoCursorAfterAFailedUndoCase } from "./runners/undo-cursor-after-a-failed-undo.runner"; import { runGroupOnAnUnreadableColumnCase } from "./runners/group-on-an-unreadable-column.runner"; +import { runArchiveGrantedByTheMatrixCase } from "./runners/archive-granted-by-the-matrix.runner"; +import { runCommentGrantedByTheMatrixCase } from "./runners/comment-granted-by-the-matrix.runner"; +import { runLegacyColumnVisibilityMetadataCase } from "./runners/legacy-column-visibility-metadata.runner"; import { runLookupOfRollupCreateCase } from "./runners/lookup-of-rollup-create.runner"; import type { BugCase, @@ -267,6 +270,9 @@ const runners: { [K in BugRunnerKind]: RunnerFn } = { "switch-mixed-branch-storage": runSwitchMixedBranchStorageCase, "undo-cursor-after-a-failed-undo": runUndoCursorAfterAFailedUndoCase, "group-on-an-unreadable-column": runGroupOnAnUnreadableColumnCase, + "archive-granted-by-the-matrix": runArchiveGrantedByTheMatrixCase, + "comment-granted-by-the-matrix": runCommentGrantedByTheMatrixCase, + "legacy-column-visibility-metadata": runLegacyColumnVisibilityMetadataCase, }; export const executeRegisteredRunner = ( diff --git a/framework/runners/archive-granted-by-the-matrix.runner.ts b/framework/runners/archive-granted-by-the-matrix.runner.ts new file mode 100644 index 0000000..0ceaa4d --- /dev/null +++ b/framework/runners/archive-granted-by-the-matrix.runner.ts @@ -0,0 +1,226 @@ +import { FieldKeyType, FieldType } from "@teable/core"; +import { ARCHIVE_RECORDS, GET_RECORDS_URL, urlBuilder } from "@teable/openapi"; +import { createTable, permanentDeleteTable } from "../../../utils/init-app"; +import { withRestrictedPerson } from "../authority-matrix"; +import { bugCheckpoint } from "../checkpoint"; +import { assertServedByV2 } from "../engine"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { ArchiveGrantedByTheMatrixCaseConfig } from "../types"; + +// Somebody whose role grants archiving -> archive a row the role lets them see +// -> checkpoint: the row is archived. +// +// Giving somebody a role in the authority matrix also puts them in the base, and +// it puts them in as a Viewer. A Viewer, by their base role alone, may not +// archive anything. The role says they may. +// +// Two gates read those two answers, and the wrong one went first: the base role +// was checked before the matrix, so the answer was always the Viewer's. The +// person was refused an action their role had been given, and the refusal said +// only that they lack permission - naming neither the role that grants it nor +// the base role that withholds it. From the settings screen everything looks +// correctly configured, because it is. +// +// The report describes this in a grouped and sorted view. That was incidental; +// the fix says so and this fixture leaves it out. +// +// Archiving OUTSIDE the role's rows is asked too, and must still be refused. +// That half never goes red - being refused everything is also being refused +// this - but it is what separates the fix from simply opening the gate. + +const NAME_FIELD = "Name"; +const TEAM_FIELD = "Team"; + +export const runArchiveGrantedByTheMatrixCase = async ( + bugCase: BugCaseFor<"archive-granted-by-the-matrix">, + context: BugRunContext, +): Promise => { + const config: ArchiveGrantedByTheMatrixCaseConfig = bugCase.config; + const suffix = `${config.tableNamePrefix}-${context.runId}`; + let person: Awaited> | undefined; + let tableId = ""; + const idByName = new Map(); + + const inScope = config.rows.filter((row) => row.team === config.allowedTeam); + const outOfScope = config.rows.filter( + (row) => row.team !== config.allowedTeam, + ); + if (inScope.length === 0 || outOfScope.length === 0) { + throw new Error( + "the fixture needs a row the role lets them see and one it does not - without the second, " + + "'refused outside the role' cannot be told from 'refused everywhere'", + ); + } + + try { + person = await withRestrictedPerson({ + namePrefix: config.tableNamePrefix, + runId: context.runId, + // Through the role alone, so they arrive as a Viewer. Invited as an + // editor first, their base role would allow archiving on its own and the + // gate that read it would answer correctly by accident. + join: "throughTheRoleAlone", + buildTables: async (baseId) => { + const table = await createTable(baseId, { + name: suffix, + fields: [ + { + name: NAME_FIELD, + type: FieldType.SingleLineText, + isPrimary: true, + }, + { name: TEAM_FIELD, type: FieldType.SingleLineText }, + ], + records: config.rows.map((row) => ({ + fields: { [NAME_FIELD]: row.name, [TEAM_FIELD]: row.team }, + })), + }); + tableId = table.id; + for (const record of table.records as { + id: string; + fields: Record; + }[]) { + idByName.set(String(record.fields[NAME_FIELD]), record.id); + } + const teamFieldId = table.fields.find( + (field: { name: string }) => field.name === TEAM_FIELD, + )?.id as string; + + // Nothing withheld: archiving is granted. Rows are scoped, so there is + // something the role does not reach. + return [ + { + tableId: table.id, + disabledActions: [], + recordFilter: { + conjunction: "and", + filterSet: [ + { + fieldId: teamFieldId, + operator: "is", + value: config.allowedTeam, + }, + ], + }, + }, + ]; + }, + }); + + const archiveAs = async (recordIds: string[]) => + person!.axios.post( + urlBuilder(ARCHIVE_RECORDS, { tableId }), + { recordIds }, + { validateStatus: () => true }, + ); + + // Fixture verification, outside the checkpoint: the person sees exactly the + // rows their role lets them see. If they saw none, a refusal below would be + // about reaching the table at all rather than about archiving. + const visible = await person.axios.get( + urlBuilder(GET_RECORDS_URL, { tableId }), + { + params: { fieldKeyType: FieldKeyType.Name, take: config.rows.length }, + validateStatus: () => true, + }, + ); + if (visible.status !== 200) { + throw new Error( + `the restricted person cannot read the table (${visible.status}): ${JSON.stringify(visible.data)}`, + ); + } + const visibleNames = ( + (visible.data as { records?: { fields: Record }[] }) + ?.records ?? [] + ).map((record) => String(record.fields[NAME_FIELD])); + if (visibleNames.length !== inScope.length) { + throw new Error( + `the restricted person sees ${JSON.stringify(visibleNames)}, expected the ${inScope.length} row(s) ` + + `their role scopes them to - the fixture is not in place`, + ); + } + const routing = assertServedByV2(visible.headers, { + operation: "GET /table/{tableId}/record", + feature: "getRecords", + }); + + const probe = await bugCheckpoint( + "a-role-that-grants-archiving-lets-them-archive", + async () => { + const target = idByName.get(inScope[0].name) as string; + const granted = await archiveAs([target]); + const body = + typeof granted.data === "string" + ? granted.data + : JSON.stringify(granted.data ?? ""); + + if (granted.status < 200 || granted.status >= 300) { + throw new Error( + `archiving a row the role lets them see answered ${granted.status}: ${body}. ` + + `They arrived in the base through the role, which joins them as a Viewer, and a Viewer ` + + `may not archive - so the answer is the base role's rather than the role's`, + ); + } + const archived = + (granted.data as { archivedRecordIds?: string[] }) + ?.archivedRecordIds ?? []; + if (!archived.includes(target)) { + throw new Error( + `archiving answered ${granted.status} but reported ${JSON.stringify(archived)}: ${body}`, + ); + } + + // The other half: what the role does not reach is still refused. This + // never goes red - being refused everything is also being refused this - + // and it is what separates the fix from opening the gate. + const beyond = await archiveAs([ + idByName.get(outOfScope[0].name) as string, + ]); + if (beyond.status >= 200 && beyond.status < 300) { + throw new Error( + `archiving a row OUTSIDE the role's rows was allowed (${beyond.status}): ` + + (typeof beyond.data === "string" + ? beyond.data + : JSON.stringify(beyond.data)), + ); + } + return { archived, refusedBeyond: beyond.status }; + }, + ); + + return { + details: { + baseId: person.baseId, + tableId, + join: person.join, + roleId: person.roleId, + routing, + ...probe, + }, + }; + } finally { + if (tableId && person) { + try { + await permanentDeleteTable(person.baseId, tableId); + } catch (error) { + // Cleanup is the case's own housekeeping - the product did not fail. + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + if (person) { + try { + await person.cleanUp(); + } catch (error) { + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (space ${person.spaceId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/runners/comment-granted-by-the-matrix.runner.ts b/framework/runners/comment-granted-by-the-matrix.runner.ts new file mode 100644 index 0000000..2af194f --- /dev/null +++ b/framework/runners/comment-granted-by-the-matrix.runner.ts @@ -0,0 +1,233 @@ +import { FieldKeyType, FieldType } from "@teable/core"; +import { + CREATE_COMMENT, + GET_COMMENT_LIST, + GET_RECORDS_URL, + urlBuilder, +} from "@teable/openapi"; +import { createTable, permanentDeleteTable } from "../../../utils/init-app"; +import { withRestrictedPerson } from "../authority-matrix"; +import { bugCheckpoint } from "../checkpoint"; +import { assertServedByV2 } from "../engine"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { CommentGrantedByTheMatrixCaseConfig } from "../types"; + +// Somebody whose role lets them comment -> leave a comment on a row the role +// lets them see -> checkpoint: the comment is saved, and it is in the thread. +// +// Giving somebody a role in the authority matrix also puts them in the base, as +// a Viewer. A Viewer, by their base role alone, may not comment. The role says +// they may. +// +// Commenting was gated on the base role alone, so the role's grant never +// reached the write and every comment was refused as a restricted resource. The +// person can see the record, can open it, can read the thread - and cannot add +// to it, with a message about permissions that names nothing they can change. +// +// Commenting on a row the role does NOT reach is asked too, and must still be +// refused. That half never goes red - being refused everywhere is also being +// refused there - and it is what separates the fix from removing the gate: the +// same change also had to bound commenting by the role's row conditions, which +// the base-role path never applied. + +const NAME_FIELD = "Name"; +const TEAM_FIELD = "Team"; + +export const runCommentGrantedByTheMatrixCase = async ( + bugCase: BugCaseFor<"comment-granted-by-the-matrix">, + context: BugRunContext, +): Promise => { + const config: CommentGrantedByTheMatrixCaseConfig = bugCase.config; + const suffix = `${config.tableNamePrefix}-${context.runId}`; + let person: Awaited> | undefined; + let tableId = ""; + const idByName = new Map(); + + const inScope = config.rows.filter((row) => row.team === config.allowedTeam); + const outOfScope = config.rows.filter( + (row) => row.team !== config.allowedTeam, + ); + if (inScope.length === 0 || outOfScope.length === 0) { + throw new Error( + "the fixture needs a row the role lets them see and one it does not - without the second, " + + "'refused outside the role' cannot be told from 'refused everywhere'", + ); + } + + const comment = (value: string) => ({ + content: [{ type: "p", children: [{ type: "span", value }] }], + }); + + try { + person = await withRestrictedPerson({ + namePrefix: config.tableNamePrefix, + runId: context.runId, + // Through the role alone, so they arrive as a Viewer - the base role that + // withholds commenting is the whole point. + join: "throughTheRoleAlone", + buildTables: async (baseId) => { + const table = await createTable(baseId, { + name: suffix, + fields: [ + { + name: NAME_FIELD, + type: FieldType.SingleLineText, + isPrimary: true, + }, + { name: TEAM_FIELD, type: FieldType.SingleLineText }, + ], + records: config.rows.map((row) => ({ + fields: { [NAME_FIELD]: row.name, [TEAM_FIELD]: row.team }, + })), + }); + tableId = table.id; + for (const record of table.records as { + id: string; + fields: Record; + }[]) { + idByName.set(String(record.fields[NAME_FIELD]), record.id); + } + const teamFieldId = table.fields.find( + (field: { name: string }) => field.name === TEAM_FIELD, + )?.id as string; + + return [ + { + tableId: table.id, + disabledActions: [], + recordFilter: { + conjunction: "and", + filterSet: [ + { + fieldId: teamFieldId, + operator: "is", + value: config.allowedTeam, + }, + ], + }, + }, + ]; + }, + }); + + const commentAs = async (recordId: string, value: string) => + person!.axios.post( + urlBuilder(CREATE_COMMENT, { tableId, recordId }), + comment(value), + { validateStatus: () => true }, + ); + + // Fixture verification, outside the checkpoint: the person sees exactly the + // rows their role scopes them to. Seeing none, a refusal below would be + // about reaching the table rather than about commenting. + const visible = await person.axios.get( + urlBuilder(GET_RECORDS_URL, { tableId }), + { + params: { fieldKeyType: FieldKeyType.Name, take: config.rows.length }, + validateStatus: () => true, + }, + ); + if (visible.status !== 200) { + throw new Error( + `the restricted person cannot read the table (${visible.status}): ${JSON.stringify(visible.data)}`, + ); + } + const visibleNames = ( + (visible.data as { records?: { fields: Record }[] }) + ?.records ?? [] + ).map((record) => String(record.fields[NAME_FIELD])); + if (visibleNames.length !== inScope.length) { + throw new Error( + `the restricted person sees ${JSON.stringify(visibleNames)}, expected the ${inScope.length} row(s) ` + + "their role scopes them to - the fixture is not in place", + ); + } + const routing = assertServedByV2(visible.headers, { + operation: "GET /table/{tableId}/record", + feature: "getRecords", + }); + + const probe = await bugCheckpoint( + "a-role-that-lets-them-comment-lets-them-comment", + async () => { + const target = idByName.get(inScope[0].name) as string; + const posted = await commentAs(target, config.commentText); + const body = + typeof posted.data === "string" + ? posted.data + : JSON.stringify(posted.data ?? ""); + + if (posted.status < 200 || posted.status >= 300) { + throw new Error( + `commenting on a row the role lets them see answered ${posted.status}: ${body}. ` + + "They arrived in the base through the role, which joins them as a Viewer, and a Viewer " + + "may not comment - so the answer is the base role's rather than the role's", + ); + } + + // And it is in the thread. A write that answered and left nothing is the + // same silence with a friendlier status. + const thread = await person!.axios.get( + urlBuilder(GET_COMMENT_LIST, { tableId, recordId: target }), + { validateStatus: () => true }, + ); + const said = JSON.stringify(thread.data ?? ""); + if (!said.includes(config.commentText)) { + throw new Error( + `the comment was accepted but the thread does not carry it: ${said}`, + ); + } + + // The other half: what the role does not reach is still refused. + const beyond = await commentAs( + idByName.get(outOfScope[0].name) as string, + config.commentText, + ); + if (beyond.status >= 200 && beyond.status < 300) { + throw new Error( + `commenting on a row OUTSIDE the role's rows was allowed (${beyond.status}): ` + + (typeof beyond.data === "string" + ? beyond.data + : JSON.stringify(beyond.data)), + ); + } + return { posted: posted.status, refusedBeyond: beyond.status }; + }, + ); + + return { + details: { + baseId: person.baseId, + tableId, + join: person.join, + roleId: person.roleId, + routing, + ...probe, + }, + }; + } finally { + if (tableId && person) { + try { + await permanentDeleteTable(person.baseId, tableId); + } catch (error) { + // Cleanup is the case's own housekeeping - the product did not fail. + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + if (person) { + try { + await person.cleanUp(); + } catch (error) { + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (space ${person.spaceId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/runners/legacy-column-visibility-metadata.runner.ts b/framework/runners/legacy-column-visibility-metadata.runner.ts new file mode 100644 index 0000000..cf6e7b7 --- /dev/null +++ b/framework/runners/legacy-column-visibility-metadata.runner.ts @@ -0,0 +1,165 @@ +import { FieldType } from "@teable/core"; +import { axios, GET_VIEW_LIST, urlBuilder } from "@teable/openapi"; +import { createTable, permanentDeleteTable } from "../../../utils/init-app"; +import { bugCheckpoint } from "../checkpoint"; +import { assertServedByV2 } from "../engine"; +import { fixtureDb } from "../fixture-db"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { LegacyColumnVisibilityMetadataCaseConfig } from "../types"; + +// A view whose stored notes about a column say both "shown" and "not hidden" -> +// open the table -> checkpoint: the view comes back, and says one thing about +// that column. +// +// Which columns a view shows has been recorded two ways over the life of this +// product: an older note saying whether a column is SHOWN, and the current one +// saying whether it is HIDDEN. Views made long enough ago carry both, and no +// request writes that shape any more - it is simply what is in the table. +// +// Read back, the two were passed through side by side. What a view says about a +// column is checked on the way out, and an entry carrying a note nobody expects +// any more does not pass that check: the request for the table's views failed, +// which is every view at once rather than one column in one of them. +// +// So the checkpoint asks for the views at all, and then asks that the entry has +// been settled into one answer - the older note gone, the current one kept. A +// request that came back carrying both would be the same contradiction handed +// to whatever reads it next. + +const NAME_FIELD = "Name"; +const OTHER_FIELD = "Other"; + +export const runLegacyColumnVisibilityMetadataCase = async ( + bugCase: BugCaseFor<"legacy-column-visibility-metadata">, + context: BugRunContext, +): Promise => { + const config: LegacyColumnVisibilityMetadataCaseConfig = bugCase.config; + const baseId = globalThis.testConfig.baseId; + const suffix = `${config.tableNamePrefix}-${context.runId}`; + let tableId = ""; + + try { + const table = await createTable(baseId, { + name: suffix, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + { name: OTHER_FIELD, type: FieldType.SingleLineText }, + ], + records: [{ fields: { [NAME_FIELD]: config.rowTitle } }], + }); + tableId = table.id; + const viewId = table.views?.[0]?.id; + const columnId = table.fields.find( + (field: { name: string }) => field.name === OTHER_FIELD, + )?.id as string; + if (!viewId || !columnId) { + throw new Error("the table has no view or no second column"); + } + + const readViews = async () => + axios.get(urlBuilder(GET_VIEW_LIST, { tableId }), { + validateStatus: () => true, + }); + + const before = await readViews(); + if (before.status !== 200) { + throw new Error( + `the views do not read before anything is done to them (${before.status}): ${JSON.stringify(before.data)}`, + ); + } + const routing = assertServedByV2(before.headers, { + operation: "GET /table/{tableId}/view", + feature: "getViews", + }); + + // What a view made long enough ago carries: the older note about whether + // the column is shown, beside the current one about whether it is hidden. + // Written with SQL because nothing writes that shape any more. + const db = fixtureDb(context.app); + const legacy = { + [columnId]: { + order: config.order, + visible: true, + hidden: false, + width: config.width, + }, + }; + await db.execute( + `UPDATE "view" SET "column_meta" = $1 WHERE "id" = $2`, + JSON.stringify(legacy), + viewId, + ); + + // Fixture verification, outside the checkpoint: the older note really is in + // the table. Without it there is nothing unexpected to read back and the + // case would report on nothing. + const stored = await db.query<{ columnMeta: string }[]>( + `SELECT "column_meta" AS "columnMeta" FROM "view" WHERE "id" = $1`, + viewId, + ); + if (!String(stored[0]?.columnMeta ?? "").includes('"visible"')) { + throw new Error( + `the stored notes do not carry the older key: ${stored[0]?.columnMeta} - the fixture is not in place`, + ); + } + + const probe = await bugCheckpoint( + "a-view-carrying-both-notes-about-a-column-still-reads", + async () => { + const listed = await readViews(); + const body = + typeof listed.data === "string" + ? listed.data + : JSON.stringify(listed.data ?? ""); + if (listed.status !== 200) { + throw new Error( + `asking for the table's views answered ${listed.status} - that is every view at once, ` + + `not one column in one of them: ${body}`, + ); + } + + const view = ( + listed.data as { id: string; columnMeta?: Record }[] + ).find((candidate) => candidate.id === viewId); + const entry = view?.columnMeta?.[columnId] as + | Record + | undefined; + if (!entry) { + throw new Error( + `the view came back with nothing about the column: ${body}`, + ); + } + if ("visible" in entry) { + throw new Error( + `the view still says both things about the column: ${JSON.stringify(entry)} - ` + + "whatever reads this next is handed the contradiction", + ); + } + if (entry.hidden !== false) { + throw new Error( + `the view came back saying the column is ${JSON.stringify(entry.hidden)}, expected false: ` + + JSON.stringify(entry), + ); + } + return { entry }; + }, + ); + + return { + details: { tableId, viewId, columnId, routing, ...probe }, + }; + } finally { + if (tableId) { + try { + await permanentDeleteTable(baseId, tableId); + } catch (error) { + // Cleanup is the case's own housekeeping - the product did not fail. + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/types.ts b/framework/types.ts index ffca643..d89f75e 100644 --- a/framework/types.ts +++ b/framework/types.ts @@ -133,6 +133,9 @@ export interface BugCaseConfigByRunner { "switch-mixed-branch-storage": SwitchMixedBranchStorageCaseConfig; "undo-cursor-after-a-failed-undo": UndoCursorAfterAFailedUndoCaseConfig; "group-on-an-unreadable-column": GroupOnAnUnreadableColumnCaseConfig; + "archive-granted-by-the-matrix": ArchiveGrantedByTheMatrixCaseConfig; + "comment-granted-by-the-matrix": CommentGrantedByTheMatrixCaseConfig; + "legacy-column-visibility-metadata": LegacyColumnVisibilityMetadataCaseConfig; } export type BugRunnerKind = keyof BugCaseConfigByRunner; @@ -2195,3 +2198,36 @@ export interface GroupOnAnUnreadableColumnCaseConfig { // that returns everything. rows: { name: string; stage: string; cost: number }[]; } + +// Somebody whose authority-matrix role grants archiving, arriving in the base +// through that role and therefore as a Viewer. +export interface ArchiveGrantedByTheMatrixCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + // Rows, split by team. The role reaches one team's rows and not the other's; + // the runner refuses a fixture missing either side. + rows: { name: string; team: string }[]; + allowedTeam: string; +} + +// Somebody whose authority-matrix role lets them comment, arriving in the base +// through that role and therefore as a Viewer. +export interface CommentGrantedByTheMatrixCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + rows: { name: string; team: string }[]; + allowedTeam: string; + commentText: string; +} + +// A view whose stored notes about a column carry both the older key and the +// current one - what a view made long enough ago has been carrying all along. +export interface LegacyColumnVisibilityMetadataCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + rowTitle: string; + // Written into the stored notes beside the two visibility keys, so the case + // can tell a settled entry from an emptied one. + order: number; + width: number; +} diff --git a/registry.ts b/registry.ts index 1abe4ad..e3c9874 100644 --- a/registry.ts +++ b/registry.ts @@ -41,6 +41,9 @@ import shareViewUnreadyDataDbCase from "./cases/base-share/a-share-link-whose-da import switchMixedBranchStorageCase from "./cases/formula/a-column-that-picks-by-case.case"; import undoCursorAfterAFailedUndoCase from "./cases/undo/a-second-undo-after-one-that-failed.case"; import groupOnAnUnreadableColumnCase from "./cases/view/a-grid-grouped-by-a-column-you-cannot-read.case"; +import archiveGrantedByTheMatrixCase from "./cases/record/archive-a-row-your-role-says-you-may.case"; +import commentGrantedByTheMatrixCase from "./cases/record/comment-on-a-row-your-role-lets-you-see.case"; +import legacyColumnVisibilityMetadataCase from "./cases/view/a-view-that-says-both-things-about-a-column.case"; import sparseBatchUpdateCase from "./cases/record/a-batch-write-leaves-what-it-did-not-mention.case"; import generatedFormulaColumnCase from "./cases/record/edit-a-cell-behind-a-generated-formula.case"; import legacyGeneratedAuditColumnCase from "./cases/record/add-a-row-to-a-legacy-table.case"; @@ -192,6 +195,9 @@ const cases = [ switchMixedBranchStorageCase, undoCursorAfterAFailedUndoCase, groupOnAnUnreadableColumnCase, + archiveGrantedByTheMatrixCase, + commentGrantedByTheMatrixCase, + legacyColumnVisibilityMetadataCase, sparseBatchUpdateCase, generatedFormulaColumnCase, legacyGeneratedAuditColumnCase, From 0838494346e5d565ce90f8a0d442c584e8682621 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 3 Sep 2026 18:10:06 +0800 Subject: [PATCH 22/22] An unplaced column and a doubled address (#140) * Give a column its place when the stored notes give it none T6545: the notes a view keeps about each column say where it sits and how wide it is, and views made long enough ago have entries with a width and no position at all - a shape nothing writes any more. Nothing filled the gap in on the way out, and what a view says about a column is checked there, so the request for the table's views failed: every view at once, not one column in one of them. Reproduced on 66919acae with Invalid View projection and expected "number". Second shape on the runner the T6597 case introduced, differing only in the `legacy` config value: same fixture, same observation, two shapes of old data. The two were fixed three days apart, this one first, and what differs is which part of the entry the check rejects - an unrecognised key there, a missing number here. The doc first said this one "comes back looking fine and is wrong". The run said otherwise and the doc now says what was measured. The checkpoint name lost its reference to the other shape's keys for the same reason. The width is asserted as well as the position: filling the gap by replacing the entry would satisfy "it has a position" while throwing away the only thing the old notes actually said. The position is compared against the column's own index rather than a number written into the case, so nothing here encodes a particular default. Co-Authored-By: Claude Opus 5 * Give a shared form's picture one address instead of two T6604: where a form's picture lives is stored as a short path, and the address a browser can fetch is worked out from it when the form is read. A shared form is read through two layers and both worked it out, the second over the first's answer, so what came back was one address with another on the front of it. The person who opens the link sees a broken picture while the same form inside the product looks right - inside, it is read through one layer only. Measured on d961a7a03: http://127.0.0.1:PORT/api/attachments/read/public/http:/127.0.0.1:PORT/api/ attachments/read/public/form/e2e-lab-cover-image The first version of this case counted "http://" and passed on both sides. Joining one address onto another leaves the inner one with a SINGLE slash, so that count found one address in a string plainly holding two. It counts the scheme now, and the doc says why - the wrong reading was invisible until the value itself was printed. Addresses are counted rather than compared against an expected string: what the storage prefix is depends on how the instance is deployed, and pinning it would make this case about configuration. Ending at the stored path is what says the address still points at the right thing. The stored value has to be a short path and the runner refuses an address - an address is exactly what the fix passes through untouched. Before the checkpoint the form is read from inside the product as the control, where the same view is read through one layer and comes back right. Co-Authored-By: Claude Opus 5 * Write down two more that change cost, not answers T6890 replaces a leading (expr IS NULL) sort key with Postgres's own NULLS FIRST/LAST; the commit and the issue both say the order stays identical to v1's, and what changes is the length of the sort list and whether an index can serve it. T6669 stops producing a search hit index by re-running the whole v1 pipeline for ids it then throws away; the index itself is the same on both sides. Neither has anything for a case to tell apart. Co-Authored-By: Claude Opus 5 * Record that v1 leaves this entry unplaced too Same reading as the sibling case: v1 answers 200 and hands the entry back exactly as stored, so on the older engine this data never caused an outage and was never filled in either. Red on every column of the acceptance matrix, develop included. Reported, not enforced. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .../base-share/a-shared-forms-picture.case.ts | 26 +++ cases/base-share/a-shared-forms-picture.md | 59 ++++++ .../a-column-the-view-does-not-place.case.ts | 26 +++ .../view/a-column-the-view-does-not-place.md | 71 +++++++ ...at-says-both-things-about-a-column.case.ts | 1 + docs/triage-ledger.md | 2 + framework/runner-registry.ts | 2 + ...egacy-column-visibility-metadata.runner.ts | 82 +++++--- .../runners/shared-form-cover-url.runner.ts | 181 ++++++++++++++++++ framework/types.ts | 23 ++- registry.ts | 4 + 11 files changed, 450 insertions(+), 27 deletions(-) create mode 100644 cases/base-share/a-shared-forms-picture.case.ts create mode 100644 cases/base-share/a-shared-forms-picture.md create mode 100644 cases/view/a-column-the-view-does-not-place.case.ts create mode 100644 cases/view/a-column-the-view-does-not-place.md create mode 100644 framework/runners/shared-form-cover-url.runner.ts diff --git a/cases/base-share/a-shared-forms-picture.case.ts b/cases/base-share/a-shared-forms-picture.case.ts new file mode 100644 index 0000000..0399bff --- /dev/null +++ b/cases/base-share/a-shared-forms-picture.case.ts @@ -0,0 +1,26 @@ +import { defineBugCase } from "../../framework/types"; + +// T6604: where a form's picture lives is stored as a short path, and the address +// a browser can fetch is worked out from it when the form is read. A shared form +// is read through two layers, and both worked it out - the second over the +// first's answer - so what came back was one address with another stuck on the +// front of it, which fetches nothing. The person who opens the link sees a form +// with a broken picture while the same form inside the product looks right, +// because inside it is read through one layer only. +export default defineBugCase({ + id: "base-share/a-shared-forms-picture", + title: "A shared form's picture has one address, not two", + runner: "shared-form-cover-url", + timeoutMs: 180_000, + bug: { + issue: "T6604", + status: "fixed", + sourceCommits: ["573e0b70e"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-form-cover", + rowTitle: "a-row-behind-the-form", + storedPath: "form/e2e-lab-cover-image", + }, +}); diff --git a/cases/base-share/a-shared-forms-picture.md b/cases/base-share/a-shared-forms-picture.md new file mode 100644 index 0000000..b74a27e --- /dev/null +++ b/cases/base-share/a-shared-forms-picture.md @@ -0,0 +1,59 @@ +# base-share/a-shared-forms-picture + +**T6604** — fixed. On the `shared-form-cover-url` runner. + +## What the user sees + +A shared form with a broken picture. The same form inside the product looks +right, so nothing is wrong with the picture or the form — only with what the +share link hands out. + +The person seeing it is usually outside the company, filling the form in, and has +nothing to compare against. + +## Why + +Where a form's picture lives is stored as a short path. The address a browser can +fetch is worked out from that path when the form is read. + +The shared form is read through two layers, and both worked it out — the second +over the first's answer. What came back was one address with another stuck on the +front of it. Inside the product the same view is read through one layer, which is +why it looks right there. + +## What the checkpoint asserts + +That the address was built **once**: the cover and the logo each carry exactly one +`http(s)://`, and each ends at the stored path. + +Counting addresses rather than comparing against an expected string is +deliberate. What the storage prefix is depends on how the instance is deployed, +and pinning it would make this case about configuration instead of about the +doubling. Ending at the stored path is what says the address still points at the +right thing. + +What is counted is the **scheme**, not `http://`. Joining one address onto +another leaves the inner one with a single slash — measured on the fix's parent, +the value is + +``` +http://127.0.0.1:PORT/api/attachments/read/public/http:/127.0.0.1:PORT/api/attachments/read/public/form/… +``` + +— so looking for the double slash finds one address in a string that plainly +holds two. The first version of this case did exactly that and passed on both +sides. + +Both the cover and the logo are set and both are read, because the fix covers +both and either could regress alone. + +## Why the fixture is shaped this way + +The stored value must be a **short path**, and the runner refuses an address: an +address is exactly what the fix passes through untouched, so a fixture holding one +would be green on both sides. + +Before the checkpoint, the form is read from **inside** the product and its +picture must carry the stored path. That is the control — it says the form and the +stored value are fine, so a doubled address afterwards is about the share path and +not about the fixture. diff --git a/cases/view/a-column-the-view-does-not-place.case.ts b/cases/view/a-column-the-view-does-not-place.case.ts new file mode 100644 index 0000000..50e4630 --- /dev/null +++ b/cases/view/a-column-the-view-does-not-place.case.ts @@ -0,0 +1,26 @@ +import { defineBugCase } from "../../framework/types"; + +// T6545: the notes a view keeps about a column say where it sits and how wide it +// is. Views made long enough ago have entries with a width and no position at all +// - a shape nothing writes any more. Read back, the missing position was passed +// through as missing, so whatever draws the view was handed a column with no +// place among the others. +export default defineBugCase({ + id: "view/a-column-the-view-does-not-place", + title: "A column whose stored notes give it no place still gets one", + runner: "legacy-column-visibility-metadata", + timeoutMs: 180_000, + bug: { + issue: "T6545", + status: "fixed", + sourceCommits: ["fd32044e4"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-legacy-column-place", + rowTitle: "a-row-in-the-table", + legacy: "noPosition", + order: 1, + width: 241, + }, +}); diff --git a/cases/view/a-column-the-view-does-not-place.md b/cases/view/a-column-the-view-does-not-place.md new file mode 100644 index 0000000..57b023f --- /dev/null +++ b/cases/view/a-column-the-view-does-not-place.md @@ -0,0 +1,71 @@ +# view/a-column-the-view-does-not-place + +**T6545** — fixed. On the `legacy-column-visibility-metadata` runner, +`legacy: "noPosition"`. + +## What the user sees + +A table whose views will not load. + +The notes a view keeps about each of its columns say where the column sits and +how wide it is. In views made long enough ago, some entries carry a width and +**no position at all** — a shape nothing writes any more, and not something +anyone did. + +## Why + +Nothing filled the gap in on the way out, and what a view says about a column is +checked there. An entry with no position does not pass that check, so the request +for the table's views fails — every view at once, not one column in one of them. + +Measured on `66919acae`: + +``` +500 {"message":"Invalid View projection","domainCode":"view.invalid_projection", + "issues":[{"code":"invalid_union","errors":[[{"expected":"number", …}]]}]} +``` + +## What the checkpoint asserts + +The views come back at all, then that the entry carries the column's place among +the columns, and the width the stored notes did carry. + +The first of those is what catches this on a pre-fix commit — the request never +returns an entry to inspect. The other two are what says the gap was filled in +rather than papered over. + +The width matters as much as the position here: filling the gap by replacing the +entry would satisfy "it has a position" while throwing away the only thing the +old notes actually said. + +The position is compared against the column's own index among the table's fields +rather than a number written into the case, so the case does not encode a +particular default — only that a column gets the place it should have. + +## Its sibling on this runner + +`view/a-view-that-says-both-things-about-a-column` (T6597) is the other shape of +old notes: an entry carrying both the older visibility key and the current one. +Both shapes fail the same way — the view list refuses with `Invalid View +projection` — and they were fixed three days apart, this one first. What differs +is which part of the entry the check rejects: an unrecognised key there, a +missing number here. + +Same fixture, same observation, two shapes of old data. That is why they share a +runner and differ only in the `legacy` config value. + +## Why the fixture is written with SQL + +Nothing writes either shape any more, which is also why a base carrying one +cannot get out of it from the interface. Before the checkpoint the fixture reads +the stored notes back and requires that they really are the shape this case is +about — for this one, that there is no `order` in them at all. + +## The v1 column + +v1 is red on every column of the acceptance matrix, `develop` included, and for +the same reason as its sibling's: v1 does not fail the request, it answers 200 and +hands the entry back exactly as stored — here, still without a position. + +So on the older engine this data never caused an outage and was never filled in +either. Reported, not enforced. diff --git a/cases/view/a-view-that-says-both-things-about-a-column.case.ts b/cases/view/a-view-that-says-both-things-about-a-column.case.ts index 0821e7a..d1899b6 100644 --- a/cases/view/a-view-that-says-both-things-about-a-column.case.ts +++ b/cases/view/a-view-that-says-both-things-about-a-column.case.ts @@ -21,6 +21,7 @@ export default defineBugCase({ baseId: "seed-base", tableNamePrefix: "e2e-lab-legacy-column-meta", rowTitle: "a-row-in-the-table", + legacy: "bothVisibilityNotes", order: 1, width: 241, }, diff --git a/docs/triage-ledger.md b/docs/triage-ledger.md index 894b784..4c823dc 100644 --- a/docs/triage-ledger.md +++ b/docs/triage-ledger.md @@ -119,6 +119,8 @@ The shape is gone; the runner is not kept. | `2d93fbef4` | T3303 | Written and run: a formula comparing a number column against blank already answers per row on the fix's parent, empty and zero included (run 32698802701). The half that was broken is the v1 generated-column conversion in `sql-conversion.visitor.ts`, which the lab does not exercise - the same file as the T5496 row above. | | `7829d83c6` | T6925 | Written in **three** shapes now. The first two were green on both columns: an overdue column added over existing rows computes on the fix's parent, whether written as a bare yes/no comparison (run 32705428574) or as an IF() returning two words (run 32704974280). The third went at the actual cause named in the commit - an `IF()` whose branches are a **date** and a word, so the column is typed as text and every branch is trimmed - and it does not express the behaviour either: on `develop` that column is created without error and then computes **nothing at all**, for the row taking the date and for the row taking the word alike, so there is no correct answer for a pre-fix column to differ from. The path the commit's own reproduction uses is still untried: the computed backfill a **field conversion** runs (`table.update`), not the pass that fills a newly created column. | | `6ee7f96c4` | T6500 | Written and run twice, green on the fix's parent both times. This is the **field-conversion** backfill the T6925 row names as untried, so that path has now been tried: a table with a number column, rows, and a formula column reading it, then the number column converted to text inside the checkpoint, and in the second shape converted back again - which is what the report describes people doing. Neither direction reproduces `operator does not exist: double precision = text`. Something narrower decides whether the stored column and the freshly computed value end up different kinds; a formula that simply echoes the column is not it. The production reports name six computed fields across two tables in one base, so the shape may need a chain rather than one formula. | +| `7b969558a` | T6890 | Sorting, and deliberately not a change to it: nullable sorts stop emitting a leading `(expr IS NULL)` key and use Postgres's own NULLS FIRST/LAST instead. The commit and the issue both say the point is that the order stays identical to v1's - what changes is the length of the sort list and whether an index can serve it. There is nothing for a case to tell apart. Performance lab, if anywhere. | +| `1ea5d6c40` | T6669 | A read that stopped doing work twice: producing `extra.searchHitIndex` re-ran the whole v1 pipeline - bootstrapping v1 metadata, rebuilding the search WHERE, rescanning for ids that were then thrown away - to get a per-page hit index the v2 read could compute from the ids it already had. The hit index itself is the same on both sides. | | `d36e266aa` | T6912 | Written in two shapes and run twice, green on both columns each time. A payroll chain - rate rows rolling up into an employee's highest rate, a payroll line borrowing that rate and the employee's site, a view filtered on the borrowed site - built entirely through ordinary requests opens on the fix's parent (run 32708030924). The same chain with the borrowed total's rule stripped the way the T6911 case strips it also opens (run 32709591507). The commit's own reproduction is a stored column shape neither of those two produce; what distinguishes it is not established. The already-shipped T6911 case was also run against this parent on its own and stayed green (run 32705941080). | | `6c0970d52` | T6509 | Written in two shapes and run twice, green on both columns each time: a link cell pointing at a row whose name is blank, saved a second time unchanged, comes back without an empty name and can be written straight back. First shape run 32825087075; second - the link naming the column it shows, and the unnamed row written as explicitly having no name - run 32825483798. The commit's own reproduction goes through the v2 contract's own record endpoints rather than the public ones, and what the two send differently is not established. | | `d28589d10` | T6734 | Written in two shapes and run twice, green on both columns each time: a date borrowed across a one-to-one link arrives on the fix's parent, both when the borrowing column is added next to a link that already exists (run 32836154719) and when the host's own date column is converted into a borrowed one (run 32836945426). The commit's own reproduction drains the computed queue between each step; what the two do differently is not established. | diff --git a/framework/runner-registry.ts b/framework/runner-registry.ts index f34d75b..de954bb 100644 --- a/framework/runner-registry.ts +++ b/framework/runner-registry.ts @@ -126,6 +126,7 @@ import { runGroupOnAnUnreadableColumnCase } from "./runners/group-on-an-unreadab import { runArchiveGrantedByTheMatrixCase } from "./runners/archive-granted-by-the-matrix.runner"; import { runCommentGrantedByTheMatrixCase } from "./runners/comment-granted-by-the-matrix.runner"; import { runLegacyColumnVisibilityMetadataCase } from "./runners/legacy-column-visibility-metadata.runner"; +import { runSharedFormCoverUrlCase } from "./runners/shared-form-cover-url.runner"; import { runLookupOfRollupCreateCase } from "./runners/lookup-of-rollup-create.runner"; import type { BugCase, @@ -273,6 +274,7 @@ const runners: { [K in BugRunnerKind]: RunnerFn } = { "archive-granted-by-the-matrix": runArchiveGrantedByTheMatrixCase, "comment-granted-by-the-matrix": runCommentGrantedByTheMatrixCase, "legacy-column-visibility-metadata": runLegacyColumnVisibilityMetadataCase, + "shared-form-cover-url": runSharedFormCoverUrlCase, }; export const executeRegisteredRunner = ( diff --git a/framework/runners/legacy-column-visibility-metadata.runner.ts b/framework/runners/legacy-column-visibility-metadata.runner.ts index cf6e7b7..40ceaf8 100644 --- a/framework/runners/legacy-column-visibility-metadata.runner.ts +++ b/framework/runners/legacy-column-visibility-metadata.runner.ts @@ -7,9 +7,12 @@ import { fixtureDb } from "../fixture-db"; import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; import type { LegacyColumnVisibilityMetadataCaseConfig } from "../types"; -// A view whose stored notes about a column say both "shown" and "not hidden" -> -// open the table -> checkpoint: the view comes back, and says one thing about -// that column. +// A view whose stored notes about a column are of a shape nothing writes any +// more -> open the table -> checkpoint: the view comes back, and the entry is +// settled. +// +// Two shapes, on one runner because the fixture and the observation are the +// same: write notes no request produces, then read the views. // // Which columns a view shows has been recorded two ways over the life of this // product: an older note saying whether a column is SHOWN, and the current one @@ -72,18 +75,24 @@ export const runLegacyColumnVisibilityMetadataCase = async ( feature: "getViews", }); - // What a view made long enough ago carries: the older note about whether - // the column is shown, beside the current one about whether it is hidden. - // Written with SQL because nothing writes that shape any more. + // What a view made long enough ago carries. Written with SQL because + // nothing writes either shape any more. const db = fixtureDb(context.app); - const legacy = { - [columnId]: { - order: config.order, - visible: true, - hidden: false, - width: config.width, - }, - }; + const columnIndex = table.fields.findIndex( + (field: { id: string }) => field.id === columnId, + ); + const legacy = + config.legacy === "bothVisibilityNotes" + ? { + [columnId]: { + order: config.order, + visible: true, + hidden: false, + width: config.width, + }, + } + : // No position at all - the other shape old views carry. + { [columnId]: { width: config.width } }; await db.execute( `UPDATE "view" SET "column_meta" = $1 WHERE "id" = $2`, JSON.stringify(legacy), @@ -97,14 +106,19 @@ export const runLegacyColumnVisibilityMetadataCase = async ( `SELECT "column_meta" AS "columnMeta" FROM "view" WHERE "id" = $1`, viewId, ); - if (!String(stored[0]?.columnMeta ?? "").includes('"visible"')) { + const storedText = String(stored[0]?.columnMeta ?? ""); + const missingMark = + config.legacy === "bothVisibilityNotes" ? '"visible"' : '"order"'; + const present = storedText.includes(missingMark); + if (config.legacy === "bothVisibilityNotes" ? !present : present) { throw new Error( - `the stored notes do not carry the older key: ${stored[0]?.columnMeta} - the fixture is not in place`, + `the stored notes are not the shape this case is about (${config.legacy}): ${storedText} - ` + + "the fixture is not in place", ); } const probe = await bugCheckpoint( - "a-view-carrying-both-notes-about-a-column-still-reads", + "a-view-with-old-notes-about-a-column-still-reads", async () => { const listed = await readViews(); const body = @@ -129,16 +143,34 @@ export const runLegacyColumnVisibilityMetadataCase = async ( `the view came back with nothing about the column: ${body}`, ); } - if ("visible" in entry) { - throw new Error( - `the view still says both things about the column: ${JSON.stringify(entry)} - ` + - "whatever reads this next is handed the contradiction", - ); + if (config.legacy === "bothVisibilityNotes") { + if ("visible" in entry) { + throw new Error( + `the view still says both things about the column: ${JSON.stringify(entry)} - ` + + "whatever reads this next is handed the contradiction", + ); + } + if (entry.hidden !== false) { + throw new Error( + `the view came back saying the column is ${JSON.stringify(entry.hidden)}, expected false: ` + + JSON.stringify(entry), + ); + } + } else { + // The entry has to come back with a position. Where a column sits is + // not optional to whatever draws the view, and the stored notes do + // not say. + if (entry.order !== columnIndex) { + throw new Error( + `the view came back with the column at ${JSON.stringify(entry.order)}, expected ` + + `${columnIndex} - its place among the columns: ${JSON.stringify(entry)}`, + ); + } } - if (entry.hidden !== false) { + // Either way, what the notes did carry survives. + if (entry.width !== config.width) { throw new Error( - `the view came back saying the column is ${JSON.stringify(entry.hidden)}, expected false: ` + - JSON.stringify(entry), + `the width was ${JSON.stringify(entry.width)}, expected ${config.width}: ${JSON.stringify(entry)}`, ); } return { entry }; diff --git a/framework/runners/shared-form-cover-url.runner.ts b/framework/runners/shared-form-cover-url.runner.ts new file mode 100644 index 0000000..df3d6b0 --- /dev/null +++ b/framework/runners/shared-form-cover-url.runner.ts @@ -0,0 +1,181 @@ +import { ViewType } from "@teable/core"; +import { + axios, + createView as apiCreateView, + enableShareView as apiEnableShareView, + SHARE_VIEW_GET, + urlBuilder, + VIEW_OPTION, +} from "@teable/openapi"; +import { createTable, permanentDeleteTable } from "../../../utils/init-app"; +import { bugCheckpoint } from "../checkpoint"; +import { assertServedByV2 } from "../engine"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { SharedFormCoverUrlCaseConfig } from "../types"; + +// A form with a picture at the top, shared -> open the link -> checkpoint: the +// address of the picture is an address. +// +// Where a form's picture lives is stored as a short path, and the address a +// browser can fetch is worked out from it when the form is read. The shared +// form is read through two layers, and both worked it out - the second one over +// the first one's answer. What came back was one address with another stuck on +// the front of it, which fetches nothing. +// +// So the person who opens the shared link sees a form with a broken picture, +// while the same form inside the product looks right - it is only read through +// one layer there. Nothing is wrong with the picture or the form. +// +// The address is checked for being built once, not for being any particular +// string: what the storage prefix is depends on how the instance is deployed, +// and pinning it would make this case about configuration. + +const NAME_FIELD = "Name"; + +export const runSharedFormCoverUrlCase = async ( + bugCase: BugCaseFor<"shared-form-cover-url">, + context: BugRunContext, +): Promise => { + const config: SharedFormCoverUrlCaseConfig = bugCase.config; + const baseId = globalThis.testConfig.baseId; + const suffix = `${config.tableNamePrefix}-${context.runId}`; + let tableId = ""; + + if (/^https?:\/\//i.test(config.storedPath)) { + throw new Error( + "the stored path must be a short path, not an address - an address is what the fix passes through untouched", + ); + } + + try { + const table = await createTable(baseId, { + name: suffix, + fields: [{ name: NAME_FIELD, type: "singleLineText" as never }], + records: [{ fields: { [NAME_FIELD]: config.rowTitle } }], + }); + tableId = table.id; + + const form = await apiCreateView(tableId, { + name: `${suffix}-form`, + type: ViewType.Form, + }); + const viewId = form.data.id; + + // Where the picture lives, as it is stored: a short path. + const options = await axios.patch( + urlBuilder(VIEW_OPTION, { tableId, viewId }), + { options: { coverUrl: config.storedPath, logoUrl: config.storedPath } }, + { validateStatus: () => true }, + ); + if (options.status < 200 || options.status >= 300) { + throw new Error( + `setting the form's picture answered ${options.status}: ${JSON.stringify(options.data)}`, + ); + } + + const shared = await apiEnableShareView({ tableId, viewId }); + const shareId = shared.data?.shareId; + if (!shareId) { + throw new Error( + `sharing the form returned no link: ${JSON.stringify(shared.data)}`, + ); + } + const routing = assertServedByV2(shared.headers, { + operation: "POST /table/{tableId}/view/{viewId}/enable-share", + feature: "enableViewShare", + }); + + // Fixture verification, outside the checkpoint: read from inside the + // product, the address is built once. That is the control - it says the + // picture and the form are fine, and it is the same view the shared link + // serves. + const inside = await axios.get( + urlBuilder("/table/{tableId}/view/{viewId}", { tableId, viewId }), + { validateStatus: () => true }, + ); + const insideCover = ( + inside.data as { options?: { coverUrl?: string } } | undefined + )?.options?.coverUrl; + if (!insideCover || !insideCover.includes(config.storedPath)) { + throw new Error( + `inside the product the form's picture reads ${JSON.stringify(insideCover)}, ` + + `which does not carry ${JSON.stringify(config.storedPath)} - the fixture is not in place`, + ); + } + + const probe = await bugCheckpoint( + "a-shared-forms-picture-has-one-address", + async () => { + const opened = await axios.get( + urlBuilder(SHARE_VIEW_GET, { shareId }), + { validateStatus: () => true }, + ); + const body = + typeof opened.data === "string" + ? opened.data + : JSON.stringify(opened.data ?? ""); + if (opened.status !== 200) { + throw new Error( + `opening the shared form answered ${opened.status}: ${body}`, + ); + } + + const view = ( + opened.data as { + view?: { options?: { coverUrl?: string; logoUrl?: string } }; + } + )?.view; + const seen = { + coverUrl: view?.options?.coverUrl, + logoUrl: view?.options?.logoUrl, + }; + + for (const [which, value] of Object.entries(seen)) { + if (!value) { + throw new Error( + `the shared form carries no ${which}: ${JSON.stringify(seen)}`, + ); + } + // Built once. Two addresses in one string is the whole fault, and + // counting them says so without pinning what the address is. + // + // The SCHEME is what gets counted, not "http://": joining one address + // onto another leaves the inner one with a single slash - the measured + // value is ".../public/http:/127.0.0.1/..." - so looking for the + // double slash finds one address in a string that plainly holds two. + const addresses = value.match(/https?:/gi)?.length ?? 0; + if (addresses !== 1) { + throw new Error( + `the shared form's ${which} carries ${addresses} addresses, expected one: ` + + `${JSON.stringify(value)} - the address was worked out twice, once over the other`, + ); + } + if (!value.endsWith(config.storedPath)) { + throw new Error( + `the shared form's ${which} does not end at the stored path ` + + `${JSON.stringify(config.storedPath)}: ${JSON.stringify(value)}`, + ); + } + } + return { seen }; + }, + ); + + return { + details: { tableId, viewId, shareId, routing, ...probe }, + }; + } finally { + if (tableId) { + try { + await permanentDeleteTable(baseId, tableId); + } catch (error) { + // Cleanup is the case's own housekeeping - the product did not fail. + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/types.ts b/framework/types.ts index d89f75e..662fdbf 100644 --- a/framework/types.ts +++ b/framework/types.ts @@ -136,6 +136,7 @@ export interface BugCaseConfigByRunner { "archive-granted-by-the-matrix": ArchiveGrantedByTheMatrixCaseConfig; "comment-granted-by-the-matrix": CommentGrantedByTheMatrixCaseConfig; "legacy-column-visibility-metadata": LegacyColumnVisibilityMetadataCaseConfig; + "shared-form-cover-url": SharedFormCoverUrlCaseConfig; } export type BugRunnerKind = keyof BugCaseConfigByRunner; @@ -2226,8 +2227,26 @@ export interface LegacyColumnVisibilityMetadataCaseConfig { baseId: "seed-base"; tableNamePrefix: string; rowTitle: string; - // Written into the stored notes beside the two visibility keys, so the case - // can tell a settled entry from an emptied one. + // Which shape of old notes to write. "bothVisibilityNotes" carries the older + // key beside the current one; "noPosition" carries no order at all. Both are + // shapes nothing writes any more, and each broke differently. + legacy: "bothVisibilityNotes" | "noPosition"; + // Written into the stored notes for the "bothVisibilityNotes" shape only. The + // other shape is defined by having no order. order: number; + // Written into the stored notes either way, so a settled entry can be told + // from an emptied one. width: number; } + +// A shared form whose picture is stored as a short path and read through two +// layers, each of which works the address out. +export interface SharedFormCoverUrlCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + rowTitle: string; + // Where the picture lives, as it is stored. Must be a short path: an address + // is what the fix passes through untouched, so an address here would make the + // case green either way. The runner refuses one. + storedPath: string; +} diff --git a/registry.ts b/registry.ts index e3c9874..3ce4775 100644 --- a/registry.ts +++ b/registry.ts @@ -44,6 +44,8 @@ import groupOnAnUnreadableColumnCase from "./cases/view/a-grid-grouped-by-a-colu import archiveGrantedByTheMatrixCase from "./cases/record/archive-a-row-your-role-says-you-may.case"; import commentGrantedByTheMatrixCase from "./cases/record/comment-on-a-row-your-role-lets-you-see.case"; import legacyColumnVisibilityMetadataCase from "./cases/view/a-view-that-says-both-things-about-a-column.case"; +import legacyColumnNoPositionCase from "./cases/view/a-column-the-view-does-not-place.case"; +import sharedFormCoverUrlCase from "./cases/base-share/a-shared-forms-picture.case"; import sparseBatchUpdateCase from "./cases/record/a-batch-write-leaves-what-it-did-not-mention.case"; import generatedFormulaColumnCase from "./cases/record/edit-a-cell-behind-a-generated-formula.case"; import legacyGeneratedAuditColumnCase from "./cases/record/add-a-row-to-a-legacy-table.case"; @@ -198,6 +200,8 @@ const cases = [ archiveGrantedByTheMatrixCase, commentGrantedByTheMatrixCase, legacyColumnVisibilityMetadataCase, + legacyColumnNoPositionCase, + sharedFormCoverUrlCase, sparseBatchUpdateCase, generatedFormulaColumnCase, legacyGeneratedAuditColumnCase,