@happyvertical/smrt-core 0.60.8 → 0.60.10

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 (72) hide show
  1. package/README.md +36 -0
  2. package/agents/browser-entry.md +10 -0
  3. package/agents/change-feed.md +33 -0
  4. package/agents/object-runtime.md +8 -0
  5. package/agents/schema-paths.md +6 -0
  6. package/dist/browser.d.ts +2 -1
  7. package/dist/browser.d.ts.map +1 -1
  8. package/dist/browser.js +3 -2
  9. package/dist/byo-inference.d.ts +55 -0
  10. package/dist/byo-inference.d.ts.map +1 -0
  11. package/dist/byo-inference.js +113 -0
  12. package/dist/byo-inference.js.map +1 -0
  13. package/dist/cascade.d.ts +17 -3
  14. package/dist/cascade.d.ts.map +1 -1
  15. package/dist/cascade.js +30 -15
  16. package/dist/cascade.js.map +1 -1
  17. package/dist/collection.d.ts.map +1 -1
  18. package/dist/collection.js +8 -4
  19. package/dist/collection.js.map +1 -1
  20. package/dist/embedded-write-queue.d.ts +18 -7
  21. package/dist/embedded-write-queue.d.ts.map +1 -1
  22. package/dist/embedded-write-queue.js +40 -10
  23. package/dist/embedded-write-queue.js.map +1 -1
  24. package/dist/index.d.ts +2 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +3 -2
  27. package/dist/manifest/static-manifest.d.ts.map +1 -1
  28. package/dist/manifest/static-manifest.js +25 -7
  29. package/dist/manifest/static-manifest.js.map +1 -1
  30. package/dist/manifest/store.js +1 -1
  31. package/dist/manifest/store.js.map +1 -1
  32. package/dist/manifest.json +39 -7
  33. package/dist/migrations/differ.d.ts +4 -0
  34. package/dist/migrations/differ.d.ts.map +1 -1
  35. package/dist/migrations/differ.js +25 -9
  36. package/dist/migrations/differ.js.map +1 -1
  37. package/dist/migrations/orchestrate.d.ts +1 -1
  38. package/dist/migrations/orchestrate.d.ts.map +1 -1
  39. package/dist/migrations/orchestrate.js +5 -14
  40. package/dist/migrations/orchestrate.js.map +1 -1
  41. package/dist/native-duckdb.d.ts.map +1 -1
  42. package/dist/native-duckdb.js +5 -1
  43. package/dist/native-duckdb.js.map +1 -1
  44. package/dist/object.d.ts +26 -0
  45. package/dist/object.d.ts.map +1 -1
  46. package/dist/object.js +215 -55
  47. package/dist/object.js.map +1 -1
  48. package/dist/schema/generator.d.ts.map +1 -1
  49. package/dist/schema/generator.js +8 -4
  50. package/dist/schema/generator.js.map +1 -1
  51. package/dist/scripts/browser-entry-evaluation.mjs +63 -8
  52. package/dist/scripts/browser-unbundled-dev.mjs +5 -2
  53. package/dist/scripts/fixtures/browser-pglite-cascade.ts +107 -0
  54. package/dist/scripts/fixtures/pglite-peer/package.json +9 -0
  55. package/dist/scripts/fixtures/pglite-peer/peer.js +1 -0
  56. package/dist/scripts/fixtures/pglite-peer/pnpm-lock.yaml +22 -0
  57. package/dist/scripts/fixtures/pglite-peer/pnpm-workspace.yaml +1 -0
  58. package/dist/smrt-knowledge.json +20 -11
  59. package/dist/smrt-knowledge.okf.md +181 -0
  60. package/dist/sync-device.d.ts +6 -0
  61. package/dist/sync-device.d.ts.map +1 -0
  62. package/dist/sync-device.js +18 -0
  63. package/dist/sync-device.js.map +1 -0
  64. package/dist/sync-tombstones.d.ts +30 -0
  65. package/dist/sync-tombstones.d.ts.map +1 -0
  66. package/dist/sync-tombstones.js +112 -0
  67. package/dist/sync-tombstones.js.map +1 -0
  68. package/dist/system/schema.d.ts +5 -1
  69. package/dist/system/schema.d.ts.map +1 -1
  70. package/dist/system/schema.js +21 -4
  71. package/dist/system/schema.js.map +1 -1
  72. package/package.json +16 -11
package/dist/object.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { classifyDatabaseError, classifyDialectMessage } from "./db-errors.js";
2
2
  import { buildWhere } from "./host.js";
3
- import { isKnownNativeDuckDbHandle } from "./native-duckdb.js";
4
- import { isEmbeddedDatabase, isPostgresDatabase, usesEmbeddedRevisionFallback, withEmbeddedWriteQueue, withEmbeddedWriteTransaction } from "./embedded-write-queue.js";
3
+ import { isJsonAdapterHandle, isKnownNativeDuckDbHandle } from "./native-duckdb.js";
4
+ import { getDatabaseSyncDeviceId } from "./sync-device.js";
5
+ import { isEmbeddedDatabase, isKnownDatabaseTransaction, isPostgresDatabase, usesEmbeddedRevisionFallback, usesPostgresDialect, withEmbeddedWriteQueue, withEmbeddedWriteTransaction } from "./embedded-write-queue.js";
5
6
  import { SmrtError } from "./smrt-error.js";
6
7
  import { DatabaseError, ErrorUtils, RuntimeError, TenantIsolationError, ValidationError } from "./errors.js";
8
+ import { bindSyncRevision, compareSyncRevisions, deleteSyncRows, lockSyncRowIdentity, putSyncTombstone, syncRevisionConflict, validateSyncRevision } from "./sync-tombstones.js";
7
9
  import { toSnakeCase } from "./utils/naming.js";
8
- import { buildCascadePlan, cascadeReferencesTo, runCascadeDelete, runInOwningCascadeTransaction } from "./cascade.js";
10
+ import { abortJoinedDuckDbTransaction, buildCascadePlan, cascadeReferencesTo, recordCascadeWrite, runCascadeDelete, runInOwningCascadeTransaction } from "./cascade.js";
9
11
  import { broadcastCacheInvalidation, hasCrossProcessCacheInterest, invalidateCollectionCache, resolveDbCacheKey } from "./collection-cache.js";
10
12
  import { GlobalInterceptors, createInterceptorContext, resolveGetStringFilter } from "./interceptors.js";
11
13
  import { detectEngine } from "./schema/ddl/index.js";
@@ -232,6 +234,7 @@ var SmrtObject = class SmrtObject extends SmrtClass {
232
234
  * (issue #1472).
233
235
  */
234
236
  _persisted = false;
237
+ _explicitId = false;
235
238
  /**
236
239
  * When set (and the object is not yet persisted), `save()` performs a plain
237
240
  * INSERT instead of an upsert, so ANY collision — primary key or natural
@@ -278,6 +281,43 @@ var SmrtObject = class SmrtObject extends SmrtClass {
278
281
  * Last update timestamp
279
282
  */
280
283
  updated_at = null;
284
+ _syncMetadata = null;
285
+ /** Sync state read from or committed to the backing row. */
286
+ get syncMetadata() {
287
+ return this._syncMetadata;
288
+ }
289
+ /** @internal Read only from a physical row, never from model input or STI JSON. */
290
+ hydrateSyncMetadata(row) {
291
+ const origin = row._sync_origin_device_id;
292
+ const revision = Number(row._sync_revision);
293
+ this._syncMetadata = typeof origin === "string" && Number.isSafeInteger(revision) && revision > 0 ? Object.freeze({
294
+ originDeviceId: origin,
295
+ revision,
296
+ tombstone: false
297
+ }) : null;
298
+ }
299
+ normalizeSyncRowId(id) {
300
+ if (ObjectRegistry.getConfig(this.getResolvedQualifiedName()).idType === "text" || !usesPostgresDialect(this.db) && !this.isPositivelyNativeDuckDb()) return id;
301
+ const hex = id.replace(/[-{}]/g, "").toLowerCase();
302
+ return /^[0-9a-f]{32}$/.test(hex) ? [
303
+ hex.slice(0, 8),
304
+ hex.slice(8, 12),
305
+ hex.slice(12, 16),
306
+ hex.slice(16, 20),
307
+ hex.slice(20)
308
+ ].join("-") : id;
309
+ }
310
+ nextSyncMetadata(write, trackSync = false) {
311
+ const previous = this._syncMetadata;
312
+ if (!write && !previous && !trackSync) return null;
313
+ if (write && trackSync) throw new ValidationError("Choose either local tracking or an incoming revision", "VALIDATION_SYNC_METADATA");
314
+ const revision = validateSyncRevision(write?.originDeviceId ?? getDatabaseSyncDeviceId(this.db), write ? write.revision : (previous?.revision ?? 0) + 1);
315
+ if (write && previous && compareSyncRevisions(revision, previous) <= 0) throw syncRevisionConflict(this.id);
316
+ return Object.freeze({
317
+ ...revision,
318
+ tombstone: false
319
+ });
320
+ }
281
321
  /**
282
322
  * Creates a new SmrtObject instance
283
323
  *
@@ -289,6 +329,7 @@ var SmrtObject = class SmrtObject extends SmrtClass {
289
329
  this.options = options;
290
330
  if (options === null) throw new Error("options cant be null");
291
331
  this._id = options.id || null;
332
+ this._explicitId = Boolean(options.id);
292
333
  this._slug = options.slug || null;
293
334
  this._context = options.context || "";
294
335
  if (this.constructor !== SmrtObject && !ObjectRegistry.getClassByConstructor(this.constructor) && !options?._skipRegistration) ObjectRegistry.register(this.constructor, {});
@@ -368,6 +409,7 @@ var SmrtObject = class SmrtObject extends SmrtClass {
368
409
  * @internal
369
410
  */
370
411
  setId(id) {
412
+ if (id !== this._id) this._explicitId = true;
371
413
  this._id = id;
372
414
  }
373
415
  /**
@@ -436,7 +478,8 @@ var SmrtObject = class SmrtObject extends SmrtClass {
436
478
  context: this._context,
437
479
  createdAt: this.created_at instanceof Date ? new Date(this.created_at) : this.created_at,
438
480
  updatedAt: this.updated_at instanceof Date ? new Date(this.updated_at) : this.updated_at,
439
- persisted: this._persisted
481
+ persisted: this._persisted,
482
+ syncMetadata: this._syncMetadata
440
483
  };
441
484
  try {
442
485
  return await withEmbeddedWriteTransaction(database, isEmbeddedDatabase(database), (transaction) => this.withDatabase(transaction, operation));
@@ -447,6 +490,7 @@ var SmrtObject = class SmrtObject extends SmrtClass {
447
490
  this.created_at = persistence.createdAt;
448
491
  this.updated_at = persistence.updatedAt;
449
492
  this._persisted = persistence.persisted;
493
+ this._syncMetadata = persistence.syncMetadata;
450
494
  throw error;
451
495
  }
452
496
  }
@@ -521,6 +565,7 @@ var SmrtObject = class SmrtObject extends SmrtClass {
521
565
  */
522
566
  set id(value) {
523
567
  if (!value || value === "undefined" || value === "null") throw new Error(`id is required, ${value} given`);
568
+ if (value !== this._id) this._explicitId = true;
524
569
  this._id = value;
525
570
  }
526
571
  /**
@@ -678,6 +723,7 @@ var SmrtObject = class SmrtObject extends SmrtClass {
678
723
  skippedCount,
679
724
  totalFields: Object.keys(fields).length
680
725
  });
726
+ this.hydrateSyncMetadata(data);
681
727
  this._persisted = true;
682
728
  }
683
729
  /** Fail closed if a caller tries to hydrate a lossy DuckDB UUID wrapper. */
@@ -1268,11 +1314,11 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1268
1314
  const all = [...removed, ...added];
1269
1315
  const first = all[0];
1270
1316
  if (!first) return true;
1271
- if (all.length > 100 || all.some((item) => item.db !== first.db || item.tableName !== first.tableName || item.constructor !== first.constructor || !SmrtObject.prototype.supportsJunctionBatch.call(item)) || added.some((item) => item.isPersisted) || removed.some((item) => !item.id)) return false;
1317
+ if (all.length > 100 || all.some((item) => item.db !== first.db || item.tableName !== first.tableName || item.constructor !== first.constructor || !SmrtObject.prototype.supportsJunctionBatch.call(item)) || added.some((item) => item.isPersisted || item._explicitId) || removed.some((item) => !item.id)) return false;
1272
1318
  if (first.getDatabaseEngineHint() === "json") return false;
1273
1319
  const duck = first.isNativeDuckDb();
1274
1320
  if (duck && !first.isPositivelyNativeDuckDb()) return false;
1275
- const engine = isPostgresDatabase(first.db) ? "postgres" : duck ? "duckdb" : detectEngine(first.db.url ?? "", first.getDatabaseEngineHint());
1321
+ const engine = usesPostgresDialect(first.db) ? "postgres" : duck ? "duckdb" : detectEngine(first.db.url ?? "", first.getDatabaseEngineHint());
1276
1322
  if (![
1277
1323
  "sqlite",
1278
1324
  "duckdb",
@@ -1334,11 +1380,17 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1334
1380
  tableName: first.tableName,
1335
1381
  ids
1336
1382
  });
1337
- await db.delete(first.tableName, { "id in": ids });
1383
+ const model = first.getResolvedQualifiedName();
1384
+ await deleteSyncRows(db, {
1385
+ tableName: first.tableName,
1386
+ model,
1387
+ tenantColumn: ObjectRegistry.getOwnershipTenantColumn(model)
1388
+ }, { "id in": ids }, (affected) => recordCascadeWrite(db, { affected }));
1338
1389
  return result;
1339
1390
  }, true);
1340
1391
  for (const instance of removed) {
1341
1392
  instance._persisted = false;
1393
+ instance._explicitId = true;
1342
1394
  instance.invalidateCollectionReadCache();
1343
1395
  await instance.runHook("afterDelete");
1344
1396
  }
@@ -1352,19 +1404,20 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1352
1404
  return engine === "postgres" ? `$${values.length}` : "?";
1353
1405
  }).join(", ")})`);
1354
1406
  const updates = columns.filter((column) => column !== "id" && column !== "created_at" && !ownershipColumns.has(column)).map((column) => `${quote(column)} = excluded.${quote(column)}`).join(", ");
1355
- const sql = `INSERT INTO ${quote(first.tableName)} (${columns.map(quote).join(", ")}) VALUES ${tuples.join(", ")} ON CONFLICT (${conflict.map(quote).join(", ")}) DO UPDATE SET ${updates}`;
1356
- await withEmbeddedWriteQueue(first.db, isEmbeddedDatabase(first.db), () => ErrorUtils.withRetry(async () => {
1407
+ const sql = `INSERT INTO ${quote(first.tableName)} (${columns.map(quote).join(", ")}) VALUES ${tuples.join(", ")} ON CONFLICT (${conflict.map(quote).join(", ")}) DO UPDATE SET ${updates} RETURNING *`;
1408
+ await ErrorUtils.withRetry(async () => {
1357
1409
  try {
1358
- await first.db.query(sql, ...values);
1410
+ await withEmbeddedWriteTransaction(first.db, isEmbeddedDatabase(first.db), async (db) => {
1411
+ const { rows } = await db.query(sql, ...values);
1412
+ const tracked = rows.find((row) => row._sync_origin_device_id != null);
1413
+ if (!tracked) return;
1414
+ if (duck && db === first.db) await abortJoinedDuckDbTransaction(db, "smrt: tracked junction conflict aborted the caller transaction");
1415
+ throw first.trackedIdentityWriteError(typeof tracked.id === "string" ? tracked.id : void 0);
1416
+ }, duck);
1359
1417
  } catch (error) {
1360
- if (!(error instanceof Error)) throw error;
1361
- const classification = classifyDatabaseError(error);
1362
- const field = first.extractConstraintFieldFromChain(error, classification);
1363
- if (classification.kind === "unique_violation") throw ValidationError.uniqueConstraint(field, first.getFieldValue(field));
1364
- if (classification.kind === "not_null_violation") throw ValidationError.requiredField(field, first.getResolvedClassName());
1365
- throw DatabaseError.queryFailed(`UPSERT INTO ${first.tableName}`, error);
1418
+ throw first.toTypedWriteError(error, `UPSERT INTO ${first.tableName}`);
1366
1419
  }
1367
- }, 3, 500));
1420
+ }, 3, 500);
1368
1421
  const completions = [];
1369
1422
  for (let i = 0; i < added.length; i++) {
1370
1423
  await added[i].completeSave(prepared[i].interceptorContext, false);
@@ -1386,7 +1439,7 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1386
1439
  return ObjectRegistry.getOwnershipColumns(qualifiedName);
1387
1440
  }
1388
1441
  /** Shared preparation for ordinary saves and compatible bulk creates. */
1389
- async prepareSave(revisionGuard) {
1442
+ async prepareSave(revisionGuard, syncMetadata = null) {
1390
1443
  const className = this.getResolvedClassName();
1391
1444
  if (ObjectRegistry.getConfig(this.getResolvedQualifiedName()).abstract) throw new ValidationError(`Cannot save abstract model ${className}`, "VALIDATION_ABSTRACT_MODEL", { className });
1392
1445
  await this.validateBeforeSave();
@@ -1422,6 +1475,12 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1422
1475
  data._meta_data = sanitizeMetaData(data._meta_data, reservedFields);
1423
1476
  }
1424
1477
  data.updated_at = this.updated_at;
1478
+ delete data._sync_origin_device_id;
1479
+ delete data._sync_revision;
1480
+ if (syncMetadata) {
1481
+ data._sync_origin_device_id = syncMetadata.originDeviceId;
1482
+ data._sync_revision = bindSyncRevision(syncMetadata.revision, this.isPositivelyNativeDuckDb());
1483
+ }
1425
1484
  await this.coerceEmptyUuidValuesToNull(data);
1426
1485
  const conflictColumns = ObjectRegistry.getConflictColumns(this.getResolvedQualifiedName());
1427
1486
  const ownershipColumn = ObjectRegistry.getOwnershipTenantColumn(this.getResolvedQualifiedName());
@@ -1437,6 +1496,8 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1437
1496
  "updated_at",
1438
1497
  "_meta_type",
1439
1498
  "_meta_data",
1499
+ "_sync_origin_device_id",
1500
+ "_sync_revision",
1440
1501
  ...conflictColumns.map(toSnakeCase)
1441
1502
  ]);
1442
1503
  for (const [name, field] of registeredFields) if (field.__tenancy?.isTenantIdField || field._meta?.__tenancy?.isTenantIdField) protectedColumns.add(toSnakeCase(name));
@@ -1529,13 +1590,20 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1529
1590
  async save(options = {}) {
1530
1591
  const className = this.getResolvedClassName();
1531
1592
  const expectedUpdatedAt = options.expectedUpdatedAt;
1593
+ const explicitNewId = !this._persisted && (this._explicitId || options.sync || options.trackSync) && this.id;
1532
1594
  const previousUpdatedAt = this.updated_at;
1533
1595
  let revisionPersisted = false;
1534
1596
  const loadedRevision = expectedUpdatedAt ?? (this._persisted ? this.updated_at ?? void 0 : void 0);
1535
1597
  const revisionGuard = loadedRevision instanceof Date ? loadedRevision.toISOString() : loadedRevision;
1536
1598
  try {
1537
1599
  if (expectedUpdatedAt !== void 0 && (!this._persisted || !this.id)) throw RuntimeError.invalidState("A revision-guarded save requires a persisted object", { className });
1538
- const { interceptorContext, data, writePlan, conflictColumns } = await this.prepareSave(revisionGuard);
1600
+ const syncMetadata = this.nextSyncMetadata(options.sync, options.trackSync);
1601
+ if (syncMetadata && (this.getDatabaseEngineHint() === "json" || isJsonAdapterHandle(this.db))) throw RuntimeError.invalidState("Sync revisions require a SQL database", { className });
1602
+ if (syncMetadata && !this._persisted) {
1603
+ if (!this.id) throw RuntimeError.invalidState("A tracked create requires an explicit id", { className });
1604
+ this.requireInsertOnSave();
1605
+ }
1606
+ const { interceptorContext, data, writePlan, conflictColumns } = await this.prepareSave(revisionGuard, syncMetadata);
1539
1607
  const upsertConflictColumns = this._persisted && data.id ? ["id"] : conflictColumns;
1540
1608
  const useEmbeddedRevisionFallback = revisionGuard !== void 0 && usesEmbeddedRevisionFallback(this.db);
1541
1609
  const serializeEmbeddedWrite = isEmbeddedDatabase(this.db);
@@ -1543,7 +1611,7 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1543
1611
  let revisionMatched = true;
1544
1612
  let naturalKeyWrite = "upsert";
1545
1613
  const adopted = {};
1546
- await withEmbeddedWriteQueue(this.db, serializeEmbeddedWrite, async () => {
1614
+ const write = () => withEmbeddedWriteQueue(this.db, serializeEmbeddedWrite, async () => {
1547
1615
  const writeGuard = GlobalInterceptors.executeWriteGuard(this, interceptorContext);
1548
1616
  if (naturalKeyUpsert) {
1549
1617
  naturalKeyWrite = await this.writeNaturalKeyRow(data, upsertConflictColumns, adopted);
@@ -1574,18 +1642,35 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1574
1642
  } else if (this._insertOnly && !this._persisted || insertUnderFreshSlug) await this.db.insert(this.tableName, data);
1575
1643
  else {
1576
1644
  const naturalKeyConflictColumns = ObjectRegistry.getConflictPredicate(this.getResolvedQualifiedName()) ? ["id"] : upsertConflictColumns;
1577
- await this.writeNaturalKeyUpsert(data, naturalKeyConflictColumns, adopted.existing ?? null, writeGuard);
1645
+ await this.writeNaturalKeyUpsert(data, naturalKeyConflictColumns, adopted, writeGuard);
1578
1646
  }
1579
1647
  } catch (error) {
1580
1648
  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}`);
1581
1649
  }
1582
1650
  }, 3, 500);
1583
1651
  });
1652
+ if (explicitNewId && this.getDatabaseEngineHint() !== "json" && !isJsonAdapterHandle(this.db)) {
1653
+ const guardedWrite = async (tx) => {
1654
+ const rowId = this.normalizeSyncRowId(explicitNewId);
1655
+ const filter = {
1656
+ table_name: this.tableName,
1657
+ row_id: rowId
1658
+ };
1659
+ await lockSyncRowIdentity(tx, this.tableName, rowId);
1660
+ if (await tx.get("_smrt_sync_tombstones", filter)) throw syncRevisionConflict(rowId);
1661
+ await this.withDatabase(tx, write);
1662
+ if (isPostgresDatabase(tx) && await tx.get("_smrt_sync_tombstones", filter)) throw syncRevisionConflict(rowId);
1663
+ };
1664
+ const embedded = usesEmbeddedRevisionFallback(this.db);
1665
+ if (embedded && isKnownDatabaseTransaction(this.db)) await withEmbeddedWriteQueue(this.db, true, () => guardedWrite(this.db));
1666
+ else await withEmbeddedWriteTransaction(this.db, serializeEmbeddedWrite, guardedWrite, embedded);
1667
+ } else await write();
1584
1668
  if (!revisionMatched) throw new RuntimeError(`Revision conflict while saving ${className}`, "RUNTIME_REVISION_CONFLICT", {
1585
1669
  className,
1586
1670
  id: this.id
1587
1671
  });
1588
1672
  revisionPersisted = true;
1673
+ this._syncMetadata = syncMetadata;
1589
1674
  if (adopted.id) {
1590
1675
  this.id = adopted.id;
1591
1676
  if (adopted.createdAt != null) {
@@ -1616,6 +1701,14 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1616
1701
  const interceptorContext = createInterceptorContext(className, "save", void 0, void 0, this.getResolvedQualifiedName());
1617
1702
  await GlobalInterceptors.executeBeforeSave(this, interceptorContext);
1618
1703
  const updatedAt = this.nextRevisionTimestamp(expectedUpdatedAt);
1704
+ const syncMetadata = this.nextSyncMetadata();
1705
+ const updates = {
1706
+ updated_at: updatedAt.toISOString(),
1707
+ ...syncMetadata && {
1708
+ _sync_origin_device_id: syncMetadata.originDeviceId,
1709
+ _sync_revision: bindSyncRevision(syncMetadata.revision, this.isPositivelyNativeDuckDb())
1710
+ }
1711
+ };
1619
1712
  let result;
1620
1713
  let revisionMatched = true;
1621
1714
  const useEmbeddedRevisionFallback = usesEmbeddedRevisionFallback(this.db);
@@ -1632,11 +1725,11 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1632
1725
  if (!useEmbeddedRevisionFallback) result = await this.db.update(this.tableName, this.guardedWriteFilter({
1633
1726
  id: this.id,
1634
1727
  ...this.revisionPredicate(expectedUpdatedAt)
1635
- }, writeGuard), { updated_at: updatedAt.toISOString() });
1636
- else if (this.isPositivelyNativeDuckDb()) result = await this.updateDuckDbRowById(this.id, { updated_at: updatedAt.toISOString() });
1728
+ }, writeGuard), updates);
1729
+ else if (this.isPositivelyNativeDuckDb()) result = await this.updateDuckDbRowById(this.id, updates);
1637
1730
  else result = await this.db.upsert(this.tableName, ["id"], {
1638
1731
  ...current,
1639
- updated_at: updatedAt.toISOString()
1732
+ ...updates
1640
1733
  });
1641
1734
  });
1642
1735
  if (!revisionMatched || result?.affected !== 1) throw new RuntimeError(`Revision conflict while claiming ${className}`, "RUNTIME_REVISION_CONFLICT", {
@@ -1644,6 +1737,7 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1644
1737
  id: this.id
1645
1738
  });
1646
1739
  this.updated_at = updatedAt;
1740
+ this._syncMetadata = syncMetadata;
1647
1741
  this.invalidateCollectionReadCache();
1648
1742
  await recordInstanceChange(this, "update");
1649
1743
  return this;
@@ -1742,21 +1836,56 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1742
1836
  ...guard
1743
1837
  };
1744
1838
  }
1745
- async writeNaturalKeyUpsert(data, conflictColumns, existing, writeGuard) {
1746
- if (writeGuard) {
1839
+ async writeNaturalKeyUpsert(data, conflictColumns, adopted, writeGuard) {
1840
+ const existing = adopted.existing ?? null;
1841
+ const postgres = isPostgresDatabase(this.db);
1842
+ const crossProcess = !usesEmbeddedRevisionFallback(this.db);
1843
+ const guardAdoptedSync = crossProcess && existing !== null;
1844
+ if (writeGuard || guardAdoptedSync) {
1747
1845
  const stored = existing;
1748
1846
  if (!stored) return this.db.insert(this.tableName, data);
1749
1847
  const unchanged = await this.unchangedIndexedColumnsOf(data, stored);
1750
- const assignments = Object.fromEntries(Object.entries(data).filter(([column]) => column !== "id" && !unchanged.has(column) && !conflictColumns.includes(column)));
1751
- const filter = this.guardedWriteFilter({ id: stored.id }, writeGuard);
1848
+ const assignments = Object.fromEntries(Object.entries(data).filter(([column]) => column !== "id" && (!guardAdoptedSync || column !== "created_at") && !unchanged.has(column) && !conflictColumns.includes(column)));
1849
+ const filter = this.guardedWriteFilter({
1850
+ id: stored.id,
1851
+ ...guardAdoptedSync ? { _sync_origin_device_id: null } : {}
1852
+ }, writeGuard);
1752
1853
  let result;
1753
1854
  if (this.isPositivelyNativeDuckDb()) {
1754
1855
  if (!await this.db.get(this.tableName, filter)) throw new Error("Mandatory write predicate denied mutation");
1755
1856
  result = await this.updateDuckDbRowById(stored.id, assignments);
1756
1857
  } else result = await this.db.update(this.tableName, filter, assignments);
1757
- if (result.affected !== 1) throw new Error("Mandatory write predicate denied mutation");
1858
+ if (result.affected !== 1) {
1859
+ if (guardAdoptedSync && !writeGuard) throw this.trackedIdentityWriteError(stored.id);
1860
+ throw new Error("Mandatory write predicate denied mutation");
1861
+ }
1758
1862
  return result;
1759
1863
  }
1864
+ const idConflict = conflictColumns.length === 1 && conflictColumns[0] === "id";
1865
+ if (!this._persisted && idConflict && postgres) {
1866
+ if (!await this.insertIfAbsent(data, conflictColumns, true)) throw this.trackedIdentityWriteError(data.id);
1867
+ return;
1868
+ }
1869
+ if (!this._persisted && (!crossProcess && idConflict || !conflictColumns.every((column) => data[column] !== void 0))) {
1870
+ if ((await this.readNaturalKeyRow({ id: data.id }))?._sync_origin_device_id != null) throw this.trackedIdentityWriteError(data.id);
1871
+ }
1872
+ if (!this._persisted && crossProcess && conflictColumns.every((column) => data[column] !== void 0) && (!postgres || !existing && conflictColumns.some((column) => data[column] === null))) {
1873
+ const filter = Object.fromEntries(conflictColumns.map((column) => [column, data[column]]));
1874
+ return withEmbeddedWriteTransaction(this.db, false, async (tx) => {
1875
+ if (postgres) await tx.query("SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))", `smrt.nullable-natural-key:${this.tableName}`, JSON.stringify(Object.keys(filter).sort().map((column) => [column, filter[column]]), (_, value) => typeof value === "bigint" || typeof value === "number" ? String(value) : value));
1876
+ return this.withDatabase(tx, async () => {
1877
+ const current = await this.readNaturalKeyRow(filter);
1878
+ if (!current) {
1879
+ if (!postgres) return tx.insert(this.tableName, data);
1880
+ if (await this.insertIfAbsent(data, conflictColumns)) return;
1881
+ }
1882
+ const winner = current ?? await this.readNaturalKeyRow(filter);
1883
+ if (!winner) throw RuntimeError.invalidState("Natural-key winner disappeared before it could be adopted", { className: this.getResolvedClassName() });
1884
+ this.adoptNaturalKeyRow(data, winner, conflictColumns, filter, adopted);
1885
+ return this.writeNaturalKeyUpsert(data, conflictColumns, adopted);
1886
+ });
1887
+ });
1888
+ }
1760
1889
  const unchanged = await this.unchangedIndexedColumnsOf(data, existing);
1761
1890
  if (!existing || unchanged.size === 0 || existing.id == null) return this.db.upsert(this.tableName, conflictColumns, data);
1762
1891
  const assignments = {};
@@ -1764,10 +1893,7 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1764
1893
  if (column === "id" || unchanged.has(column) || conflictColumns.includes(column)) continue;
1765
1894
  assignments[column] = value;
1766
1895
  }
1767
- return Object.keys(assignments).length === 0 ? {
1768
- operation: "upsert",
1769
- affected: 1
1770
- } : this.updateDuckDbRowById(existing.id, assignments);
1896
+ return Object.keys(assignments).length === 0 ? { affected: 1 } : this.updateDuckDbRowById(existing.id, assignments);
1771
1897
  }
1772
1898
  /**
1773
1899
  * One id-targeted `UPDATE` on native DuckDB (see {@link buildDuckDbIdUpdate}
@@ -1809,7 +1935,7 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1809
1935
  * compare/upsert fallback in {@link usesEmbeddedRevisionFallback}.
1810
1936
  */
1811
1937
  revisionPredicate(revision) {
1812
- if (isPostgresDatabase(this.db)) return postgresRevisionCondition(revision);
1938
+ if (usesPostgresDialect(this.db)) return postgresRevisionCondition(revision);
1813
1939
  return { updated_at: this.revisionPredicateValue(revision) };
1814
1940
  }
1815
1941
  revisionPredicateValue(revision) {
@@ -1959,21 +2085,25 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1959
2085
  if (atomic) continue;
1960
2086
  return "insert";
1961
2087
  }
1962
- this.assertSameNaturalKeyOwner(data, existing, conflictColumns, conflictFilter);
1963
- const existingId = existing.id;
1964
- if (typeof existingId === "string" && existingId !== "" && existingId !== data.id) {
1965
- data.id = existingId;
1966
- adopted.id = existingId;
1967
- if (existing.created_at != null && existing.created_at !== "") {
1968
- data.created_at = existing.created_at;
1969
- adopted.createdAt = existing.created_at;
1970
- }
1971
- }
1972
- adopted.existing = existing;
2088
+ this.adoptNaturalKeyRow(data, existing, conflictColumns, conflictFilter, adopted);
1973
2089
  return "upsert";
1974
2090
  }
1975
2091
  throw DatabaseError.queryFailed(`INSERT INTO ${this.tableName} (natural key)`, /* @__PURE__ */ new Error("The natural key kept colliding with concurrent writes; retry the save."));
1976
2092
  }
2093
+ adoptNaturalKeyRow(data, existing, conflictColumns, conflictFilter, adopted) {
2094
+ this.assertSameNaturalKeyOwner(data, existing, conflictColumns, conflictFilter);
2095
+ if (existing._sync_origin_device_id != null) throw this.trackedIdentityWriteError(existing.id);
2096
+ const existingId = existing.id;
2097
+ if (typeof existingId === "string" && existingId !== "") {
2098
+ data.id = existingId;
2099
+ adopted.id = existingId;
2100
+ if (existing.created_at != null && existing.created_at !== "") {
2101
+ data.created_at = existing.created_at;
2102
+ adopted.createdAt = existing.created_at;
2103
+ }
2104
+ }
2105
+ adopted.existing = existing;
2106
+ }
1977
2107
  scopePartialConflictFilter(filter, data) {
1978
2108
  const className = this.getResolvedQualifiedName();
1979
2109
  const overrides = ObjectRegistry.getSTIConflictOverrides(className);
@@ -2004,6 +2134,12 @@ var SmrtObject = class SmrtObject extends SmrtClass {
2004
2134
  tenantId: typeof ownTenant === "string" ? ownTenant : null
2005
2135
  });
2006
2136
  }
2137
+ trackedIdentityWriteError(id) {
2138
+ return RuntimeError.invalidState("A tracked row must be loaded by identity before saving", {
2139
+ className: this.getResolvedClassName(),
2140
+ id
2141
+ });
2142
+ }
2007
2143
  /**
2008
2144
  * PostgreSQL: `INSERT … ON CONFLICT (conflict) DO NOTHING RETURNING id`.
2009
2145
  * Values are serialized exactly as the SDK's insert does (undefined
@@ -2011,7 +2147,7 @@ var SmrtObject = class SmrtObject extends SmrtClass {
2011
2147
  * natural key inserts nothing and returns `false`; any other constraint
2012
2148
  * error propagates for the caller's typed classification.
2013
2149
  */
2014
- async insertIfAbsent(data, conflictColumns) {
2150
+ async insertIfAbsent(data, conflictColumns, updateUntrackedId = false) {
2015
2151
  const quote = (identifier) => `"${identifier.replaceAll("\"", "\"\"")}"`;
2016
2152
  const columns = [];
2017
2153
  const values = [];
@@ -2021,9 +2157,11 @@ var SmrtObject = class SmrtObject extends SmrtClass {
2021
2157
  values.push(value === null ? null : value instanceof Date ? value.toISOString() : Buffer.isBuffer(value) || ArrayBuffer.isView(value) ? value : typeof value === "object" ? JSON.stringify(value) : value);
2022
2158
  }
2023
2159
  const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
2024
- const predicate = ObjectRegistry.getConflictPredicate(this.getResolvedQualifiedName());
2160
+ const table = quote(this.tableName);
2161
+ const predicate = updateUntrackedId ? void 0 : ObjectRegistry.getConflictPredicate(this.getResolvedQualifiedName());
2025
2162
  const conflictWhere = predicate ? ` WHERE ${predicate}` : "";
2026
- return ((await this.db.query(`INSERT INTO ${quote(this.tableName)} (${columns.join(", ")}) VALUES (${placeholders}) ON CONFLICT (${conflictColumns.map(quote).join(", ")})${conflictWhere} DO NOTHING RETURNING ${quote("id")}`, ...values))?.rows?.length ?? 0) > 0;
2163
+ const action = updateUntrackedId ? `DO UPDATE SET ${columns.filter((column) => column !== quote("id") && column !== quote("created_at")).map((column) => `${column} = excluded.${column}`).join(", ")} WHERE ${table}."_sync_origin_device_id" IS NULL` : "DO NOTHING";
2164
+ return ((await this.db.query(`INSERT INTO ${table} (${columns.join(", ")}) VALUES (${placeholders}) ON CONFLICT (${conflictColumns.map(quote).join(", ")})${conflictWhere} ${action} RETURNING ${quote("id")}`, ...values))?.rows?.length ?? 0) > 0;
2027
2165
  }
2028
2166
  /**
2029
2167
  * Moves a NEW object's taken derived slug to the first free `<base>-2` …
@@ -2723,11 +2861,30 @@ var SmrtObject = class SmrtObject extends SmrtClass {
2723
2861
  async delete(options = {}) {
2724
2862
  const className = this.getResolvedClassName();
2725
2863
  const expectedUpdatedAt = options.expectedUpdatedAt;
2864
+ const incoming = options.sync && validateSyncRevision(options.sync.originDeviceId, options.sync.revision);
2865
+ const model = this.getResolvedQualifiedName();
2866
+ const identity = {
2867
+ tableName: this.tableName,
2868
+ model,
2869
+ sti: ObjectRegistry.getTableStrategy(model) === "sti",
2870
+ tenantColumn: ObjectRegistry.getOwnershipTenantColumn(model)
2871
+ };
2872
+ if (incoming && !this.id) throw RuntimeError.invalidState("An incoming deletion requires an explicit id", { className });
2726
2873
  if (expectedUpdatedAt !== void 0 && (!this._persisted || !this.id)) throw RuntimeError.invalidState("A revision-guarded delete requires a persisted object", { className });
2727
2874
  const interceptorContext = createInterceptorContext(this.constructor.name, "delete", void 0, void 0, this.getResolvedQualifiedName());
2728
2875
  await GlobalInterceptors.executeBeforeDelete(this, interceptorContext);
2729
2876
  await this.runHook("beforeDelete");
2730
2877
  await this.verifyStorageReady();
2878
+ const incomingDeletion = (() => {
2879
+ if (!incoming || !this.id) return void 0;
2880
+ const tenantId = identity.tenantColumn ? Object.entries(this.toJSON()).find(([key]) => toSnakeCase(key) === identity.tenantColumn)?.[1] ?? null : null;
2881
+ if (tenantId !== null && typeof tenantId !== "string") throw syncRevisionConflict(this.id);
2882
+ return {
2883
+ rowId: this.normalizeSyncRowId(this.id),
2884
+ tenantId,
2885
+ revision: incoming
2886
+ };
2887
+ })();
2731
2888
  const embeddedRevisionGuard = expectedUpdatedAt !== void 0 && usesEmbeddedRevisionFallback(this.db);
2732
2889
  const { affectedTables, affectedTableClasses } = await withEmbeddedWriteQueue(this.db, isEmbeddedDatabase(this.db), async () => {
2733
2890
  const writeGuard = GlobalInterceptors.executeWriteGuard(this, interceptorContext);
@@ -2747,19 +2904,22 @@ var SmrtObject = class SmrtObject extends SmrtClass {
2747
2904
  id: this.id,
2748
2905
  ...expectedUpdatedAt !== void 0 && !embeddedRevisionGuard ? this.revisionPredicate(expectedUpdatedAt) : {}
2749
2906
  }, writeGuard);
2750
- let affected;
2751
- if (writeGuard) {
2752
- const { sql, values } = buildWhere(filter, 1, isPostgresDatabase(this.db) ? "postgres" : this.isNativeDuckDb() ? "duckdb" : "sqlite");
2753
- const table = `"${this.tableName.replaceAll("\"", "\"\"")}"`;
2754
- affected = (await db.query(`DELETE FROM ${table} ${sql} RETURNING id`, ...values)).rows.length;
2755
- } else affected = (await db.delete(this.tableName, filter)).affected;
2907
+ const { affected } = await deleteSyncRows(db, identity, filter, (count) => recordCascadeWrite(db, { affected: count }), incomingDeletion);
2756
2908
  if ((expectedUpdatedAt !== void 0 || writeGuard !== void 0) && affected !== 1) throw new RuntimeError(`Revision conflict while deleting ${className}`, "RUNTIME_REVISION_CONFLICT", {
2757
2909
  className,
2758
2910
  id: this.id
2759
2911
  });
2760
- });
2912
+ if (incomingDeletion && affected === 0) {
2913
+ if (await db.get(this.tableName, { id: this.id })) throw syncRevisionConflict(this.id);
2914
+ await putSyncTombstone(db, identity, incomingDeletion.rowId, identity.model, incomingDeletion.tenantId, incomingDeletion.revision);
2915
+ }
2916
+ }, incomingDeletion ? async (db) => {
2917
+ await lockSyncRowIdentity(db, this.tableName, incomingDeletion.rowId);
2918
+ return Boolean(await db.get(this.tableName, this.guardedWriteFilter({ id: this.id }, writeGuard)));
2919
+ } : void 0);
2761
2920
  });
2762
2921
  this._persisted = false;
2922
+ this._explicitId = true;
2763
2923
  this.invalidateCollectionReadCache();
2764
2924
  for (const table of affectedTables) this.invalidateCollectionReadCache(table, affectedTableClasses.get(table));
2765
2925
  await this.runHook("afterDelete");