@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.d.mts +55 -2
- package/dist/index.d.ts +55 -2
- package/dist/index.js +119 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +119 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
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
|
-
|
|
99
|
-
this.
|
|
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
|
|
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
|
// ===================================
|