@objectstack/driver-sql 7.2.1 → 7.4.0

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.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  // src/sql-driver.ts
2
2
  import { StorageNameMapping } from "@objectstack/spec/system";
3
+ import { ExternalSchemaModeViolationError } from "@objectstack/spec/shared";
3
4
  import knex from "knex";
4
5
  import { nanoid } from "nanoid";
6
+ import { createHash } from "crypto";
5
7
  var DEFAULT_ID_LENGTH = 16;
6
8
  var SEQUENCES_TABLE = "_objectstack_sequences";
7
9
  var GLOBAL_TENANT = "__global__";
@@ -58,8 +60,10 @@ var SqlDriver = class {
58
60
  this.logger = {
59
61
  warn: (msg, meta) => console.warn(msg, meta ?? "")
60
62
  };
61
- this.config = config;
62
- this.knex = knex(config);
63
+ const { schemaMode, ...knexConfig } = config;
64
+ this.schemaMode = schemaMode ?? "managed";
65
+ this.config = knexConfig;
66
+ this.knex = knex(knexConfig);
63
67
  }
64
68
  get supports() {
65
69
  return {
@@ -102,7 +106,9 @@ var SqlDriver = class {
102
106
  schemaSync: true,
103
107
  batchSchemaSync: false,
104
108
  migrations: false,
105
- indexes: false,
109
+ // Object-level declared `indexes` (incl. multi-column UNIQUE) are
110
+ // materialized during `initObjects` — see `syncDeclaredIndexes`.
111
+ indexes: true,
106
112
  // Performance & Optimization
107
113
  connectionPooling: true,
108
114
  preparedStatements: true,
@@ -204,6 +210,19 @@ var SqlDriver = class {
204
210
  }
205
211
  return null;
206
212
  }
213
+ /**
214
+ * DDL gate (ADR-0015 §5.1). Single choke-point asserting that
215
+ * schema-mutating DDL is only performed on a `managed` datasource.
216
+ * Federated datasources (`external` / `validate-only`) are guests in a
217
+ * database ObjectStack does not own and must never run DDL against.
218
+ */
219
+ assertSchemaMutable(operation) {
220
+ if (this.schemaMode !== "managed") {
221
+ throw new ExternalSchemaModeViolationError(
222
+ `DDL operation '${operation}' is forbidden: datasource schemaMode='${this.schemaMode}'. ObjectStack never mutates the schema of an external database.`
223
+ );
224
+ }
225
+ }
207
226
  // ===================================
208
227
  // Lifecycle
209
228
  // ===================================
@@ -742,6 +761,7 @@ var SqlDriver = class {
742
761
  await this.initObjects([{ ...objectDef, name: object }]);
743
762
  }
744
763
  async dropTable(object, _options) {
764
+ this.assertSchemaMutable("dropTable");
745
765
  await this.knex.schema.dropTableIfExists(object);
746
766
  }
747
767
  /**
@@ -749,6 +769,7 @@ var SqlDriver = class {
749
769
  */
750
770
  async initObjects(objects) {
751
771
  var _a, _b;
772
+ this.assertSchemaMutable("initObjects");
752
773
  await this.ensureDatabaseExists();
753
774
  for (const obj of objects) {
754
775
  const tableName = StorageNameMapping.resolveTableName(obj);
@@ -834,6 +855,101 @@ var SqlDriver = class {
834
855
  }
835
856
  });
836
857
  }
858
+ const declaredIndexes = obj.indexes;
859
+ if (Array.isArray(declaredIndexes) && declaredIndexes.length > 0) {
860
+ const colInfo = await this.knex(tableName).columnInfo();
861
+ const physicalColumns = new Set(Object.keys(colInfo));
862
+ await this.syncDeclaredIndexes(tableName, declaredIndexes, physicalColumns);
863
+ }
864
+ }
865
+ }
866
+ /**
867
+ * Build a deterministic index name for a declared index so repeated
868
+ * `initObjects` runs converge on the same identifier (and can detect an
869
+ * already-materialized index by name). Long names are hash-suffixed to
870
+ * stay within the 63/64-char identifier limits of Postgres/MySQL.
871
+ */
872
+ buildIndexName(tableName, fields, unique) {
873
+ const prefix = unique ? "uniq" : "idx";
874
+ const base = `${prefix}_${tableName}_${fields.join("_")}`;
875
+ const MAX = 60;
876
+ if (base.length <= MAX) return base;
877
+ const hash = createHash("sha1").update(base).digest("hex").slice(0, 8);
878
+ return `${`${prefix}_${tableName}`.slice(0, MAX - 9)}_${hash}`;
879
+ }
880
+ /**
881
+ * Read the names of indexes that already exist on a table, per dialect.
882
+ * Used to make declared-index sync idempotent across repeated runs.
883
+ * Failures are swallowed — at worst we attempt a create and absorb the
884
+ * "already exists" error in `syncDeclaredIndexes`.
885
+ */
886
+ async getExistingIndexNames(tableName) {
887
+ const names = /* @__PURE__ */ new Set();
888
+ try {
889
+ if (this.isSqlite) {
890
+ const safe = tableName.replace(/[^a-zA-Z0-9_]/g, "");
891
+ const rows = await this.knex.raw(`PRAGMA index_list(${safe})`);
892
+ for (const r of rows) names.add(r.name);
893
+ } else if (this.isPostgres) {
894
+ const res = await this.knex.raw(
895
+ `SELECT indexname FROM pg_indexes WHERE schemaname = 'public' AND tablename = ?`,
896
+ [tableName]
897
+ );
898
+ for (const r of res.rows) names.add(r.indexname);
899
+ } else if (this.isMysql) {
900
+ const res = await this.knex.raw(
901
+ `SELECT INDEX_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`,
902
+ [tableName]
903
+ );
904
+ for (const r of res[0]) names.add(r.INDEX_NAME);
905
+ }
906
+ } catch {
907
+ }
908
+ return names;
909
+ }
910
+ /**
911
+ * Materialize declared object-level indexes.
912
+ *
913
+ * - Multi-column and single-column indexes are both supported.
914
+ * - `unique: true` emits a UNIQUE index. NULL-distinct semantics are the
915
+ * default across SQLite/Postgres/MySQL, so multiple NULL rows remain
916
+ * allowed while non-NULL duplicates are rejected — matching the
917
+ * convergence-on-conflict pattern the messaging pipeline relies on.
918
+ * - Idempotent: indexes already present (by deterministic name) are
919
+ * skipped, and an "already exists" race is absorbed.
920
+ * - Indexes referencing a column that wasn't materialized (e.g. a virtual
921
+ * `formula` field) are skipped with a warning rather than failing sync.
922
+ */
923
+ async syncDeclaredIndexes(tableName, indexes, physicalColumns) {
924
+ const existing = await this.getExistingIndexNames(tableName);
925
+ for (const idx of indexes) {
926
+ const fields = Array.isArray(idx?.fields) ? idx.fields.filter((f) => typeof f === "string" && f.length > 0) : [];
927
+ if (fields.length === 0) continue;
928
+ const missing = fields.filter((f) => !physicalColumns.has(f));
929
+ if (missing.length > 0) {
930
+ this.logger.warn(
931
+ `[sql-driver] skipping declared index on "${tableName}" \u2014 column(s) not materialized: ${missing.join(", ")}`,
932
+ { tableName, fields }
933
+ );
934
+ continue;
935
+ }
936
+ const unique = idx.unique === true;
937
+ const name = typeof idx.name === "string" && idx.name.trim() ? idx.name.trim() : this.buildIndexName(tableName, fields, unique);
938
+ if (existing.has(name)) continue;
939
+ try {
940
+ await this.knex.schema.alterTable(tableName, (table) => {
941
+ if (unique) {
942
+ table.unique(fields, { indexName: name });
943
+ } else {
944
+ table.index(fields, name);
945
+ }
946
+ });
947
+ existing.add(name);
948
+ } catch (e) {
949
+ const msg = String(e?.message ?? e);
950
+ if (/already exists|duplicate key name|exists/i.test(msg)) continue;
951
+ throw e;
952
+ }
837
953
  }
838
954
  }
839
955
  // ===================================