@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.
Files changed (79) hide show
  1. package/AGENTS.md +8 -0
  2. package/agents/schema-paths.md +90 -0
  3. package/dist/cascade.d.ts.map +1 -1
  4. package/dist/cascade.js +2 -1
  5. package/dist/cascade.js.map +1 -1
  6. package/dist/collection.d.ts +10 -1
  7. package/dist/collection.d.ts.map +1 -1
  8. package/dist/collection.js +14 -4
  9. package/dist/collection.js.map +1 -1
  10. package/dist/errors.d.ts +18 -0
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/errors.js +21 -0
  13. package/dist/errors.js.map +1 -1
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -1
  17. package/dist/manifest/static-manifest.d.ts.map +1 -1
  18. package/dist/manifest/static-manifest.js +9 -1
  19. package/dist/manifest/static-manifest.js.map +1 -1
  20. package/dist/manifest/store.js +1 -1
  21. package/dist/manifest/store.js.map +1 -1
  22. package/dist/manifest.json +9 -1
  23. package/dist/migrations/differ.d.ts +58 -0
  24. package/dist/migrations/differ.d.ts.map +1 -1
  25. package/dist/migrations/differ.js +187 -32
  26. package/dist/migrations/differ.js.map +1 -1
  27. package/dist/migrations/null-equal-indexes.js +1 -1
  28. package/dist/migrations/orchestrate.d.ts +7 -0
  29. package/dist/migrations/orchestrate.d.ts.map +1 -1
  30. package/dist/migrations/orchestrate.js +4 -2
  31. package/dist/migrations/orchestrate.js.map +1 -1
  32. package/dist/migrations/tracker.d.ts +29 -1
  33. package/dist/migrations/tracker.d.ts.map +1 -1
  34. package/dist/migrations/tracker.js +92 -7
  35. package/dist/migrations/tracker.js.map +1 -1
  36. package/dist/object.d.ts +128 -0
  37. package/dist/object.d.ts.map +1 -1
  38. package/dist/object.js +333 -44
  39. package/dist/object.js.map +1 -1
  40. package/dist/registry/schema-builder.d.ts.map +1 -1
  41. package/dist/registry/schema-builder.js +7 -5
  42. package/dist/registry/schema-builder.js.map +1 -1
  43. package/dist/registry.d.ts +68 -4
  44. package/dist/registry.d.ts.map +1 -1
  45. package/dist/registry.js +119 -8
  46. package/dist/registry.js.map +1 -1
  47. package/dist/scanner/index.d.ts +1 -1
  48. package/dist/scanner/index.d.ts.map +1 -1
  49. package/dist/scanner/manifest-generator.d.ts +3 -2
  50. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  51. package/dist/scanner/manifest-generator.js +12 -8
  52. package/dist/scanner/manifest-generator.js.map +1 -1
  53. package/dist/scanner/types.d.ts +21 -0
  54. package/dist/scanner/types.d.ts.map +1 -1
  55. package/dist/scanner/types.js.map +1 -1
  56. package/dist/schema/conflict-target.d.ts +68 -11
  57. package/dist/schema/conflict-target.d.ts.map +1 -1
  58. package/dist/schema/conflict-target.js +68 -12
  59. package/dist/schema/conflict-target.js.map +1 -1
  60. package/dist/schema/foreign-key-ddl.d.ts +17 -1
  61. package/dist/schema/foreign-key-ddl.d.ts.map +1 -1
  62. package/dist/schema/foreign-key-ddl.js +29 -3
  63. package/dist/schema/foreign-key-ddl.js.map +1 -1
  64. package/dist/schema/foreign-key-policy.d.ts +8 -0
  65. package/dist/schema/foreign-key-policy.d.ts.map +1 -1
  66. package/dist/schema/foreign-key-policy.js +2 -1
  67. package/dist/schema/foreign-key-policy.js.map +1 -1
  68. package/dist/schema/generator.d.ts +7 -0
  69. package/dist/schema/generator.d.ts.map +1 -1
  70. package/dist/schema/generator.js +18 -10
  71. package/dist/schema/generator.js.map +1 -1
  72. package/dist/schema/text-cast-probe.d.ts +50 -1
  73. package/dist/schema/text-cast-probe.d.ts.map +1 -1
  74. package/dist/schema/text-cast-probe.js +46 -11
  75. package/dist/schema/text-cast-probe.js.map +1 -1
  76. package/dist/schema/types.d.ts +6 -0
  77. package/dist/schema/types.d.ts.map +1 -1
  78. package/dist/smrt-knowledge.json +12 -7
  79. 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") if (isSTI && fieldDef?.type === "meta") metaData[key] = "";
830
- else data[key] = "";
831
- else if (fieldType === "json") {
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) this.slug = sourceField.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
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
- if (columns.length * added.length > 900 || prepared.some((entry) => {
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
- const value = data[column];
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
- await withEmbeddedWriteQueue(this.db, serializeEmbeddedWrite, () => ErrorUtils.withRetry(async () => {
1451
- try {
1452
- if (writePlan.type === "updateById" || revisionGuard !== void 0) {
1453
- const { id: _id, ...updateData } = data;
1454
- if (useEmbeddedRevisionFallback) {
1455
- const current = await this.db.get(this.tableName, { id: data.id });
1456
- if (!current || !this.revisionsEqual(current.updated_at, revisionGuard)) {
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
- const updateResult = useEmbeddedRevisionFallback ? await this.db.upsert(this.tableName, ["id"], data) : await this.db.update(this.tableName, {
1462
- id: data.id,
1463
- ...revisionGuard !== void 0 ? this.revisionPredicate(revisionGuard) : {}
1464
- }, updateData);
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
- throw error;
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