@happyvertical/smrt-core 0.51.38 → 0.52.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/AGENTS.md +8 -0
- package/agents/schema-paths.md +90 -0
- package/dist/cascade.d.ts.map +1 -1
- package/dist/cascade.js +2 -1
- package/dist/cascade.js.map +1 -1
- package/dist/collection.d.ts +10 -1
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +14 -4
- package/dist/collection.js.map +1 -1
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +21 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/manifest/static-manifest.d.ts.map +1 -1
- package/dist/manifest/static-manifest.js +9 -1
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest/store.js.map +1 -1
- package/dist/manifest.json +9 -1
- package/dist/migrations/differ.d.ts +58 -0
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +187 -32
- package/dist/migrations/differ.js.map +1 -1
- package/dist/migrations/null-equal-indexes.js +1 -1
- package/dist/migrations/orchestrate.d.ts +7 -0
- package/dist/migrations/orchestrate.d.ts.map +1 -1
- package/dist/migrations/orchestrate.js +4 -2
- package/dist/migrations/orchestrate.js.map +1 -1
- package/dist/migrations/tracker.d.ts +29 -1
- package/dist/migrations/tracker.d.ts.map +1 -1
- package/dist/migrations/tracker.js +92 -7
- package/dist/migrations/tracker.js.map +1 -1
- package/dist/object.d.ts +128 -0
- package/dist/object.d.ts.map +1 -1
- package/dist/object.js +333 -44
- package/dist/object.js.map +1 -1
- package/dist/registry/schema-builder.d.ts.map +1 -1
- package/dist/registry/schema-builder.js +7 -5
- package/dist/registry/schema-builder.js.map +1 -1
- package/dist/registry.d.ts +68 -4
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +119 -8
- package/dist/registry.js.map +1 -1
- package/dist/scanner/index.d.ts +1 -1
- package/dist/scanner/index.d.ts.map +1 -1
- package/dist/scanner/manifest-generator.d.ts +3 -2
- package/dist/scanner/manifest-generator.d.ts.map +1 -1
- package/dist/scanner/manifest-generator.js +12 -8
- package/dist/scanner/manifest-generator.js.map +1 -1
- package/dist/scanner/types.d.ts +21 -0
- package/dist/scanner/types.d.ts.map +1 -1
- package/dist/scanner/types.js.map +1 -1
- package/dist/schema/conflict-target.d.ts +68 -11
- package/dist/schema/conflict-target.d.ts.map +1 -1
- package/dist/schema/conflict-target.js +68 -12
- package/dist/schema/conflict-target.js.map +1 -1
- package/dist/schema/foreign-key-ddl.d.ts +17 -1
- package/dist/schema/foreign-key-ddl.d.ts.map +1 -1
- package/dist/schema/foreign-key-ddl.js +29 -3
- package/dist/schema/foreign-key-ddl.js.map +1 -1
- package/dist/schema/foreign-key-policy.d.ts +8 -0
- package/dist/schema/foreign-key-policy.d.ts.map +1 -1
- package/dist/schema/foreign-key-policy.js +2 -1
- package/dist/schema/foreign-key-policy.js.map +1 -1
- package/dist/schema/generator.d.ts +7 -0
- package/dist/schema/generator.d.ts.map +1 -1
- package/dist/schema/generator.js +18 -10
- package/dist/schema/generator.js.map +1 -1
- package/dist/schema/text-cast-probe.d.ts +50 -1
- package/dist/schema/text-cast-probe.d.ts.map +1 -1
- package/dist/schema/text-cast-probe.js +46 -11
- package/dist/schema/text-cast-probe.js.map +1 -1
- package/dist/schema/types.d.ts +6 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/smrt-knowledge.json +12 -7
- package/package.json +10 -10
package/dist/object.js
CHANGED
|
@@ -239,6 +239,22 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
239
239
|
*/
|
|
240
240
|
_insertOnly = false;
|
|
241
241
|
/**
|
|
242
|
+
* The slug {@link getSlug} derived from a human value (`name`, `title` or
|
|
243
|
+
* `label`), or `null` when the slug was set explicitly or derived from the
|
|
244
|
+
* id. A human value is not an identity — two pictures named `photo.jpg`, two
|
|
245
|
+
* articles titled "Council meeting" — so a NEW object whose natural key only
|
|
246
|
+
* matches an existing row through such a slug never adopts that row: it
|
|
247
|
+
* saves under a unique slug instead (see {@link writeNaturalKeyRow}).
|
|
248
|
+
*/
|
|
249
|
+
_derivedSlug = null;
|
|
250
|
+
/** The slug as first derived, before any free-slug move (see above). */
|
|
251
|
+
_derivedSlugBase = null;
|
|
252
|
+
/**
|
|
253
|
+
* Set by `getOrUpsert()`'s create path: a derived slug then names the row
|
|
254
|
+
* to update in place, as an explicit key does, instead of moving.
|
|
255
|
+
*/
|
|
256
|
+
_adoptNaturalKey = false;
|
|
257
|
+
/**
|
|
242
258
|
* Unique identifier for the object
|
|
243
259
|
*/
|
|
244
260
|
_id;
|
|
@@ -380,6 +396,17 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
380
396
|
requireInsertOnSave() {
|
|
381
397
|
this._insertOnly = true;
|
|
382
398
|
}
|
|
399
|
+
/**
|
|
400
|
+
* Makes the initial `save()` update the row its natural key names in place
|
|
401
|
+
* — even when the slug was derived from `name`/`title`/`label` — instead of
|
|
402
|
+
* moving to a free slug. Set by `collection.getOrUpsert()`'s create path,
|
|
403
|
+
* whose contract is "update the row this data identifies". The ownership
|
|
404
|
+
* guard still applies. No effect once the object is persisted.
|
|
405
|
+
* @internal
|
|
406
|
+
*/
|
|
407
|
+
adoptNaturalKeyOnSave() {
|
|
408
|
+
this._adoptNaturalKey = true;
|
|
409
|
+
}
|
|
383
410
|
async verifyStorageReady() {
|
|
384
411
|
await verifyPersistenceTable(this.db, this.tableName, this.constructor.name);
|
|
385
412
|
}
|
|
@@ -804,6 +831,7 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
804
831
|
data._meta_data = metaData;
|
|
805
832
|
}
|
|
806
833
|
let registeredFields = (this.getRegisteredClassInfo() ?? ObjectRegistry.getClass(this.getResolvedQualifiedName()))?.inheritedFields || ObjectRegistry.getFields(this.getResolvedQualifiedName());
|
|
834
|
+
const ownFieldKeys = new Set(registeredFields.keys());
|
|
807
835
|
if (isSTI) {
|
|
808
836
|
const descendants = getSTIHierarchyMembers(this.getResolvedQualifiedName());
|
|
809
837
|
if (descendants.length > 0) {
|
|
@@ -826,9 +854,11 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
826
854
|
const fieldType = prop && typeof prop === "object" && "type" in prop && prop.type || fieldDef?.type;
|
|
827
855
|
if ((prop && typeof prop === "object" && "__tenancy" in prop ? prop.__tenancy : void 0)?.isTenantIdField || fieldDef?.__tenancy?.isTenantIdField || fieldDef?._meta?.__tenancy?.isTenantIdField) if (isSTI && fieldDef?.type === "meta") metaData[key] = null;
|
|
828
856
|
else data[key] = null;
|
|
829
|
-
else if (fieldType === "text")
|
|
830
|
-
|
|
831
|
-
|
|
857
|
+
else if (fieldType === "text") {
|
|
858
|
+
const emptyValue = isSTI && !ownFieldKeys.has(key) ? null : "";
|
|
859
|
+
if (isSTI && fieldDef?.type === "meta") metaData[key] = emptyValue;
|
|
860
|
+
else data[key] = emptyValue;
|
|
861
|
+
} else if (fieldType === "json") {
|
|
832
862
|
const defaultValue = fieldDef?.default ?? null;
|
|
833
863
|
if (isSTI && fieldDef?.type === "meta") metaData[key] = defaultValue;
|
|
834
864
|
else data[key] = defaultValue;
|
|
@@ -1018,7 +1048,11 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1018
1048
|
else if (self.title) sourceField = String(self.title);
|
|
1019
1049
|
else if (self.label) sourceField = String(self.label);
|
|
1020
1050
|
else if (this.id) sourceField = String(this.id);
|
|
1021
|
-
if (sourceField)
|
|
1051
|
+
if (sourceField) {
|
|
1052
|
+
this.slug = sourceField.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
1053
|
+
this._derivedSlug = sourceField === String(this.id) ? null : this.slug;
|
|
1054
|
+
this._derivedSlugBase = this._derivedSlug;
|
|
1055
|
+
}
|
|
1022
1056
|
}
|
|
1023
1057
|
return this.slug;
|
|
1024
1058
|
}
|
|
@@ -1228,13 +1262,32 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1228
1262
|
const columns = prepared[0] ? Object.keys(prepared[0].data) : [];
|
|
1229
1263
|
const conflict = prepared[0]?.conflictColumns ?? [];
|
|
1230
1264
|
const identities = /* @__PURE__ */ new Set();
|
|
1231
|
-
|
|
1265
|
+
const ownershipColumns = SmrtObject.ownershipColumnsFor(first.getResolvedQualifiedName());
|
|
1266
|
+
if ([...ownershipColumns].some((column) => columns.includes(column) && !conflict.includes(column)) || columns.length * added.length > 900 || prepared.some((entry) => {
|
|
1232
1267
|
if (entry.writePlan.type !== "upsert" || JSON.stringify(Object.keys(entry.data)) !== JSON.stringify(columns) || JSON.stringify(entry.conflictColumns) !== JSON.stringify(conflict) || conflict.some((key) => entry.data[key] == null)) return true;
|
|
1233
1268
|
const identity = JSON.stringify(conflict.map((key) => entry.data[key]));
|
|
1234
1269
|
if (identities.has(identity)) return true;
|
|
1235
1270
|
identities.add(identity);
|
|
1236
1271
|
return false;
|
|
1237
1272
|
})) return false;
|
|
1273
|
+
const quote = (identifier) => `"${identifier.replaceAll("\"", "\"\"")}"`;
|
|
1274
|
+
const bindValue = (value) => value instanceof Date ? value.toISOString() : value === void 0 ? null : value !== null && typeof value === "object" ? JSON.stringify(value) : typeof value === "boolean" && engine === "sqlite" ? Number(value) : value;
|
|
1275
|
+
if (added.length > 0 && conflict.length > 0) {
|
|
1276
|
+
const probeValues = [];
|
|
1277
|
+
const placeholder = () => engine === "postgres" ? `$${probeValues.length}` : "?";
|
|
1278
|
+
const clauses = prepared.map(({ data }) => `(${conflict.map((column) => {
|
|
1279
|
+
probeValues.push(bindValue(data[column]));
|
|
1280
|
+
return `${quote(column)} = ${placeholder()}`;
|
|
1281
|
+
}).join(" AND ")})`);
|
|
1282
|
+
const removedIds = new Set(removed.map((item) => String(item.id).toLowerCase()));
|
|
1283
|
+
let existingRows;
|
|
1284
|
+
try {
|
|
1285
|
+
existingRows = (await first.db.query(`SELECT ${quote("id")} FROM ${quote(first.tableName)} WHERE ${clauses.join(" OR ")}`, ...probeValues))?.rows ?? [];
|
|
1286
|
+
} catch {
|
|
1287
|
+
return false;
|
|
1288
|
+
}
|
|
1289
|
+
if (existingRows.some((row) => !removedIds.has(String(row.id).toLowerCase()))) return false;
|
|
1290
|
+
}
|
|
1238
1291
|
const deletions = [];
|
|
1239
1292
|
for (const instance of removed) {
|
|
1240
1293
|
const context = createInterceptorContext(instance.constructor.name, "delete", void 0, void 0, instance.getResolvedQualifiedName());
|
|
@@ -1267,14 +1320,12 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1267
1320
|
await GlobalInterceptors.executeBulkAfter("afterDelete", deletions);
|
|
1268
1321
|
}
|
|
1269
1322
|
if (added.length > 0) {
|
|
1270
|
-
const quote = (identifier) => `"${identifier.replaceAll("\"", "\"\"")}"`;
|
|
1271
1323
|
const values = [];
|
|
1272
1324
|
const tuples = prepared.map(({ data }) => `(${columns.map((column) => {
|
|
1273
|
-
|
|
1274
|
-
values.push(value instanceof Date ? value.toISOString() : value === void 0 ? null : value !== null && typeof value === "object" ? JSON.stringify(value) : typeof value === "boolean" && engine === "sqlite" ? Number(value) : value);
|
|
1325
|
+
values.push(bindValue(data[column]));
|
|
1275
1326
|
return engine === "postgres" ? `$${values.length}` : "?";
|
|
1276
1327
|
}).join(", ")})`);
|
|
1277
|
-
const updates = columns.map((column) => `${quote(column)} = excluded.${quote(column)}`).join(", ");
|
|
1328
|
+
const updates = columns.filter((column) => column !== "id" && column !== "created_at" && !ownershipColumns.has(column)).map((column) => `${quote(column)} = excluded.${quote(column)}`).join(", ");
|
|
1278
1329
|
const sql = `INSERT INTO ${quote(first.tableName)} (${columns.map(quote).join(", ")}) VALUES ${tuples.join(", ")} ON CONFLICT (${conflict.map(quote).join(", ")}) DO UPDATE SET ${updates}`;
|
|
1279
1330
|
await withEmbeddedWriteQueue(first.db, isEmbeddedDatabase(first.db), () => ErrorUtils.withRetry(async () => {
|
|
1280
1331
|
try {
|
|
@@ -1300,6 +1351,14 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1300
1351
|
}
|
|
1301
1352
|
return true;
|
|
1302
1353
|
}
|
|
1354
|
+
/**
|
|
1355
|
+
* The columns that record a row's owner — see
|
|
1356
|
+
* {@link ObjectRegistry.getOwnershipColumns} (declared, `@tenantId`-marked
|
|
1357
|
+
* and runtime-registered ownership columns, plus `tenant_id`).
|
|
1358
|
+
*/
|
|
1359
|
+
static ownershipColumnsFor(qualifiedName) {
|
|
1360
|
+
return ObjectRegistry.getOwnershipColumns(qualifiedName);
|
|
1361
|
+
}
|
|
1303
1362
|
/** Shared preparation for ordinary saves and compatible bulk creates. */
|
|
1304
1363
|
async prepareSave(revisionGuard) {
|
|
1305
1364
|
const className = this.getResolvedClassName();
|
|
@@ -1333,6 +1392,8 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1333
1392
|
data.updated_at = this.updated_at;
|
|
1334
1393
|
await this.coerceEmptyUuidValuesToNull(data);
|
|
1335
1394
|
const conflictColumns = ObjectRegistry.getConflictColumns(this.getResolvedQualifiedName());
|
|
1395
|
+
const ownershipColumn = ObjectRegistry.getOwnershipTenantColumn(this.getResolvedQualifiedName());
|
|
1396
|
+
if (ownershipColumn && conflictColumns.includes(ownershipColumn) && data[ownershipColumn] === void 0) data[ownershipColumn] = null;
|
|
1336
1397
|
const derivedColumns = this.getPersistenceDerivedColumns();
|
|
1337
1398
|
const registeredFields = ObjectRegistry.getFields(this.getResolvedQualifiedName());
|
|
1338
1399
|
const registeredColumns = new Set([...registeredFields].filter(([, field]) => field.type !== "oneToMany" && field.type !== "manyToMany").map(([name]) => toSnakeCase(name)));
|
|
@@ -1446,51 +1507,55 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1446
1507
|
const upsertConflictColumns = this._persisted && data.id ? ["id"] : conflictColumns;
|
|
1447
1508
|
const useEmbeddedRevisionFallback = revisionGuard !== void 0 && usesEmbeddedRevisionFallback(this.db);
|
|
1448
1509
|
const serializeEmbeddedWrite = isEmbeddedDatabase(this.db);
|
|
1510
|
+
const naturalKeyUpsert = writePlan.type === "upsert" && revisionGuard === void 0 && !(this._insertOnly && !this._persisted) && !(upsertConflictColumns.length === 1 && upsertConflictColumns[0] === "id");
|
|
1449
1511
|
let revisionMatched = true;
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1512
|
+
let naturalKeyWrite = "upsert";
|
|
1513
|
+
const adopted = {};
|
|
1514
|
+
await withEmbeddedWriteQueue(this.db, serializeEmbeddedWrite, async () => {
|
|
1515
|
+
if (naturalKeyUpsert) {
|
|
1516
|
+
naturalKeyWrite = await this.writeNaturalKeyRow(data, upsertConflictColumns, adopted);
|
|
1517
|
+
if (naturalKeyWrite === "done") return;
|
|
1518
|
+
} else if (this._insertOnly && !this._persisted) await this.assertNullableNaturalKeyFree(data, conflictColumns);
|
|
1519
|
+
const insertUnderFreshSlug = naturalKeyWrite === "insert";
|
|
1520
|
+
return ErrorUtils.withRetry(async () => {
|
|
1521
|
+
try {
|
|
1522
|
+
if (writePlan.type === "updateById" || revisionGuard !== void 0) {
|
|
1523
|
+
const { id: _id, ...updateData } = data;
|
|
1524
|
+
if (useEmbeddedRevisionFallback) {
|
|
1525
|
+
const current = await this.db.get(this.tableName, { id: data.id });
|
|
1526
|
+
if (!current || !this.revisionsEqual(current.updated_at, revisionGuard)) {
|
|
1527
|
+
revisionMatched = false;
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
const updateResult = useEmbeddedRevisionFallback ? await this.db.upsert(this.tableName, ["id"], data) : await this.db.update(this.tableName, {
|
|
1532
|
+
id: data.id,
|
|
1533
|
+
...revisionGuard !== void 0 ? this.revisionPredicate(revisionGuard) : {}
|
|
1534
|
+
}, updateData);
|
|
1535
|
+
if (revisionGuard !== void 0 && updateResult.affected !== 1) {
|
|
1457
1536
|
revisionMatched = false;
|
|
1458
1537
|
return;
|
|
1459
1538
|
}
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
if (revisionGuard !== void 0 && updateResult.affected !== 1) {
|
|
1466
|
-
revisionMatched = false;
|
|
1467
|
-
return;
|
|
1468
|
-
}
|
|
1469
|
-
if (writePlan.type === "updateById") this.setMetaType(writePlan.qualifiedMetaType);
|
|
1470
|
-
} else if (this._insertOnly && !this._persisted) await this.db.insert(this.tableName, data);
|
|
1471
|
-
else await this.db.upsert(this.tableName, upsertConflictColumns, data);
|
|
1472
|
-
} catch (error) {
|
|
1473
|
-
if (error instanceof Error) {
|
|
1474
|
-
const classification = classifyDatabaseError(error);
|
|
1475
|
-
if (classification.kind === "unique_violation") {
|
|
1476
|
-
const field = this.extractConstraintFieldFromChain(error, classification);
|
|
1477
|
-
throw ValidationError.uniqueConstraint(field, this.getFieldValue(field));
|
|
1478
|
-
}
|
|
1479
|
-
if (classification.kind === "not_null_violation") {
|
|
1480
|
-
const field = this.extractConstraintFieldFromChain(error, classification);
|
|
1481
|
-
throw ValidationError.requiredField(field, className);
|
|
1482
|
-
}
|
|
1483
|
-
const operation = writePlan.type === "updateById" || revisionGuard !== void 0 ? `UPDATE ${this.tableName} (id-targeted)` : this._insertOnly && !this._persisted ? `INSERT INTO ${this.tableName}` : `UPSERT INTO ${this.tableName}`;
|
|
1484
|
-
throw DatabaseError.queryFailed(operation, error);
|
|
1539
|
+
if (writePlan.type === "updateById") this.setMetaType(writePlan.qualifiedMetaType);
|
|
1540
|
+
} else if (this._insertOnly && !this._persisted || insertUnderFreshSlug) await this.db.insert(this.tableName, data);
|
|
1541
|
+
else await this.db.upsert(this.tableName, upsertConflictColumns, data);
|
|
1542
|
+
} catch (error) {
|
|
1543
|
+
throw this.toTypedWriteError(error, writePlan.type === "updateById" || revisionGuard !== void 0 ? `UPDATE ${this.tableName} (id-targeted)` : this._insertOnly && !this._persisted || insertUnderFreshSlug ? `INSERT INTO ${this.tableName}` : `UPSERT INTO ${this.tableName}`);
|
|
1485
1544
|
}
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
}, 3, 500));
|
|
1545
|
+
}, 3, 500);
|
|
1546
|
+
});
|
|
1489
1547
|
if (!revisionMatched) throw new RuntimeError(`Revision conflict while saving ${className}`, "RUNTIME_REVISION_CONFLICT", {
|
|
1490
1548
|
className,
|
|
1491
1549
|
id: this.id
|
|
1492
1550
|
});
|
|
1493
1551
|
revisionPersisted = true;
|
|
1552
|
+
if (adopted.id) {
|
|
1553
|
+
this.id = adopted.id;
|
|
1554
|
+
if (adopted.createdAt != null) {
|
|
1555
|
+
const createdAt = new Date(adopted.createdAt);
|
|
1556
|
+
if (!Number.isNaN(createdAt.getTime())) this.created_at = createdAt;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1494
1559
|
await this.completeSave(interceptorContext);
|
|
1495
1560
|
return this;
|
|
1496
1561
|
} catch (error) {
|
|
@@ -1598,6 +1663,230 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1598
1663
|
return new Date(nextRevisionTime);
|
|
1599
1664
|
}
|
|
1600
1665
|
/**
|
|
1666
|
+
* Read the row a natural-key write would hit, directly (no read
|
|
1667
|
+
* interceptors), retrying only transient failures like the write itself.
|
|
1668
|
+
*/
|
|
1669
|
+
async readNaturalKeyRow(filter) {
|
|
1670
|
+
return ErrorUtils.withRetry(async () => {
|
|
1671
|
+
try {
|
|
1672
|
+
return await this.getCanonicalPersistedRow(filter);
|
|
1673
|
+
} catch (error) {
|
|
1674
|
+
if (error instanceof SmrtError) throw error;
|
|
1675
|
+
throw DatabaseError.queryFailed(`get(${this.tableName}, natural-key identity)`, error instanceof Error ? error : new Error(String(error)));
|
|
1676
|
+
}
|
|
1677
|
+
}, 3, 500);
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* Strict inserts (`_insertOnly`, #1759) rely on the unique conflict index to
|
|
1681
|
+
* reject a natural-key collision. A NULL in that key (a global row of a
|
|
1682
|
+
* tenant-owned table, whose key leads with `tenant_id`) is distinct from
|
|
1683
|
+
* every other NULL on SQLite, DuckDB and PostgreSQL before 15, so the index
|
|
1684
|
+
* admits the duplicate the upsert path would have deduplicated. Check with
|
|
1685
|
+
* the same `IS NULL` match the null-aware upsert uses and raise the unique
|
|
1686
|
+
* violation the index would have.
|
|
1687
|
+
*/
|
|
1688
|
+
async assertNullableNaturalKeyFree(data, conflictColumns) {
|
|
1689
|
+
if (conflictColumns.length === 0 || conflictColumns.length === 1 && conflictColumns[0] === "id" || !conflictColumns.every((column) => Object.hasOwn(data, column)) || !conflictColumns.some((column) => data[column] == null)) return;
|
|
1690
|
+
const filter = {};
|
|
1691
|
+
for (const column of conflictColumns) filter[column] = data[column] ?? null;
|
|
1692
|
+
const existing = await this.readNaturalKeyRow(filter);
|
|
1693
|
+
if (existing && existing.id !== data.id) {
|
|
1694
|
+
const column = conflictColumns.find((name) => data[name] != null) ?? conflictColumns[0];
|
|
1695
|
+
throw ValidationError.uniqueConstraint(column, data[column]);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* Map a failed write to the typed error contract. `@happyvertical/sql` wraps
|
|
1700
|
+
* every driver error as `DatabaseError('Failed to upsert record into
|
|
1701
|
+
* table', { …, originalError })`, so the constraint wording never reaches
|
|
1702
|
+
* `error.message`. Classify through the whole cause chain — driver codes
|
|
1703
|
+
* first (SQLSTATE / SQLite result codes), dialect wording only as the
|
|
1704
|
+
* DuckDB fallback — to restore the typed-error parity the docs promise
|
|
1705
|
+
* (#1378, #2366).
|
|
1706
|
+
*/
|
|
1707
|
+
toTypedWriteError(error, operation) {
|
|
1708
|
+
if (!(error instanceof Error)) return error;
|
|
1709
|
+
const classification = classifyDatabaseError(error);
|
|
1710
|
+
if (classification.kind === "unique_violation") {
|
|
1711
|
+
const field = this.extractConstraintFieldFromChain(error, classification);
|
|
1712
|
+
return ValidationError.uniqueConstraint(field, this.getFieldValue(field));
|
|
1713
|
+
}
|
|
1714
|
+
if (classification.kind === "not_null_violation") {
|
|
1715
|
+
const field = this.extractConstraintFieldFromChain(error, classification);
|
|
1716
|
+
return ValidationError.requiredField(field, this.getResolvedClassName());
|
|
1717
|
+
}
|
|
1718
|
+
return DatabaseError.queryFailed(operation, error);
|
|
1719
|
+
}
|
|
1720
|
+
/**
|
|
1721
|
+
* Natural-key write for a NEW object (identity guard).
|
|
1722
|
+
*
|
|
1723
|
+
* A NEW object upserts on its natural key (`getConflictColumns()`), and the
|
|
1724
|
+
* SDK's `ON CONFLICT (…) DO UPDATE SET` rewrites EVERY column of the matched
|
|
1725
|
+
* row — including `id` and the tenant column. Two failure modes followed:
|
|
1726
|
+
*
|
|
1727
|
+
* - **Cross-tenant takeover.** When the conflict target omits the owner (an
|
|
1728
|
+
* explicit key without it, or a live table still carrying a global
|
|
1729
|
+
* `(slug, context)` unique), tenant B's save of a key tenant A already
|
|
1730
|
+
* owns matched A's row and rewrote its `id` and owner; with
|
|
1731
|
+
* `ON UPDATE CASCADE` A's children followed it into tenant B. The tenancy
|
|
1732
|
+
* interceptor cannot prevent this: it filters B's LOOKUP only.
|
|
1733
|
+
* - **Primary-key churn.** A fresh instance saved over an existing same-owner
|
|
1734
|
+
* row replaced that row's primary key with the instance's new UUID.
|
|
1735
|
+
*
|
|
1736
|
+
* The row the conflict target matches is read directly, bypassing read
|
|
1737
|
+
* interceptors (the collision is a storage fact whatever the caller may
|
|
1738
|
+
* read). A different owner on any ownership column
|
|
1739
|
+
* ({@link ObjectRegistry.getOwnershipColumns}: `tenant_id`, the declared
|
|
1740
|
+
* tenant column, `@tenantId` fields and runtime tenancy registrations) is
|
|
1741
|
+
* refused with {@link TenantIsolationError} (`TENANT_ISOLATION_VIOLATION`)
|
|
1742
|
+
* — under `withSystemContext()` too, and an owner the save leaves unset
|
|
1743
|
+
* counts as NULL: a tenant save never adopts a global row and a global save
|
|
1744
|
+
* never adopts a tenant's row. A same-owner row is adopted: its id and
|
|
1745
|
+
* creation time replace the instance's, so `DO UPDATE` keeps the primary
|
|
1746
|
+
* key and `created_at`.
|
|
1747
|
+
*
|
|
1748
|
+
* A slug {@link getSlug} derived from `name`/`title`/`label` is a human
|
|
1749
|
+
* value, not an identity: such an object never adopts a row (whoever owns
|
|
1750
|
+
* it) and moves to the first free `<slug>-2` … `<slug>-9`, then
|
|
1751
|
+
* `<slug>-<id>`, and INSERTs. `getOrUpsert()` opts back into adoption: its
|
|
1752
|
+
* create path is keyed by the natural key it would have found.
|
|
1753
|
+
*
|
|
1754
|
+
* Concurrency. On PostgreSQL, when no conflict value is NULL, the write is
|
|
1755
|
+
* `INSERT … ON CONFLICT (…) DO NOTHING RETURNING id` first: a new key costs
|
|
1756
|
+
* one statement and no pre-read, and two concurrent first creates can never
|
|
1757
|
+
* both take the upsert path — the loser sees the winner's row and adopts it
|
|
1758
|
+
* (explicit key) or moves (derived slug), retrying a bounded number of
|
|
1759
|
+
* times. Elsewhere (embedded engines, serialized by the in-process write
|
|
1760
|
+
* queue, and NULL-bearing keys, which a standard unique index cannot
|
|
1761
|
+
* arbitrate) the row is read first.
|
|
1762
|
+
*
|
|
1763
|
+
* @returns how the caller writes: `'done'` (already inserted), `'insert'`
|
|
1764
|
+
* (plain INSERT) or `'upsert'` (natural-key upsert, identity adopted).
|
|
1765
|
+
*/
|
|
1766
|
+
async writeNaturalKeyRow(data, conflictColumns, adopted) {
|
|
1767
|
+
if (conflictColumns.length === 0 || !conflictColumns.every((column) => Object.hasOwn(data, column))) return "upsert";
|
|
1768
|
+
const derived = !this._adoptNaturalKey && conflictColumns.includes("slug") && this._derivedSlug !== null && data.slug === this._derivedSlug;
|
|
1769
|
+
const conflictFilterOf = () => {
|
|
1770
|
+
const filter = {};
|
|
1771
|
+
for (const column of conflictColumns) {
|
|
1772
|
+
const value = data[column];
|
|
1773
|
+
filter[column] = value === void 0 ? null : value;
|
|
1774
|
+
}
|
|
1775
|
+
return filter;
|
|
1776
|
+
};
|
|
1777
|
+
const atomic = isPostgresDatabase(this.db) && conflictColumns.every((column) => data[column] != null);
|
|
1778
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
1779
|
+
const conflictFilter = conflictFilterOf();
|
|
1780
|
+
if (atomic) try {
|
|
1781
|
+
if (await this.insertIfAbsent(data, conflictColumns)) return "done";
|
|
1782
|
+
} catch (error) {
|
|
1783
|
+
throw this.toTypedWriteError(error, `INSERT INTO ${this.tableName}`);
|
|
1784
|
+
}
|
|
1785
|
+
const existing = await this.readNaturalKeyRow(conflictFilter);
|
|
1786
|
+
if (!existing) {
|
|
1787
|
+
if (atomic) continue;
|
|
1788
|
+
return derived ? "insert" : "upsert";
|
|
1789
|
+
}
|
|
1790
|
+
if (derived) {
|
|
1791
|
+
await this.moveToFreeDerivedSlug(data, conflictFilter, attempt > 0);
|
|
1792
|
+
if (atomic) continue;
|
|
1793
|
+
return "insert";
|
|
1794
|
+
}
|
|
1795
|
+
this.assertSameNaturalKeyOwner(data, existing, conflictColumns, conflictFilter);
|
|
1796
|
+
const existingId = existing.id;
|
|
1797
|
+
if (typeof existingId === "string" && existingId !== "" && existingId !== data.id) {
|
|
1798
|
+
data.id = existingId;
|
|
1799
|
+
adopted.id = existingId;
|
|
1800
|
+
if (existing.created_at != null && existing.created_at !== "") {
|
|
1801
|
+
data.created_at = existing.created_at;
|
|
1802
|
+
adopted.createdAt = existing.created_at;
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
return "upsert";
|
|
1806
|
+
}
|
|
1807
|
+
throw DatabaseError.queryFailed(`INSERT INTO ${this.tableName} (natural key)`, /* @__PURE__ */ new Error("The natural key kept colliding with concurrent writes; retry the save."));
|
|
1808
|
+
}
|
|
1809
|
+
/**
|
|
1810
|
+
* Refuse a natural-key collision with a row owned by someone else. Every
|
|
1811
|
+
* ownership column the stored row carries is compared; one the save leaves
|
|
1812
|
+
* unset counts as NULL (a global save never adopts a tenant's row).
|
|
1813
|
+
*/
|
|
1814
|
+
assertSameNaturalKeyOwner(data, existing, conflictColumns, conflictFilter) {
|
|
1815
|
+
const ownershipColumns = SmrtObject.ownershipColumnsFor(this.getResolvedQualifiedName());
|
|
1816
|
+
const normalizeOwner = (value) => {
|
|
1817
|
+
if (value === null || value === void 0 || value === "") return null;
|
|
1818
|
+
return String(value).trim().toLowerCase();
|
|
1819
|
+
};
|
|
1820
|
+
const differing = [...ownershipColumns].filter((column) => Object.hasOwn(existing, column) && normalizeOwner(data[column]) !== normalizeOwner(existing[column]));
|
|
1821
|
+
if (differing.length === 0) return;
|
|
1822
|
+
const ownTenant = data[differing[0]];
|
|
1823
|
+
throw TenantIsolationError.naturalKeyOwnedElsewhere({
|
|
1824
|
+
className: this.getResolvedClassName(),
|
|
1825
|
+
tableName: this.tableName,
|
|
1826
|
+
conflictColumns,
|
|
1827
|
+
conflictValues: conflictFilter,
|
|
1828
|
+
ownershipColumns: differing,
|
|
1829
|
+
tenantId: typeof ownTenant === "string" ? ownTenant : null
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
/**
|
|
1833
|
+
* PostgreSQL: `INSERT … ON CONFLICT (conflict) DO NOTHING RETURNING id`.
|
|
1834
|
+
* Values are serialized exactly as the SDK's insert does (undefined
|
|
1835
|
+
* omitted, Date → ISO, objects → JSON, buffers as-is). A conflict on the
|
|
1836
|
+
* natural key inserts nothing and returns `false`; any other constraint
|
|
1837
|
+
* error propagates for the caller's typed classification.
|
|
1838
|
+
*/
|
|
1839
|
+
async insertIfAbsent(data, conflictColumns) {
|
|
1840
|
+
const quote = (identifier) => `"${identifier.replaceAll("\"", "\"\"")}"`;
|
|
1841
|
+
const columns = [];
|
|
1842
|
+
const values = [];
|
|
1843
|
+
for (const [column, value] of Object.entries(data)) {
|
|
1844
|
+
if (value === void 0) continue;
|
|
1845
|
+
columns.push(quote(column));
|
|
1846
|
+
values.push(value === null ? null : value instanceof Date ? value.toISOString() : Buffer.isBuffer(value) || ArrayBuffer.isView(value) ? value : typeof value === "object" ? JSON.stringify(value) : value);
|
|
1847
|
+
}
|
|
1848
|
+
const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
|
|
1849
|
+
return ((await this.db.query(`INSERT INTO ${quote(this.tableName)} (${columns.join(", ")}) VALUES (${placeholders}) ON CONFLICT (${conflictColumns.map(quote).join(", ")}) DO NOTHING RETURNING ${quote("id")}`, ...values))?.rows?.length ?? 0) > 0;
|
|
1850
|
+
}
|
|
1851
|
+
/**
|
|
1852
|
+
* Moves a NEW object's taken derived slug to the first free `<base>-2` …
|
|
1853
|
+
* `<base>-9` under the same natural key (any owner's row counts as taken),
|
|
1854
|
+
* found with ONE query, then to `<base>-<first 8 id characters>`, which only
|
|
1855
|
+
* this object's id can claim (directly, with `idSuffixOnly`, after a
|
|
1856
|
+
* numbered slug was lost to a concurrent create). `<base>` is the slug as first derived, so a
|
|
1857
|
+
* retried save never compounds suffixes (`foo-2-2`). The caller INSERTs,
|
|
1858
|
+
* so a concurrent claim of the same slug raises or retries instead of
|
|
1859
|
+
* overwriting.
|
|
1860
|
+
*/
|
|
1861
|
+
async moveToFreeDerivedSlug(data, conflictFilter, idSuffixOnly = false) {
|
|
1862
|
+
const base = this._derivedSlugBase ?? String(data.slug);
|
|
1863
|
+
const idSlug = `${base}-${String(data.id).replace(/-/g, "").slice(0, 8)}`;
|
|
1864
|
+
if (idSuffixOnly) {
|
|
1865
|
+
data.slug = idSlug;
|
|
1866
|
+
this.slug = idSlug;
|
|
1867
|
+
this._derivedSlug = idSlug;
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
const candidates = Array.from({ length: 8 }, (_, index) => `${base}-${index + 2}`);
|
|
1871
|
+
const { slug: _slug, ...scope } = conflictFilter;
|
|
1872
|
+
const takenRows = await ErrorUtils.withRetry(async () => {
|
|
1873
|
+
try {
|
|
1874
|
+
return await this.db.list(this.tableName, {
|
|
1875
|
+
...scope,
|
|
1876
|
+
"slug in": candidates
|
|
1877
|
+
});
|
|
1878
|
+
} catch (error) {
|
|
1879
|
+
if (error instanceof SmrtError) throw error;
|
|
1880
|
+
throw DatabaseError.queryFailed(`list(${this.tableName}, free derived slug)`, error instanceof Error ? error : new Error(String(error)));
|
|
1881
|
+
}
|
|
1882
|
+
}, 3, 500);
|
|
1883
|
+
const taken = new Set(takenRows.map((row) => String(row.slug)));
|
|
1884
|
+
const slug = candidates.find((candidate) => candidate !== data.slug && !taken.has(candidate)) ?? idSlug;
|
|
1885
|
+
data.slug = slug;
|
|
1886
|
+
this.slug = slug;
|
|
1887
|
+
this._derivedSlug = slug;
|
|
1888
|
+
}
|
|
1889
|
+
/**
|
|
1601
1890
|
* Detects tenant-boundary errors that must propagate from `save()` unwrapped.
|
|
1602
1891
|
*
|
|
1603
1892
|
* Matches both core's {@link TenantIsolationError} and the duck-typed
|