@happyvertical/smrt-core 0.60.9 → 0.60.11
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/browser-entry.md +10 -0
- package/agents/change-feed.md +33 -0
- package/agents/object-runtime.md +8 -0
- package/agents/schema-paths.md +6 -0
- package/dist/browser.d.ts +2 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +3 -2
- package/dist/cascade.d.ts +17 -3
- package/dist/cascade.d.ts.map +1 -1
- package/dist/cascade.js +30 -15
- package/dist/cascade.js.map +1 -1
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +8 -4
- package/dist/collection.js.map +1 -1
- package/dist/embedded-write-queue.d.ts +18 -7
- package/dist/embedded-write-queue.d.ts.map +1 -1
- package/dist/embedded-write-queue.js +40 -10
- package/dist/embedded-write-queue.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/manifest/static-manifest.d.ts.map +1 -1
- package/dist/manifest/static-manifest.js +25 -7
- 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 +39 -7
- package/dist/migrations/differ.d.ts +4 -0
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +25 -9
- package/dist/migrations/differ.js.map +1 -1
- package/dist/migrations/orchestrate.d.ts +1 -1
- package/dist/migrations/orchestrate.d.ts.map +1 -1
- package/dist/migrations/orchestrate.js +5 -14
- package/dist/migrations/orchestrate.js.map +1 -1
- package/dist/native-duckdb.d.ts.map +1 -1
- package/dist/native-duckdb.js +5 -1
- package/dist/native-duckdb.js.map +1 -1
- package/dist/object.d.ts +26 -0
- package/dist/object.d.ts.map +1 -1
- package/dist/object.js +215 -55
- package/dist/object.js.map +1 -1
- package/dist/schema/generator.d.ts.map +1 -1
- package/dist/schema/generator.js +8 -4
- package/dist/schema/generator.js.map +1 -1
- package/dist/scripts/browser-entry-evaluation.mjs +63 -8
- package/dist/scripts/browser-unbundled-dev.mjs +5 -2
- package/dist/scripts/fixtures/browser-pglite-cascade.ts +107 -0
- package/dist/scripts/fixtures/pglite-peer/package.json +9 -0
- package/dist/scripts/fixtures/pglite-peer/peer.js +1 -0
- package/dist/scripts/fixtures/pglite-peer/pnpm-lock.yaml +22 -0
- package/dist/scripts/fixtures/pglite-peer/pnpm-workspace.yaml +1 -0
- package/dist/smrt-knowledge.json +19 -11
- package/dist/smrt-knowledge.okf.md +181 -0
- package/dist/sync-device.d.ts +6 -0
- package/dist/sync-device.d.ts.map +1 -0
- package/dist/sync-device.js +18 -0
- package/dist/sync-device.js.map +1 -0
- package/dist/sync-tombstones.d.ts +30 -0
- package/dist/sync-tombstones.d.ts.map +1 -0
- package/dist/sync-tombstones.js +112 -0
- package/dist/sync-tombstones.js.map +1 -0
- package/dist/system/schema.d.ts +5 -1
- package/dist/system/schema.d.ts.map +1 -1
- package/dist/system/schema.js +21 -4
- package/dist/system/schema.js.map +1 -1
- package/package.json +11 -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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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),
|
|
1636
|
-
else if (this.isPositivelyNativeDuckDb()) result = await this.updateDuckDbRowById(this.id,
|
|
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
|
-
|
|
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,
|
|
1746
|
-
|
|
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({
|
|
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)
|
|
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 (
|
|
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.
|
|
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
|
|
2160
|
+
const table = quote(this.tableName);
|
|
2161
|
+
const predicate = updateUntrackedId ? void 0 : ObjectRegistry.getConflictPredicate(this.getResolvedQualifiedName());
|
|
2025
2162
|
const conflictWhere = predicate ? ` WHERE ${predicate}` : "";
|
|
2026
|
-
|
|
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
|
-
|
|
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");
|