@objectstack/driver-sql 7.3.0 → 7.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { QueryAST, DriverOptions } from '@objectstack/spec/data';
1
+ import { SchemaMode, QueryAST, DriverOptions } from '@objectstack/spec/data';
2
2
  import { IDataDriver } from '@objectstack/spec/contracts';
3
3
  import knex, { Knex } from 'knex';
4
4
 
@@ -36,8 +36,15 @@ interface IntrospectedSchema {
36
36
  /**
37
37
  * SqlDriver configuration — passed directly to Knex.
38
38
  * See https://knexjs.org/guide/#configuration-options
39
+ *
40
+ * `schemaMode` (ADR-0015) is an ObjectStack-level concern, not a Knex
41
+ * option: it is stripped before constructing the Knex instance and gates
42
+ * all schema-mutating DDL. Defaults to `'managed'` when omitted, preserving
43
+ * legacy behaviour.
39
44
  */
40
- type SqlDriverConfig = Knex.Config;
45
+ type SqlDriverConfig = Knex.Config & {
46
+ schemaMode?: SchemaMode;
47
+ };
41
48
  /**
42
49
  * SQL Driver for ObjectStack.
43
50
  *
@@ -175,7 +182,21 @@ declare class SqlDriver implements IDataDriver {
175
182
  sql: string;
176
183
  bindings: any[];
177
184
  } | null;
185
+ /**
186
+ * Schema ownership mode (ADR-0015). When not `'managed'`, all
187
+ * schema-mutating DDL is rejected by {@link assertSchemaMutable}. The
188
+ * runtime injects this from `Datasource.schemaMode`; defaults to
189
+ * `'managed'` so existing callers are unaffected.
190
+ */
191
+ protected readonly schemaMode: SchemaMode;
178
192
  constructor(config: SqlDriverConfig);
193
+ /**
194
+ * DDL gate (ADR-0015 §5.1). Single choke-point asserting that
195
+ * schema-mutating DDL is only performed on a `managed` datasource.
196
+ * Federated datasources (`external` / `validate-only`) are guests in a
197
+ * database ObjectStack does not own and must never run DDL against.
198
+ */
199
+ protected assertSchemaMutable(operation: string): void;
179
200
  connect(): Promise<void>;
180
201
  checkHealth(): Promise<boolean>;
181
202
  disconnect(): Promise<void>;
@@ -280,6 +301,38 @@ declare class SqlDriver implements IDataDriver {
280
301
  name: string;
281
302
  fields?: Record<string, any>;
282
303
  }>): Promise<void>;
304
+ /**
305
+ * Build a deterministic index name for a declared index so repeated
306
+ * `initObjects` runs converge on the same identifier (and can detect an
307
+ * already-materialized index by name). Long names are hash-suffixed to
308
+ * stay within the 63/64-char identifier limits of Postgres/MySQL.
309
+ */
310
+ protected buildIndexName(tableName: string, fields: string[], unique: boolean): string;
311
+ /**
312
+ * Read the names of indexes that already exist on a table, per dialect.
313
+ * Used to make declared-index sync idempotent across repeated runs.
314
+ * Failures are swallowed — at worst we attempt a create and absorb the
315
+ * "already exists" error in `syncDeclaredIndexes`.
316
+ */
317
+ protected getExistingIndexNames(tableName: string): Promise<Set<string>>;
318
+ /**
319
+ * Materialize declared object-level indexes.
320
+ *
321
+ * - Multi-column and single-column indexes are both supported.
322
+ * - `unique: true` emits a UNIQUE index. NULL-distinct semantics are the
323
+ * default across SQLite/Postgres/MySQL, so multiple NULL rows remain
324
+ * allowed while non-NULL duplicates are rejected — matching the
325
+ * convergence-on-conflict pattern the messaging pipeline relies on.
326
+ * - Idempotent: indexes already present (by deterministic name) are
327
+ * skipped, and an "already exists" race is absorbed.
328
+ * - Indexes referencing a column that wasn't materialized (e.g. a virtual
329
+ * `formula` field) are skipped with a warning rather than failing sync.
330
+ */
331
+ protected syncDeclaredIndexes(tableName: string, indexes: Array<{
332
+ name?: string;
333
+ fields?: string[];
334
+ unique?: boolean;
335
+ }>, physicalColumns: Set<string>): Promise<void>;
283
336
  introspectSchema(): Promise<IntrospectedSchema>;
284
337
  /** Expose the underlying Knex instance for advanced usage. */
285
338
  getKnex(): Knex;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { QueryAST, DriverOptions } from '@objectstack/spec/data';
1
+ import { SchemaMode, QueryAST, DriverOptions } from '@objectstack/spec/data';
2
2
  import { IDataDriver } from '@objectstack/spec/contracts';
3
3
  import knex, { Knex } from 'knex';
4
4
 
@@ -36,8 +36,15 @@ interface IntrospectedSchema {
36
36
  /**
37
37
  * SqlDriver configuration — passed directly to Knex.
38
38
  * See https://knexjs.org/guide/#configuration-options
39
+ *
40
+ * `schemaMode` (ADR-0015) is an ObjectStack-level concern, not a Knex
41
+ * option: it is stripped before constructing the Knex instance and gates
42
+ * all schema-mutating DDL. Defaults to `'managed'` when omitted, preserving
43
+ * legacy behaviour.
39
44
  */
40
- type SqlDriverConfig = Knex.Config;
45
+ type SqlDriverConfig = Knex.Config & {
46
+ schemaMode?: SchemaMode;
47
+ };
41
48
  /**
42
49
  * SQL Driver for ObjectStack.
43
50
  *
@@ -175,7 +182,21 @@ declare class SqlDriver implements IDataDriver {
175
182
  sql: string;
176
183
  bindings: any[];
177
184
  } | null;
185
+ /**
186
+ * Schema ownership mode (ADR-0015). When not `'managed'`, all
187
+ * schema-mutating DDL is rejected by {@link assertSchemaMutable}. The
188
+ * runtime injects this from `Datasource.schemaMode`; defaults to
189
+ * `'managed'` so existing callers are unaffected.
190
+ */
191
+ protected readonly schemaMode: SchemaMode;
178
192
  constructor(config: SqlDriverConfig);
193
+ /**
194
+ * DDL gate (ADR-0015 §5.1). Single choke-point asserting that
195
+ * schema-mutating DDL is only performed on a `managed` datasource.
196
+ * Federated datasources (`external` / `validate-only`) are guests in a
197
+ * database ObjectStack does not own and must never run DDL against.
198
+ */
199
+ protected assertSchemaMutable(operation: string): void;
179
200
  connect(): Promise<void>;
180
201
  checkHealth(): Promise<boolean>;
181
202
  disconnect(): Promise<void>;
@@ -280,6 +301,38 @@ declare class SqlDriver implements IDataDriver {
280
301
  name: string;
281
302
  fields?: Record<string, any>;
282
303
  }>): Promise<void>;
304
+ /**
305
+ * Build a deterministic index name for a declared index so repeated
306
+ * `initObjects` runs converge on the same identifier (and can detect an
307
+ * already-materialized index by name). Long names are hash-suffixed to
308
+ * stay within the 63/64-char identifier limits of Postgres/MySQL.
309
+ */
310
+ protected buildIndexName(tableName: string, fields: string[], unique: boolean): string;
311
+ /**
312
+ * Read the names of indexes that already exist on a table, per dialect.
313
+ * Used to make declared-index sync idempotent across repeated runs.
314
+ * Failures are swallowed — at worst we attempt a create and absorb the
315
+ * "already exists" error in `syncDeclaredIndexes`.
316
+ */
317
+ protected getExistingIndexNames(tableName: string): Promise<Set<string>>;
318
+ /**
319
+ * Materialize declared object-level indexes.
320
+ *
321
+ * - Multi-column and single-column indexes are both supported.
322
+ * - `unique: true` emits a UNIQUE index. NULL-distinct semantics are the
323
+ * default across SQLite/Postgres/MySQL, so multiple NULL rows remain
324
+ * allowed while non-NULL duplicates are rejected — matching the
325
+ * convergence-on-conflict pattern the messaging pipeline relies on.
326
+ * - Idempotent: indexes already present (by deterministic name) are
327
+ * skipped, and an "already exists" race is absorbed.
328
+ * - Indexes referencing a column that wasn't materialized (e.g. a virtual
329
+ * `formula` field) are skipped with a warning rather than failing sync.
330
+ */
331
+ protected syncDeclaredIndexes(tableName: string, indexes: Array<{
332
+ name?: string;
333
+ fields?: string[];
334
+ unique?: boolean;
335
+ }>, physicalColumns: Set<string>): Promise<void>;
283
336
  introspectSchema(): Promise<IntrospectedSchema>;
284
337
  /** Expose the underlying Knex instance for advanced usage. */
285
338
  getKnex(): Knex;
package/dist/index.js CHANGED
@@ -37,8 +37,10 @@ module.exports = __toCommonJS(index_exports);
37
37
 
38
38
  // src/sql-driver.ts
39
39
  var import_system = require("@objectstack/spec/system");
40
+ var import_shared = require("@objectstack/spec/shared");
40
41
  var import_knex = __toESM(require("knex"));
41
42
  var import_nanoid = require("nanoid");
43
+ var import_node_crypto = require("crypto");
42
44
  var DEFAULT_ID_LENGTH = 16;
43
45
  var SEQUENCES_TABLE = "_objectstack_sequences";
44
46
  var GLOBAL_TENANT = "__global__";
@@ -95,8 +97,10 @@ var SqlDriver = class {
95
97
  this.logger = {
96
98
  warn: (msg, meta) => console.warn(msg, meta ?? "")
97
99
  };
98
- this.config = config;
99
- this.knex = (0, import_knex.default)(config);
100
+ const { schemaMode, ...knexConfig } = config;
101
+ this.schemaMode = schemaMode ?? "managed";
102
+ this.config = knexConfig;
103
+ this.knex = (0, import_knex.default)(knexConfig);
100
104
  }
101
105
  get supports() {
102
106
  return {
@@ -139,7 +143,9 @@ var SqlDriver = class {
139
143
  schemaSync: true,
140
144
  batchSchemaSync: false,
141
145
  migrations: false,
142
- indexes: false,
146
+ // Object-level declared `indexes` (incl. multi-column UNIQUE) are
147
+ // materialized during `initObjects` — see `syncDeclaredIndexes`.
148
+ indexes: true,
143
149
  // Performance & Optimization
144
150
  connectionPooling: true,
145
151
  preparedStatements: true,
@@ -241,6 +247,19 @@ var SqlDriver = class {
241
247
  }
242
248
  return null;
243
249
  }
250
+ /**
251
+ * DDL gate (ADR-0015 §5.1). Single choke-point asserting that
252
+ * schema-mutating DDL is only performed on a `managed` datasource.
253
+ * Federated datasources (`external` / `validate-only`) are guests in a
254
+ * database ObjectStack does not own and must never run DDL against.
255
+ */
256
+ assertSchemaMutable(operation) {
257
+ if (this.schemaMode !== "managed") {
258
+ throw new import_shared.ExternalSchemaModeViolationError(
259
+ `DDL operation '${operation}' is forbidden: datasource schemaMode='${this.schemaMode}'. ObjectStack never mutates the schema of an external database.`
260
+ );
261
+ }
262
+ }
244
263
  // ===================================
245
264
  // Lifecycle
246
265
  // ===================================
@@ -779,6 +798,7 @@ var SqlDriver = class {
779
798
  await this.initObjects([{ ...objectDef, name: object }]);
780
799
  }
781
800
  async dropTable(object, _options) {
801
+ this.assertSchemaMutable("dropTable");
782
802
  await this.knex.schema.dropTableIfExists(object);
783
803
  }
784
804
  /**
@@ -786,6 +806,7 @@ var SqlDriver = class {
786
806
  */
787
807
  async initObjects(objects) {
788
808
  var _a, _b;
809
+ this.assertSchemaMutable("initObjects");
789
810
  await this.ensureDatabaseExists();
790
811
  for (const obj of objects) {
791
812
  const tableName = import_system.StorageNameMapping.resolveTableName(obj);
@@ -871,6 +892,101 @@ var SqlDriver = class {
871
892
  }
872
893
  });
873
894
  }
895
+ const declaredIndexes = obj.indexes;
896
+ if (Array.isArray(declaredIndexes) && declaredIndexes.length > 0) {
897
+ const colInfo = await this.knex(tableName).columnInfo();
898
+ const physicalColumns = new Set(Object.keys(colInfo));
899
+ await this.syncDeclaredIndexes(tableName, declaredIndexes, physicalColumns);
900
+ }
901
+ }
902
+ }
903
+ /**
904
+ * Build a deterministic index name for a declared index so repeated
905
+ * `initObjects` runs converge on the same identifier (and can detect an
906
+ * already-materialized index by name). Long names are hash-suffixed to
907
+ * stay within the 63/64-char identifier limits of Postgres/MySQL.
908
+ */
909
+ buildIndexName(tableName, fields, unique) {
910
+ const prefix = unique ? "uniq" : "idx";
911
+ const base = `${prefix}_${tableName}_${fields.join("_")}`;
912
+ const MAX = 60;
913
+ if (base.length <= MAX) return base;
914
+ const hash = (0, import_node_crypto.createHash)("sha1").update(base).digest("hex").slice(0, 8);
915
+ return `${`${prefix}_${tableName}`.slice(0, MAX - 9)}_${hash}`;
916
+ }
917
+ /**
918
+ * Read the names of indexes that already exist on a table, per dialect.
919
+ * Used to make declared-index sync idempotent across repeated runs.
920
+ * Failures are swallowed — at worst we attempt a create and absorb the
921
+ * "already exists" error in `syncDeclaredIndexes`.
922
+ */
923
+ async getExistingIndexNames(tableName) {
924
+ const names = /* @__PURE__ */ new Set();
925
+ try {
926
+ if (this.isSqlite) {
927
+ const safe = tableName.replace(/[^a-zA-Z0-9_]/g, "");
928
+ const rows = await this.knex.raw(`PRAGMA index_list(${safe})`);
929
+ for (const r of rows) names.add(r.name);
930
+ } else if (this.isPostgres) {
931
+ const res = await this.knex.raw(
932
+ `SELECT indexname FROM pg_indexes WHERE schemaname = 'public' AND tablename = ?`,
933
+ [tableName]
934
+ );
935
+ for (const r of res.rows) names.add(r.indexname);
936
+ } else if (this.isMysql) {
937
+ const res = await this.knex.raw(
938
+ `SELECT INDEX_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`,
939
+ [tableName]
940
+ );
941
+ for (const r of res[0]) names.add(r.INDEX_NAME);
942
+ }
943
+ } catch {
944
+ }
945
+ return names;
946
+ }
947
+ /**
948
+ * Materialize declared object-level indexes.
949
+ *
950
+ * - Multi-column and single-column indexes are both supported.
951
+ * - `unique: true` emits a UNIQUE index. NULL-distinct semantics are the
952
+ * default across SQLite/Postgres/MySQL, so multiple NULL rows remain
953
+ * allowed while non-NULL duplicates are rejected — matching the
954
+ * convergence-on-conflict pattern the messaging pipeline relies on.
955
+ * - Idempotent: indexes already present (by deterministic name) are
956
+ * skipped, and an "already exists" race is absorbed.
957
+ * - Indexes referencing a column that wasn't materialized (e.g. a virtual
958
+ * `formula` field) are skipped with a warning rather than failing sync.
959
+ */
960
+ async syncDeclaredIndexes(tableName, indexes, physicalColumns) {
961
+ const existing = await this.getExistingIndexNames(tableName);
962
+ for (const idx of indexes) {
963
+ const fields = Array.isArray(idx?.fields) ? idx.fields.filter((f) => typeof f === "string" && f.length > 0) : [];
964
+ if (fields.length === 0) continue;
965
+ const missing = fields.filter((f) => !physicalColumns.has(f));
966
+ if (missing.length > 0) {
967
+ this.logger.warn(
968
+ `[sql-driver] skipping declared index on "${tableName}" \u2014 column(s) not materialized: ${missing.join(", ")}`,
969
+ { tableName, fields }
970
+ );
971
+ continue;
972
+ }
973
+ const unique = idx.unique === true;
974
+ const name = typeof idx.name === "string" && idx.name.trim() ? idx.name.trim() : this.buildIndexName(tableName, fields, unique);
975
+ if (existing.has(name)) continue;
976
+ try {
977
+ await this.knex.schema.alterTable(tableName, (table) => {
978
+ if (unique) {
979
+ table.unique(fields, { indexName: name });
980
+ } else {
981
+ table.index(fields, name);
982
+ }
983
+ });
984
+ existing.add(name);
985
+ } catch (e) {
986
+ const msg = String(e?.message ?? e);
987
+ if (/already exists|duplicate key name|exists/i.test(msg)) continue;
988
+ throw e;
989
+ }
874
990
  }
875
991
  }
876
992
  // ===================================