|
8 | 8 | */ |
9 | 9 |
|
10 | 10 | import type { DriverOptions, FilterCondition, SchemaMode } from '@objectstack/spec/data'; |
| 11 | +// The ONE introspection contract (ADR-0015 / `ISchemaDiffService`). This |
| 12 | +// driver's introspection types are DERIVED from these rather than |
| 13 | +// re-declared next to them — see the `Introspection Types` region below. |
| 14 | +import type { |
| 15 | + IntrospectedColumn as SpecIntrospectedColumn, |
| 16 | + IntrospectedSchema as SpecIntrospectedSchema, |
| 17 | + IntrospectedTable as SpecIntrospectedTable, |
| 18 | +} from '@objectstack/spec/contracts'; |
11 | 19 | import { parseAutonumberFormat, renderAutonumber, resolveAutonumberFormat, readAutonumberCounter, missingFieldValues, isTenancyDisabled, type AutonumberToken } from '@objectstack/spec/data'; |
12 | 20 | // The DECLARED aggregate vocabulary (#5907). Read from the spec so this driver's |
13 | 21 | // "the protocol has no such function" refusal cannot drift from what |
@@ -3645,31 +3653,76 @@ function nullSafeNegationOperand(node: Record<string, unknown>): Record<string, |
3645 | 3653 |
|
3646 | 3654 | // ── Introspection Types ────────────────────────────────────────────────────── |
3647 | 3655 |
|
3648 | | -export interface IntrospectedColumn { |
3649 | | - name: string; |
3650 | | - type: string; |
3651 | | - nullable: boolean; |
| 3656 | +/** |
| 3657 | + * These are DERIVED from `packages/spec/src/contracts/schema-diff-service.ts`, |
| 3658 | + * never re-declared beside it. |
| 3659 | + * |
| 3660 | + * They used to be a second, independent declaration that happened to describe |
| 3661 | + * the same idea in a different vocabulary: this file spelled a column's key |
| 3662 | + * membership `isPrimary?`, the spec spells it `primaryKey`; the spec also |
| 3663 | + * declares `dialect` and a REQUIRED `introspectedAt` that this file's schema |
| 3664 | + * type did not mention and `introspectSchema` therefore never emitted. `plugin.ts` hands |
| 3665 | + * this driver's result straight to `ExternalDatasourceService`, which is typed |
| 3666 | + * against the spec — so the consumer read a key no driver ever set and every |
| 3667 | + * federated object drafted from a remote table silently lost its primary key. |
| 3668 | + * Nothing was type-unsound; the two contracts simply never met a compiler. |
| 3669 | + * |
| 3670 | + * Maintainer ruling, 2026-08-22 (live session, 「同意所有」 item 9 = |
| 3671 | + * 驱动侧对齐 spec 契约): `packages/spec` is the one contract and the DRIVER |
| 3672 | + * aligns to it. Deriving rather than copying is what makes that mechanical — |
| 3673 | + * a key added to the spec contract now fails this file's `tsc` until the |
| 3674 | + * driver emits it, which is exactly the failure that was missing. |
| 3675 | + * |
| 3676 | + * Two divergences remain, deliberately, and neither is a second spelling of |
| 3677 | + * something the spec declares: |
| 3678 | + * |
| 3679 | + * - the SQL layer carries EXTRA per-column facts (`isUnique`, `maxLength`) |
| 3680 | + * and extra per-table facts (`foreignKeys`, `primaryKeys`) that the spec's |
| 3681 | + * diff-facing contract does not declare; |
| 3682 | + * - `defaultValue` is `unknown` here rather than the spec's `string`, because |
| 3683 | + * that is what Knex's `columnInfo()` actually returns (measured on live |
| 3684 | + * SQLite: `null`). Narrowing the declaration without normalising the value |
| 3685 | + * would move the lie rather than remove it. |
| 3686 | + */ |
| 3687 | +export interface IntrospectedColumn extends Omit<SpecIntrospectedColumn, 'defaultValue'> { |
| 3688 | + /** Raw driver-reported default. See the note above on why this is not `string`. */ |
3652 | 3689 | defaultValue?: unknown; |
3653 | | - isPrimary?: boolean; |
| 3690 | + /** SQL-introspection extra: the column carries a UNIQUE constraint. */ |
3654 | 3691 | isUnique?: boolean; |
| 3692 | + /** SQL-introspection extra: declared maximum length for string types. */ |
3655 | 3693 | maxLength?: number; |
3656 | 3694 | } |
3657 | 3695 |
|
| 3696 | +/** No spec counterpart — foreign keys are a SQL-introspection extra. */ |
3658 | 3697 | export interface IntrospectedForeignKey { |
3659 | 3698 | columnName: string; |
3660 | 3699 | referencedTable: string; |
3661 | 3700 | referencedColumn: string; |
3662 | 3701 | constraintName?: string; |
3663 | 3702 | } |
3664 | 3703 |
|
3665 | | -export interface IntrospectedTable { |
3666 | | - name: string; |
| 3704 | +/** |
| 3705 | + * `indexes` is `Omit`ted from the spec table rather than emitted empty: this |
| 3706 | + * driver does not introspect indexes, and `indexes: []` would tell a schema |
| 3707 | + * differ that a table HAS none when it merely was not asked — a worse answer |
| 3708 | + * than an absent key. Emitting them for real is per-dialect work over arms |
| 3709 | + * this container cannot execute, so it is filed rather than guessed. |
| 3710 | + */ |
| 3711 | +export interface IntrospectedTable extends Omit<SpecIntrospectedTable, 'columns' | 'indexes'> { |
3667 | 3712 | columns: IntrospectedColumn[]; |
| 3713 | + /** SQL-introspection extra: outbound foreign keys. */ |
3668 | 3714 | foreignKeys: IntrospectedForeignKey[]; |
| 3715 | + /** SQL-introspection extra: the table's primary-key columns, in key order. */ |
3669 | 3716 | primaryKeys: string[]; |
3670 | 3717 | } |
3671 | 3718 |
|
3672 | | -export interface IntrospectedSchema { |
| 3719 | +/** |
| 3720 | + * `dialect` and `introspectedAt` are inherited from the spec contract, where |
| 3721 | + * `introspectedAt` is REQUIRED — so `tsc` now refuses an `introspectSchema` |
| 3722 | + * that omits them, which is the check that was missing while this type |
| 3723 | + * declared `{ tables }` alone. |
| 3724 | + */ |
| 3725 | +export interface IntrospectedSchema extends Omit<SpecIntrospectedSchema, 'tables'> { |
3673 | 3726 | tables: Record<string, IntrospectedTable>; |
3674 | 3727 | } |
3675 | 3728 |
|
@@ -9798,6 +9851,10 @@ export class SqlDriver implements IDataDriver { |
9798 | 9851 |
|
9799 | 9852 | async introspectSchema(): Promise<IntrospectedSchema> { |
9800 | 9853 | const tables: Record<string, IntrospectedTable> = {}; |
| 9854 | + // Stamped BEFORE the reads, not after: a consumer asking "has the remote |
| 9855 | + // changed since this snapshot?" must not be told the snapshot covers a |
| 9856 | + // moment later than the first table it actually read. |
| 9857 | + const introspectedAt = new Date().toISOString(); |
9801 | 9858 | let tableNames: string[] = []; |
9802 | 9859 |
|
9803 | 9860 | if (this.isPostgres) { |
@@ -9837,14 +9894,22 @@ export class SqlDriver implements IDataDriver { |
9837 | 9894 | const uniqueConstraints = await this.introspectUniqueConstraints(tableName); |
9838 | 9895 |
|
9839 | 9896 | for (const col of columns) { |
9840 | | - if (primaryKeys.includes(col.name)) col.isPrimary = true; |
| 9897 | + if (primaryKeys.includes(col.name)) col.primaryKey = true; |
9841 | 9898 | if (uniqueConstraints.includes(col.name)) col.isUnique = true; |
9842 | 9899 | } |
9843 | 9900 |
|
9844 | 9901 | tables[tableName] = { name: tableName, columns, foreignKeys, primaryKeys }; |
9845 | 9902 | } |
9846 | 9903 |
|
9847 | | - return { tables }; |
| 9904 | + // `dialectName` — not the raw Knex client, and not the spec's |
| 9905 | + // `SQLDialectSchema` enum. The only in-tree consumer of this key is |
| 9906 | + // `suggestFieldTypeForSqlType(col.type, schema.dialect as SqlDialect)`, |
| 9907 | + // whose `SqlDialect` vocabulary (`packages/spec/src/data/type-compat.ts`) |
| 9908 | + // spells PostgreSQL `postgres`, exactly as `dialectName` does. Emitting |
| 9909 | + // the enum's `postgresql` instead would put the key in `Object.keys()` |
| 9910 | + // while leaving every per-dialect type alias unreachable — the omission |
| 9911 | + // this repairs, wearing a fix's clothes. |
| 9912 | + return { tables, dialect: this.dialectName, introspectedAt }; |
9848 | 9913 | } |
9849 | 9914 |
|
9850 | 9915 | // =================================== |
@@ -12548,7 +12613,9 @@ export class SqlDriver implements IDataDriver { |
12548 | 12613 | type, |
12549 | 12614 | nullable: info.nullable !== false, |
12550 | 12615 | defaultValue: info.defaultValue, |
12551 | | - isPrimary: false, |
| 12616 | + // The spec contract's spelling, and the only one this driver emits. |
| 12617 | + // `introspectSchema` flips it from the table's key list below. |
| 12618 | + primaryKey: false, |
12552 | 12619 | isUnique: false, |
12553 | 12620 | maxLength, |
12554 | 12621 | }); |
@@ -12695,7 +12762,7 @@ export class SqlDriver implements IDataDriver { |
12695 | 12762 | // of the key", `1` for the first key column, `2` for the second, and so |
12696 | 12763 | // on. Filtering on `pk === 1` therefore kept only the first member of a |
12697 | 12764 | // composite key and silently dropped the rest, and because |
12698 | | - // `introspectSchema` derives `col.isPrimary` FROM this list, both output |
| 12765 | + // `introspectSchema` derives `col.primaryKey` FROM this list, both output |
12699 | 12766 | // signals were wrong together. |
12700 | 12767 | // |
12701 | 12768 | // Ordering by the ordinal (rather than taking `table_info`'s row order, |
|
0 commit comments