diff --git a/.agents/skills/verify-dms-database/journeys/parts/schema-source-rows.mjs b/.agents/skills/verify-dms-database/journeys/parts/schema-source-rows.mjs index 7b77dd3..24418bf 100644 --- a/.agents/skills/verify-dms-database/journeys/parts/schema-source-rows.mjs +++ b/.agents/skills/verify-dms-database/journeys/parts/schema-source-rows.mjs @@ -82,7 +82,7 @@ export const inference = { check("columns no decorator names are typed by their rows", JSON.stringify(kinds(fieldsOf("shop", "customers"))) === JSON.stringify(["_id:string", "email:string", "company:string", "credit_limit:number", "vip:boolean"]), kinds(fieldsOf("shop", "customers"))), check("an array is typed by its elements", JSON.stringify(pick(fieldsOf("shop", "products"), "tags")) === JSON.stringify({ kind: "array", element: { kind: "string" } }), pick(fieldsOf("shop", "products"), "tags")), check("an array of no element stays untyped", JSON.stringify(pick(fieldsOf("demo", "posts"), "tag_ids")) === JSON.stringify({ kind: "array" }), pick(fieldsOf("demo", "posts"), "tag_ids")), - check("tables of named instances are sampled from the first instance holding rows", Object.keys(fieldsOf("shop", "order_items") ?? {}).join() === "_id,order_id,product_id,quantity,unit_price", fieldsOf("shop", "order_items")), + check("the columns of a table whose rows are all in named instances are sampled from them", Object.keys(fieldsOf("shop", "order_items") ?? {}).join() === "_id,order_id,product_id,quantity,unit_price", fieldsOf("shop", "order_items")), check("the instance tag of the store is not a column", ![...Object.keys(orders ?? {}), ...Object.keys(fieldsOf("shop", "customers") ?? {})].includes("_instance"), Object.keys(orders ?? {})), check("a column named constructor is a column of its own", JSON.stringify(Object.keys(members ?? {})) === JSON.stringify(["_id", "constructor", "note"]), Object.keys(members ?? {})), check("a schema reports its instances and stats", JSON.stringify(pick(shop, "instances")) === '["eu","us"]' && JSON.stringify(pick(shop, "stats")) === JSON.stringify({ tableCount: 4, elementCount: 6, instanceCount: 3 }), pick(shop, "stats")), diff --git a/src/service/introspect.ts b/src/service/introspect.ts index afeb465..fbe28cd 100644 --- a/src/service/introspect.ts +++ b/src/service/introspect.ts @@ -172,7 +172,7 @@ function inferFieldType(value: unknown): FieldDescriptor | undefined { async function sampleRows( schema: Schema, tableName: string, - instance?: string, + instance?: InstanceId, ): Promise { try { const rows: unknown[] = await schema @@ -187,9 +187,9 @@ async function sampleRows( /** * The rows to infer a table's columns from: the default instance's, or, when - * it holds none, the first named instance holding some. A schema whose data - * lives in named instances (one per region, per tenant) otherwise shows only - * its decorated columns. + * it holds none, rows read across every instance in one query. A schema whose + * data lives in named instances (one per region, per tenant) otherwise shows + * only its decorated columns. */ async function sampleAnyInstance( schema: Schema, @@ -197,11 +197,7 @@ async function sampleAnyInstance( ): Promise { const rows = await sampleRows(schema, tableName); if (rows.length > 0) return rows; - for (const instance of await listSchemaInstances(schema)) { - const named = await sampleRows(schema, tableName, instance); - if (named.length > 0) return named; - } - return []; + return sampleRows(schema, tableName, CROSS_INSTANCE); } /** diff --git a/src/test/schemas-page.test.ts b/src/test/schemas-page.test.ts index aa60746..3e3db17 100644 --- a/src/test/schemas-page.test.ts +++ b/src/test/schemas-page.test.ts @@ -33,8 +33,18 @@ class SchemasPageInvoice extends Table { declare coupon_id: string | null; } -/** Keeps the decorated class referenced: the decorator registers it. */ -const TABLES = [SchemasPageInvoice]; +/** A schema whose rows all live in a named instance, one per tenant. */ +const TENANTED = "schemas-page-tenanted"; +const TENANT = "tenant-1"; + +@RegisterTable("credit_notes", TENANTED) +class SchemasPageCreditNote extends Table { + declare _id: string; + declare amount: number; +} + +/** Keeps the decorated classes referenced: the decorator registers them. */ +const TABLES = [SchemasPageInvoice, SchemasPageCreditNote]; function shop() { const schema = Schema.get<{ invoices: SchemasPageInvoice }>(SCHEMA); @@ -43,7 +53,7 @@ function shop() { } async function seed(): Promise { - assert.equal(TABLES.length, 1); + assert.equal(TABLES.length, 2); await RegisterSchema(SCHEMA); await shop() .instance() @@ -157,6 +167,40 @@ describe("[integration] Schemas page", () => { }); }); +describe("[integration] Schemas page on a tenanted schema", () => { + function tenanted() { + const schema = Schema.get<{ credit_notes: SchemasPageCreditNote }>( + TENANTED, + ); + assert.ok(schema, "the tenanted schema is registered"); + return schema; + } + + before(async () => { + await RegisterSchema(TENANTED); + await tenanted() + .createInstance(TENANT) + .run() + .catch(() => undefined); + await tenanted() + .instance(TENANT) + .table("credit_notes") + .insert([{ _id: "cn-1", amount: 5 }]) + .run(); + }); + after(() => tenanted().destroyInstance(TENANT).run()); + + it("types the columns of a table whose rows are all in a named instance", async () => { + const summary = (await listSchemaSummaries()).find( + (item) => item.id === TENANTED, + ); + const fields = summary?.tables.find( + (table) => table.name === "credit_notes", + )?.fields; + assert.deepEqual(fields?.amount, { kind: "number" }); + }); +}); + describe("Schemas filter bar parameters", () => { it("reads the default instance, every instance and named ones", () => { assert.deepEqual(decodeInstanceParam(undefined), { kind: "default" });