Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/driver-emits-spec-introspection-shape.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
---
"@objectstack/driver-sql": minor
"@objectstack/objectql": minor
---

fix(driver-sql): `introspectSchema()` emits the spec introspection contract — `primaryKey`, `dialect`, `introspectedAt` (#10676, #10998)

**BREAKING** change to the value `SqlDriver.introspectSchema()` returns, shipped
as `minor` under the repo's launch-window convention for breaking changes.

`packages/spec/src/contracts/schema-diff-service.ts` declares one introspection
contract. The driver declared a second one beside it and, separately, so did
`packages/objectql/src/util.ts`. The three agreed on the idea and disagreed on
the vocabulary: the driver spelled a column's primary-key membership
`isPrimary`, the spec spells it `primaryKey`; the spec declares `dialect` and a
REQUIRED `introspectedAt` that the driver's schema type never mentioned and
`introspectSchema()` therefore never emitted. Nothing was type-unsound — each
side compiled against its own declaration and the value crossed between them
with no compiler in the middle.

Measured on a live in-memory SQLite database before this change: the id column
of a `primary key (id)` table came back carrying `isPrimary: true` with no
`primaryKey` key at all, and `Object.keys()` of the schema was `["tables"]`.
Two consequences, both silent:

- `ExternalDatasourceService.generateObjectDraft` reads `col.primaryKey`, so
every federated object drafted from a real remote table lost the remote
primary key — the addressing key for the federated table, dropped by the
codegen meant to produce it (#10676).
- type mapping ran with `dialect: undefined` across the whole federation path,
making every per-dialect alias in `suggestFieldTypeForSqlType` unreachable
there, and `refreshCatalog` persisted `dialect: undefined` into the
`external_catalog` record Studio's schema browser and the boot gate read
back (#10998).

Maintainer ruling, 2026-08-22 (live session, 「同意所有」 item 9 =
驱动侧对齐 spec 契约): `packages/spec` is the one contract and the driver
aligns to it.

What the driver now returns: every column carries the boolean `primaryKey`, the
schema carries `dialect` and `introspectedAt`, and the retired `isPrimary`
member is gone rather than emitted alongside — one spelling, so no consumer can
key off the wrong one again. `dialect` is the driver's canonical dialect name
(`sqlite`, `postgres`, `mysql`, `unknown`), which is the vocabulary the only
in-tree consumer keys its alias tables on; `introspectedAt` is an ISO 8601
instant stamped before the reads begin.

`IntrospectedColumn`, `IntrospectedTable` and `IntrospectedSchema` in both
`@objectstack/driver-sql` and `@objectstack/objectql` are now derived from the
spec contract instead of re-declared, so a key added there fails their `tsc`
until the producer emits it. Two divergences are kept explicitly: `defaultValue`
stays `unknown` at the SQL layer because Knex reports `null`, and `indexes` is
omitted rather than emitted empty because this driver does not introspect
indexes and an empty array would tell a schema differ that a table has none.

TypeScript consumers of the removed member are told by the compiler, precisely
and at every site: `Property 'isPrimary' does not exist on type
'IntrospectedColumn'`.

<!-- adr-0087: not-required (runtime-interface-only packages/drivers/driver-sql/src/sql-driver.ts#IntrospectedColumn, packages/drivers/driver-sql/src/sql-driver.ts#IntrospectedSchema, packages/objectql/src/util.ts#IntrospectedColumn, packages/objectql/src/util.ts#IntrospectedSchema) these are published runtime TypeScript interfaces describing a driver's introspection RESULT — not a metadata surface. There is no Zod schema, no `packages/spec` declaration of the old spelling, and no stored representation of it, so `objectstack migrate meta` has nothing to rewrite; the channel that reaches every affected consumer is the compiler. -->
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,8 +11,8 @@
* the first member of a composite key and silently dropped the rest.
*
* Both output signals were wrong together and for the same reason:
* `introspectSchema` derives `col.isPrimary` FROM `primaryKeys`
* (`if (primaryKeys.includes(col.name)) col.isPrimary = true`), so a consumer
* `introspectSchema` derives `col.primaryKey` FROM `primaryKeys`
* (`if (primaryKeys.includes(col.name)) col.primaryKey = true`), so a consumer
* could not recover the missing member by cross-checking the two. Both are
* asserted here.
*
Expand DownExpand Up@@ -80,7 +80,7 @@ describe('SqlDriver composite primary-key introspection (SQLite)', () => {
expect(pkByName).toEqual({ order_id: 1, line_no: 2, sku: 0 });
});

it('reports every member of a composite key, and derives isPrimary for all of them', async () => {
it('reports every member of a composite key, and derives primaryKey for all of them', async () => {
await knexInstance.schema.createTable('order_lines', (t: any) => {
t.string('order_id').notNullable();
t.integer('line_no').notNullable();
Expand All@@ -95,8 +95,8 @@ describe('SqlDriver composite primary-key introspection (SQLite)', () => {
expect(table.primaryKeys).toEqual(['order_id', 'line_no']);

// Signal 2: the per-column flag, derived FROM signal 1 — repaired with it.
const isPrimaryByName = Object.fromEntries(table.columns.map((c) => [c.name, c.isPrimary === true]));
expect(isPrimaryByName).toEqual({ order_id: true, line_no: true, sku: false });
const primaryKeyByName = Object.fromEntries(table.columns.map((c) => [c.name, c.primaryKey === true]));
expect(primaryKeyByName).toEqual({ order_id: true, line_no: true, sku: false });
});

it('orders primaryKeys by pk ordinal, not by column position', async () => {
Expand DownExpand Up@@ -164,7 +164,7 @@ describe('SqlDriver composite primary-key introspection (SQLite)', () => {
expect(schema.tables['widgets'].primaryKeys).toEqual(['id']);
expect(schema.tables['audit_lines'].primaryKeys).toEqual([]);

const auditPrimary = schema.tables['audit_lines'].columns.map((c) => c.isPrimary === true);
const auditPrimary = schema.tables['audit_lines'].columns.map((c) => c.primaryKey === true);
expect(auditPrimary).toEqual([false, false]);
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pin: `SqlDriver.introspectSchema()` emits the `packages/spec` introspection
* contract — the shape every consumer downstream of `plugin.ts` is typed
* against — and not a second vocabulary of its own.
*
* The defect this closes was invisible to types on both sides. The driver
* declared its own `IntrospectedColumn` spelling key membership `isPrimary?`;
* `packages/spec/src/contracts/schema-diff-service.ts` declares `primaryKey`,
* `dialect` and a REQUIRED `introspectedAt`. Each side compiled against its
* own declaration, the value crossed between them untyped, and the consumer
* read keys no driver ever set. Maintainer ruling, 2026-08-22 (live session,
* 「同意所有」 item 9 = 驱动侧对齐 spec 契约): the driver aligns to the spec.
*
* Asserted on the BYTES of a live introspection rather than on a type, because
* a type is exactly what failed to catch this: a hand-written fixture in
* either spelling is blind to the seam. Only better-sqlite3 is executed here —
* every assertion below is on a value built at a single dialect-independent
* site in `introspectSchema`, downstream of the per-dialect helpers, so the
* SHAPE cannot vary by dialect even though the per-dialect CONTENT is not
* measured here.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { SqlDriver } from '../src/index.js';

describe('SqlDriver.introspectSchema emits the spec introspection contract', () => {
let driver: SqlDriver;
let knexInstance: any;

beforeEach(async () => {
driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
knexInstance = (driver as unknown as { knex: any }).knex;
await knexInstance.schema.createTable('customers', (t: any) => {
t.string('id').primary();
t.string('name');
t.integer('age');
});
});

afterEach(async () => {
await knexInstance?.destroy();
});

it('spells column key membership `primaryKey`, the spec spelling', async () => {
const schema = await driver.introspectSchema();
const byName = Object.fromEntries(
schema.tables['customers'].columns.map((c) => [c.name, c]),
);

expect(byName.id.primaryKey).toBe(true);
// Negative half: an implementation that stamped the key onto the first
// column, or onto every column, would satisfy the line above alone.
expect(byName.name.primaryKey).toBe(false);
expect(byName.age.primaryKey).toBe(false);
});

it('emits `dialect` and the required `introspectedAt`', async () => {
const before = Date.now();
const schema = await driver.introspectSchema();

// #10998's acceptance criterion, spelled exactly as it was written: the
// producer returned `{ tables }` alone while the contract declares three
// keys, so consumers read two nobody set — type mapping ran with no
// dialect on the whole federation path, and `refreshCatalog` persisted
// `dialect: undefined` into the record Studio and the boot gate read back.
expect(Object.keys(schema)).toEqual(
expect.arrayContaining(['tables', 'dialect', 'introspectedAt']),
);

// The dialect TOKEN, not merely the key's presence. The consumer is
// `suggestFieldTypeForSqlType(col.type, schema.dialect as SqlDialect)`,
// whose vocabulary spells these `sqlite` / `postgres` / `mysql`; a token
// outside it (`better-sqlite3`, or the spec enum's `postgresql`) would
// leave every per-dialect alias unreachable with the key still present.
expect(schema.dialect).toBe('sqlite');

// Required in the contract, so it is emitted unconditionally — and it is a
// real ISO 8601 instant, not a placeholder a consumer would have to guard.
expect(typeof schema.introspectedAt).toBe('string');
expect(new Date(schema.introspectedAt).toISOString()).toBe(schema.introspectedAt);
const at = Date.parse(schema.introspectedAt);
expect(at).toBeGreaterThanOrEqual(before - 1000);
expect(at).toBeLessThanOrEqual(Date.now() + 1000);
});

it('no longer emits the retired `isPrimary` spelling', async () => {
const schema = await driver.introspectSchema();
const id = schema.tables['customers'].columns.find((c) => c.name === 'id')!;

// `in`, not a truthiness check: the failure this closes was a consumer
// reading a key that was ABSENT, so absence is what has to be pinned. Two
// spellings emitted side by side would keep the second contract alive in
// the bytes even with both values agreeing today.
expect('isPrimary' in id).toBe(false);
expect(Object.keys(id)).toEqual(
expect.arrayContaining(['name', 'type', 'nullable', 'primaryKey']),
);
});
});
91 changes: 79 additions & 12 deletions packages/drivers/driver-sql/src/sql-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,14 @@
*/

import type { DriverOptions, FilterCondition, SchemaMode } from '@objectstack/spec/data';
// The ONE introspection contract (ADR-0015 / `ISchemaDiffService`). This
// driver's introspection types are DERIVED from these rather than
// re-declared next to them — see the `Introspection Types` region below.
import type {
IntrospectedColumn as SpecIntrospectedColumn,
IntrospectedSchema as SpecIntrospectedSchema,
IntrospectedTable as SpecIntrospectedTable,
} from '@objectstack/spec/contracts';
import { parseAutonumberFormat, renderAutonumber, resolveAutonumberFormat, readAutonumberCounter, missingFieldValues, isTenancyDisabled, type AutonumberToken } from '@objectstack/spec/data';
// The DECLARED aggregate vocabulary (#5907). Read from the spec so this driver's
// "the protocol has no such function" refusal cannot drift from what
Expand DownExpand Up@@ -3645,31 +3653,76 @@ function nullSafeNegationOperand(node: Record<string, unknown>): Record<string,

// ── Introspection Types ──────────────────────────────────────────────────────

export interface IntrospectedColumn {
name: string;
type: string;
nullable: boolean;
/**
* These are DERIVED from `packages/spec/src/contracts/schema-diff-service.ts`,
* never re-declared beside it.
*
* They used to be a second, independent declaration that happened to describe
* the same idea in a different vocabulary: this file spelled a column's key
* membership `isPrimary?`, the spec spells it `primaryKey`; the spec also
* declares `dialect` and a REQUIRED `introspectedAt` that this file's schema
* type did not mention and `introspectSchema` therefore never emitted. `plugin.ts` hands
* this driver's result straight to `ExternalDatasourceService`, which is typed
* against the spec — so the consumer read a key no driver ever set and every
* federated object drafted from a remote table silently lost its primary key.
* Nothing was type-unsound; the two contracts simply never met a compiler.
*
* Maintainer ruling, 2026-08-22 (live session, 「同意所有」 item 9 =
* 驱动侧对齐 spec 契约): `packages/spec` is the one contract and the DRIVER
* aligns to it. Deriving rather than copying is what makes that mechanical —
* a key added to the spec contract now fails this file's `tsc` until the
* driver emits it, which is exactly the failure that was missing.
*
* Two divergences remain, deliberately, and neither is a second spelling of
* something the spec declares:
*
* - the SQL layer carries EXTRA per-column facts (`isUnique`, `maxLength`)
* and extra per-table facts (`foreignKeys`, `primaryKeys`) that the spec's
* diff-facing contract does not declare;
* - `defaultValue` is `unknown` here rather than the spec's `string`, because
* that is what Knex's `columnInfo()` actually returns (measured on live
* SQLite: `null`). Narrowing the declaration without normalising the value
* would move the lie rather than remove it.
*/
export interface IntrospectedColumn extends Omit<SpecIntrospectedColumn, 'defaultValue'> {
/** Raw driver-reported default. See the note above on why this is not `string`. */
defaultValue?: unknown;
isPrimary?: boolean;
/** SQL-introspection extra: the column carries a UNIQUE constraint. */
isUnique?: boolean;
/** SQL-introspection extra: declared maximum length for string types. */
maxLength?: number;
}

/** No spec counterpart — foreign keys are a SQL-introspection extra. */
export interface IntrospectedForeignKey {
columnName: string;
referencedTable: string;
referencedColumn: string;
constraintName?: string;
}

export interface IntrospectedTable {
name: string;
/**
* `indexes` is `Omit`ted from the spec table rather than emitted empty: this
* driver does not introspect indexes, and `indexes: []` would tell a schema
* differ that a table HAS none when it merely was not asked — a worse answer
* than an absent key. Emitting them for real is per-dialect work over arms
* this container cannot execute, so it is filed rather than guessed.
*/
export interface IntrospectedTable extends Omit<SpecIntrospectedTable, 'columns' | 'indexes'> {
columns: IntrospectedColumn[];
/** SQL-introspection extra: outbound foreign keys. */
foreignKeys: IntrospectedForeignKey[];
/** SQL-introspection extra: the table's primary-key columns, in key order. */
primaryKeys: string[];
}

export interface IntrospectedSchema {
/**
* `dialect` and `introspectedAt` are inherited from the spec contract, where
* `introspectedAt` is REQUIRED — so `tsc` now refuses an `introspectSchema`
* that omits them, which is the check that was missing while this type
* declared `{ tables }` alone.
*/
export interface IntrospectedSchema extends Omit<SpecIntrospectedSchema, 'tables'> {
tables: Record<string, IntrospectedTable>;
}

Expand DownExpand Up@@ -9798,6 +9851,10 @@ export class SqlDriver implements IDataDriver {

async introspectSchema(): Promise<IntrospectedSchema> {
const tables: Record<string, IntrospectedTable> = {};
// Stamped BEFORE the reads, not after: a consumer asking "has the remote
// changed since this snapshot?" must not be told the snapshot covers a
// moment later than the first table it actually read.
const introspectedAt = new Date().toISOString();
let tableNames: string[] = [];

if (this.isPostgres) {
Expand DownExpand Up@@ -9837,14 +9894,22 @@ export class SqlDriver implements IDataDriver {
const uniqueConstraints = await this.introspectUniqueConstraints(tableName);

for (const col of columns) {
if (primaryKeys.includes(col.name)) col.isPrimary = true;
if (primaryKeys.includes(col.name)) col.primaryKey = true;
if (uniqueConstraints.includes(col.name)) col.isUnique = true;
}

tables[tableName] = { name: tableName, columns, foreignKeys, primaryKeys };
}

return { tables };
// `dialectName` — not the raw Knex client, and not the spec's
// `SQLDialectSchema` enum. The only in-tree consumer of this key is
// `suggestFieldTypeForSqlType(col.type, schema.dialect as SqlDialect)`,
// whose `SqlDialect` vocabulary (`packages/spec/src/data/type-compat.ts`)
// spells PostgreSQL `postgres`, exactly as `dialectName` does. Emitting
// the enum's `postgresql` instead would put the key in `Object.keys()`
// while leaving every per-dialect type alias unreachable — the omission
// this repairs, wearing a fix's clothes.
return { tables, dialect: this.dialectName, introspectedAt };
}

// ===================================
Expand DownExpand Up@@ -12548,7 +12613,9 @@ export class SqlDriver implements IDataDriver {
type,
nullable: info.nullable !== false,
defaultValue: info.defaultValue,
isPrimary: false,
// The spec contract's spelling, and the only one this driver emits.
// `introspectSchema` flips it from the table's key list below.
primaryKey: false,
isUnique: false,
maxLength,
});
Expand DownExpand Up@@ -12695,7 +12762,7 @@ export class SqlDriver implements IDataDriver {
// of the key", `1` for the first key column, `2` for the second, and so
// on. Filtering on `pk === 1` therefore kept only the first member of a
// composite key and silently dropped the rest, and because
// `introspectSchema` derives `col.isPrimary` FROM this list, both output
// `introspectSchema` derives `col.primaryKey` FROM this list, both output
// signals were wrong together.
//
// Ordering by the ordinal (rather than taking `table_info`'s row order,
Expand Down
Loading
Loading