@happyvertical/smrt-core 0.43.9 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +24 -1
- package/agents/generators.md +78 -5
- package/agents/schema-paths.md +99 -0
- package/dist/change-feed.d.ts +4 -0
- package/dist/change-feed.d.ts.map +1 -1
- package/dist/change-feed.js +8 -4
- package/dist/change-feed.js.map +1 -1
- package/dist/collection.d.ts +10 -0
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +145 -18
- package/dist/collection.js.map +1 -1
- package/dist/embedded-write-queue.d.ts +17 -29
- package/dist/embedded-write-queue.d.ts.map +1 -1
- package/dist/embedded-write-queue.js +44 -6
- package/dist/embedded-write-queue.js.map +1 -1
- package/dist/generators/custom-action.d.ts.map +1 -1
- package/dist/generators/custom-action.js +1 -1
- package/dist/generators/custom-action.js.map +1 -1
- package/dist/generators/mcp-emit.d.ts +6 -7
- package/dist/generators/mcp-emit.d.ts.map +1 -1
- package/dist/generators/mcp-emit.js +17 -29
- package/dist/generators/mcp-emit.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/knowledge.d.ts +17 -1
- package/dist/knowledge.d.ts.map +1 -1
- package/dist/knowledge.js +33 -3
- package/dist/knowledge.js.map +1 -1
- package/dist/manifest/static-manifest.d.ts.map +1 -1
- package/dist/manifest/static-manifest.js +30 -2
- 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 +36 -2
- package/dist/migrations/differ.d.ts +49 -0
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +220 -4
- package/dist/migrations/differ.js.map +1 -1
- package/dist/migrations/orchestrate.d.ts +23 -0
- package/dist/migrations/orchestrate.d.ts.map +1 -1
- package/dist/migrations/orchestrate.js +12 -3
- package/dist/migrations/orchestrate.js.map +1 -1
- package/dist/object.d.ts +44 -2
- package/dist/object.d.ts.map +1 -1
- package/dist/object.js +207 -18
- package/dist/object.js.map +1 -1
- package/dist/registry/types.d.ts +8 -3
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/schema/foreign-key-ddl.d.ts +7 -0
- package/dist/schema/foreign-key-ddl.d.ts.map +1 -1
- package/dist/schema/foreign-key-ddl.js +7 -1
- package/dist/schema/foreign-key-ddl.js.map +1 -1
- package/dist/schema/schema-manager.d.ts +15 -0
- package/dist/schema/schema-manager.d.ts.map +1 -1
- package/dist/schema/schema-manager.js +52 -9
- package/dist/schema/schema-manager.js.map +1 -1
- package/dist/schema/types.d.ts +10 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/uuid-convergence.d.ts +142 -0
- package/dist/schema/uuid-convergence.d.ts.map +1 -0
- package/dist/schema/uuid-convergence.js +0 -0
- package/dist/schema/uuid-convergence.js.map +1 -0
- package/dist/smrt-knowledge.json +33 -11
- package/dist/utils/scanner-module.d.ts +44 -0
- package/dist/utils/scanner-module.d.ts.map +1 -1
- package/dist/vite-plugin/index.d.ts +14 -1
- package/dist/vite-plugin/index.d.ts.map +1 -1
- package/dist/vite-plugin/index.js +54 -2
- package/dist/vite-plugin/index.js.map +1 -1
- package/dist/vite-plugin.js +3 -2
- package/package.json +6 -5
package/dist/object.js
CHANGED
|
@@ -3,8 +3,10 @@ import { DatabaseError, ErrorUtils, RuntimeError, SmrtError, TenantIsolationErro
|
|
|
3
3
|
import { toSnakeCase } from "./utils/naming.js";
|
|
4
4
|
import { runCascadeDelete } from "./cascade.js";
|
|
5
5
|
import { broadcastCacheInvalidation, hasCrossProcessCacheInterest, invalidateCollectionCache, resolveDbCacheKey } from "./collection-cache.js";
|
|
6
|
-
import { isEmbeddedDatabase, withEmbeddedWriteQueue } from "./embedded-write-queue.js";
|
|
6
|
+
import { isEmbeddedDatabase, usesEmbeddedRevisionFallback, withEmbeddedWriteQueue, withEmbeddedWriteTransaction } from "./embedded-write-queue.js";
|
|
7
7
|
import { GlobalInterceptors, createInterceptorContext, resolveGetStringFilter } from "./interceptors.js";
|
|
8
|
+
import { detectEngine } from "./schema/ddl/index.js";
|
|
9
|
+
import { CHANGE_FEED_WAS_PERSISTED_KEY, recordInstanceChange } from "./change-feed.js";
|
|
8
10
|
import { SmrtClass } from "./class.js";
|
|
9
11
|
import { EmbeddingProvider } from "./embeddings/provider.js";
|
|
10
12
|
import { EmbeddingStorage } from "./embeddings/storage.js";
|
|
@@ -14,8 +16,12 @@ import { executeToolCall } from "./tools/tool-executor.js";
|
|
|
14
16
|
import { fieldsFromClass, keysToSnakeCase, tableNameFromClass } from "./utils.js";
|
|
15
17
|
import { ObjectRegistry } from "./registry.js";
|
|
16
18
|
import { createLogger } from "@happyvertical/logger";
|
|
19
|
+
import { buildWhere } from "@happyvertical/sql";
|
|
17
20
|
//#region src/object.ts
|
|
18
21
|
var logger = createLogger({ level: process.env.DEBUG_STI ? "debug" : "info" });
|
|
22
|
+
function isDuckDbHugeInt(value) {
|
|
23
|
+
return Boolean(value && typeof value === "object" && "hugeint" in value && (typeof value.hugeint === "number" || typeof value.hugeint === "bigint"));
|
|
24
|
+
}
|
|
19
25
|
/**
|
|
20
26
|
* Default maximum number of characters of serialized object data injected into
|
|
21
27
|
* the AI prompts built by `is()`, `do()`, and `describe()`.
|
|
@@ -294,6 +300,38 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
294
300
|
await verifyPersistenceTable(this.db, this.tableName, this.constructor.name);
|
|
295
301
|
}
|
|
296
302
|
/**
|
|
303
|
+
* Run work for this object in a root database transaction.
|
|
304
|
+
*
|
|
305
|
+
* Database rollback cannot undo JavaScript mutations made by save(). This
|
|
306
|
+
* helper therefore restores persistence identity and revision metadata when
|
|
307
|
+
* the transaction rejects, allowing the same instance to be corrected and
|
|
308
|
+
* retried without a false revision conflict. Domain-field mutations remain
|
|
309
|
+
* caller-owned so the caller can inspect or amend them before retrying.
|
|
310
|
+
*/
|
|
311
|
+
async withTransaction(operation) {
|
|
312
|
+
const database = this.db;
|
|
313
|
+
if (!database.transaction) throw new Error("Object transaction requires transaction support");
|
|
314
|
+
const persistence = {
|
|
315
|
+
id: this._id,
|
|
316
|
+
slug: this._slug,
|
|
317
|
+
context: this._context,
|
|
318
|
+
createdAt: this.created_at instanceof Date ? new Date(this.created_at) : this.created_at,
|
|
319
|
+
updatedAt: this.updated_at instanceof Date ? new Date(this.updated_at) : this.updated_at,
|
|
320
|
+
persisted: this._persisted
|
|
321
|
+
};
|
|
322
|
+
try {
|
|
323
|
+
return await withEmbeddedWriteTransaction(database, isEmbeddedDatabase(database), (transaction) => this.withDatabase(transaction, operation));
|
|
324
|
+
} catch (error) {
|
|
325
|
+
this._id = persistence.id;
|
|
326
|
+
this._slug = persistence.slug;
|
|
327
|
+
this._context = persistence.context;
|
|
328
|
+
this.created_at = persistence.createdAt;
|
|
329
|
+
this.updated_at = persistence.updatedAt;
|
|
330
|
+
this._persisted = persistence.persisted;
|
|
331
|
+
throw error;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
297
335
|
* Protected setter for STI discriminator to maintain type safety
|
|
298
336
|
* Used internally for Single Table Inheritance support
|
|
299
337
|
* @internal
|
|
@@ -458,6 +496,7 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
458
496
|
* @throws Error if STI validation fails
|
|
459
497
|
*/
|
|
460
498
|
async loadDataFromDb(data) {
|
|
499
|
+
data = await this.canonicalizePersistedUuidRow(data);
|
|
461
500
|
const className = this.getResolvedClassName();
|
|
462
501
|
if (process.env.DEBUG_STI) logger.debug("[loadDataFromDb] Loading", {
|
|
463
502
|
class: className,
|
|
@@ -512,6 +551,8 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
512
551
|
if (process.env.DEBUG_STI) logger.debug(`[loadDataFromDb] Skipping readonly field '${field}'`);
|
|
513
552
|
}
|
|
514
553
|
}
|
|
554
|
+
if (Object.hasOwn(data, "created_at")) this.created_at = formattedData.created_at ?? formattedData.createdAt;
|
|
555
|
+
if (Object.hasOwn(data, "updated_at")) this.updated_at = formattedData.updated_at ?? formattedData.updatedAt;
|
|
515
556
|
if (process.env.DEBUG_STI) logger.debug("[loadDataFromDb] Hydration complete", {
|
|
516
557
|
class: className,
|
|
517
558
|
hydratedCount,
|
|
@@ -520,6 +561,45 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
520
561
|
});
|
|
521
562
|
this._persisted = true;
|
|
522
563
|
}
|
|
564
|
+
/** Fail closed if a caller tries to hydrate a lossy DuckDB UUID wrapper. */
|
|
565
|
+
async canonicalizePersistedUuidRow(row) {
|
|
566
|
+
const schema = ObjectRegistry.getSchema(this.getResolvedQualifiedName()) ?? ObjectRegistry.getSchema(this.getResolvedClassName());
|
|
567
|
+
const registeredFields = ObjectRegistry.getFields(this.getResolvedQualifiedName()).size > 0 ? ObjectRegistry.getFields(this.getResolvedQualifiedName()) : ObjectRegistry.getFields(this.getResolvedClassName());
|
|
568
|
+
const instanceColumns = /* @__PURE__ */ new Set(["id", ...Array.from(registeredFields.keys(), (fieldName) => toSnakeCase(fieldName))]);
|
|
569
|
+
const uuidColumns = Object.entries(schema?.columns ?? {}).filter(([columnName, column]) => instanceColumns.has(columnName) && String(column.type).toUpperCase() === "UUID").map(([columnName]) => columnName);
|
|
570
|
+
if (uuidColumns.length === 0 || !uuidColumns.some((columnName) => isDuckDbHugeInt(row[columnName]))) return row;
|
|
571
|
+
throw RuntimeError.invalidState("Native DuckDB UUID row was not canonicalized before hydration", { className: this.getResolvedClassName() });
|
|
572
|
+
}
|
|
573
|
+
/** Fetch one exact row while casting native DuckDB UUIDs in that same read. */
|
|
574
|
+
async getCanonicalPersistedRow(filter) {
|
|
575
|
+
if (!this.isNativeDuckDb()) return await this.db.get(this.tableName, filter);
|
|
576
|
+
const schema = ObjectRegistry.getSchema(this.getResolvedQualifiedName()) ?? ObjectRegistry.getSchema(this.getResolvedClassName());
|
|
577
|
+
const quote = (identifier) => `"${identifier.replaceAll("\"", "\"\"")}"`;
|
|
578
|
+
if (Object.keys(filter).length === 0) return null;
|
|
579
|
+
for (const fieldName of Object.keys(filter)) {
|
|
580
|
+
const [, rawColumnName] = fieldName.match(/^(.*?)(?:\s+(?:not in|in|like|>=|<=|!=|>|<|=))?$/i) ?? [];
|
|
581
|
+
const columnName = toSnakeCase(rawColumnName ?? fieldName);
|
|
582
|
+
if (!schema?.columns[columnName]) throw RuntimeError.invalidState("Invalid persisted-row filter column", {
|
|
583
|
+
className: this.getResolvedClassName(),
|
|
584
|
+
fieldName
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
const { sql: whereSql, values } = buildWhere(filter, 1, "duckdb");
|
|
588
|
+
const readSql = `SELECT * FROM ${quote(this.tableName)} ${whereSql} LIMIT 1`;
|
|
589
|
+
const uuidColumns = (await this.db.query(`DESCRIBE ${readSql}`, ...values)).rows.filter((column) => String(column.column_type).toUpperCase() === "UUID").map((column) => String(column.column_name));
|
|
590
|
+
const alias = quote("__smrt_uuid_row");
|
|
591
|
+
const sql = uuidColumns.length === 0 ? readSql : `SELECT ${alias}.* REPLACE (${uuidColumns.map((columnName) => {
|
|
592
|
+
const quoted = quote(columnName);
|
|
593
|
+
return `CAST(${alias}.${quoted} AS VARCHAR) AS ${quoted}`;
|
|
594
|
+
}).join(", ")}) FROM (${readSql}) AS ${alias}`;
|
|
595
|
+
const { rows } = await this.db.query(sql, ...values);
|
|
596
|
+
return rows[0] ?? null;
|
|
597
|
+
}
|
|
598
|
+
isNativeDuckDb() {
|
|
599
|
+
const db = this.db;
|
|
600
|
+
const isDuckDbConnection = (db.client?.constructor?.name?.toLowerCase() ?? "").includes("duckdb") || typeof db.exportTable === "function" && db.client !== void 0 && "connection" in db.client;
|
|
601
|
+
return detectEngine(db.url || db.config?.url || "", this.getDatabaseEngineHint() || db.type || db.config?.type || (isDuckDbConnection ? "duckdb" : void 0)) === "duckdb";
|
|
602
|
+
}
|
|
523
603
|
/**
|
|
524
604
|
* Gets the database table name for this object
|
|
525
605
|
*/
|
|
@@ -814,11 +894,16 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
814
894
|
async getId() {
|
|
815
895
|
if (this.slug) {
|
|
816
896
|
await this.verifyStorageReady();
|
|
817
|
-
const
|
|
897
|
+
const filter = await this.interceptGetFilter({
|
|
818
898
|
slug: this.slug,
|
|
819
899
|
context: this.context
|
|
820
|
-
})
|
|
821
|
-
|
|
900
|
+
});
|
|
901
|
+
const saved = await this.getCanonicalPersistedRow(filter);
|
|
902
|
+
if (saved) {
|
|
903
|
+
const persistedId = saved.id;
|
|
904
|
+
if (typeof persistedId !== "string") throw RuntimeError.invalidState("Persisted object id is not a string", { className: this.getResolvedClassName() });
|
|
905
|
+
this.id = persistedId;
|
|
906
|
+
}
|
|
822
907
|
}
|
|
823
908
|
if (!this.id) this.id = crypto.randomUUID();
|
|
824
909
|
return this.id;
|
|
@@ -868,11 +953,13 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
868
953
|
if (!this.id && !this.slug) return null;
|
|
869
954
|
await this.verifyStorageReady();
|
|
870
955
|
if (this.id) {
|
|
871
|
-
const
|
|
956
|
+
const filter = await this.interceptGetFilter({ id: this.id });
|
|
957
|
+
const byId = await this.getCanonicalPersistedRow(filter);
|
|
872
958
|
if (byId) return byId.id;
|
|
873
959
|
}
|
|
874
960
|
if (this.slug) {
|
|
875
|
-
const
|
|
961
|
+
const filter = await this.interceptGetFilter({ slug: this.slug });
|
|
962
|
+
const bySlug = await this.getCanonicalPersistedRow(filter);
|
|
876
963
|
if (bySlug) return bySlug.id;
|
|
877
964
|
}
|
|
878
965
|
return null;
|
|
@@ -1033,16 +1120,22 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1033
1120
|
* await product.save();
|
|
1034
1121
|
* ```
|
|
1035
1122
|
*/
|
|
1036
|
-
async save() {
|
|
1123
|
+
async save(options = {}) {
|
|
1037
1124
|
const className = this.getResolvedClassName();
|
|
1125
|
+
const expectedUpdatedAt = options.expectedUpdatedAt;
|
|
1126
|
+
const previousUpdatedAt = this.updated_at;
|
|
1127
|
+
let revisionPersisted = false;
|
|
1128
|
+
const loadedRevision = expectedUpdatedAt ?? (this._persisted ? this.updated_at ?? void 0 : void 0);
|
|
1129
|
+
const revisionGuard = loadedRevision instanceof Date ? loadedRevision.toISOString() : loadedRevision;
|
|
1038
1130
|
try {
|
|
1131
|
+
if (expectedUpdatedAt !== void 0 && (!this._persisted || !this.id)) throw RuntimeError.invalidState("A revision-guarded save requires a persisted object", { className });
|
|
1039
1132
|
await this.validateBeforeSave();
|
|
1040
1133
|
await this.validateCrossPackageRefs();
|
|
1041
1134
|
const interceptorContext = createInterceptorContext(className, "save");
|
|
1042
1135
|
await GlobalInterceptors.executeBeforeSave(this, interceptorContext);
|
|
1043
1136
|
if (!this.id) this.id = crypto.randomUUID();
|
|
1044
1137
|
if (!this.slug) this.slug = await this.getSlug();
|
|
1045
|
-
this.updated_at =
|
|
1138
|
+
this.updated_at = this.nextRevisionTimestamp(revisionGuard);
|
|
1046
1139
|
if (!this.created_at) this.created_at = /* @__PURE__ */ new Date();
|
|
1047
1140
|
await this.verifyStorageReady();
|
|
1048
1141
|
const tableStrategy = ObjectRegistry.getTableStrategy(this.getResolvedQualifiedName());
|
|
@@ -1063,17 +1156,34 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1063
1156
|
const data = {};
|
|
1064
1157
|
for (const [key, value] of Object.entries(jsonData)) if (key.startsWith("_")) data[key] = value;
|
|
1065
1158
|
else data[toSnakeCase(key)] = value;
|
|
1159
|
+
data.updated_at = this.updated_at;
|
|
1066
1160
|
await this.coerceEmptyUuidValuesToNull(className, data);
|
|
1067
1161
|
const conflictColumns = ObjectRegistry.getConflictColumns(className);
|
|
1068
1162
|
const writePlan = await this.planPersistenceWrite(className, tableStrategy, data, conflictColumns);
|
|
1069
1163
|
const upsertConflictColumns = this._persisted && data.id ? ["id"] : conflictColumns;
|
|
1070
|
-
const
|
|
1164
|
+
const useEmbeddedRevisionFallback = revisionGuard !== void 0 && usesEmbeddedRevisionFallback(this.db);
|
|
1165
|
+
const serializeEmbeddedWrite = isEmbeddedDatabase(this.db);
|
|
1166
|
+
let revisionMatched = true;
|
|
1071
1167
|
await withEmbeddedWriteQueue(this.db, serializeEmbeddedWrite, () => ErrorUtils.withRetry(async () => {
|
|
1072
1168
|
try {
|
|
1073
|
-
if (writePlan.type === "updateById") {
|
|
1169
|
+
if (writePlan.type === "updateById" || revisionGuard !== void 0) {
|
|
1074
1170
|
const { id: _id, ...updateData } = data;
|
|
1075
|
-
|
|
1076
|
-
|
|
1171
|
+
if (useEmbeddedRevisionFallback) {
|
|
1172
|
+
const current = await this.db.get(this.tableName, { id: data.id });
|
|
1173
|
+
if (!current || !this.revisionsEqual(current.updated_at, revisionGuard)) {
|
|
1174
|
+
revisionMatched = false;
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
const updateResult = useEmbeddedRevisionFallback ? await this.db.upsert(this.tableName, ["id"], data) : await this.db.update(this.tableName, {
|
|
1179
|
+
id: data.id,
|
|
1180
|
+
...revisionGuard !== void 0 ? { updated_at: this.revisionPredicateValue(revisionGuard) } : {}
|
|
1181
|
+
}, updateData);
|
|
1182
|
+
if (revisionGuard !== void 0 && updateResult.affected !== 1) {
|
|
1183
|
+
revisionMatched = false;
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
if (writePlan.type === "updateById") this.setMetaType(writePlan.qualifiedMetaType);
|
|
1077
1187
|
} else if (this._insertOnly && !this._persisted) await this.db.insert(this.tableName, data);
|
|
1078
1188
|
else await this.db.upsert(this.tableName, upsertConflictColumns, data);
|
|
1079
1189
|
} catch (error) {
|
|
@@ -1087,12 +1197,17 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1087
1197
|
const field = this.extractConstraintFieldFromChain(error, classification);
|
|
1088
1198
|
throw ValidationError.requiredField(field, className);
|
|
1089
1199
|
}
|
|
1090
|
-
const operation = writePlan.type === "updateById" ? `UPDATE ${this.tableName} (id-targeted)` : this._insertOnly && !this._persisted ? `INSERT INTO ${this.tableName}` : `UPSERT INTO ${this.tableName}`;
|
|
1200
|
+
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}`;
|
|
1091
1201
|
throw DatabaseError.queryFailed(operation, error);
|
|
1092
1202
|
}
|
|
1093
1203
|
throw error;
|
|
1094
1204
|
}
|
|
1095
1205
|
}, 3, 500));
|
|
1206
|
+
if (!revisionMatched) throw new RuntimeError(`Revision conflict while saving ${className}`, "RUNTIME_REVISION_CONFLICT", {
|
|
1207
|
+
className,
|
|
1208
|
+
id: this.id
|
|
1209
|
+
});
|
|
1210
|
+
revisionPersisted = true;
|
|
1096
1211
|
this._persisted = true;
|
|
1097
1212
|
this.invalidateCollectionReadCache();
|
|
1098
1213
|
await GlobalInterceptors.executeAfterSave(this, interceptorContext);
|
|
@@ -1108,12 +1223,85 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1108
1223
|
if (skipAutoEmbeddings) this.options._skipAutoEmbeddings = false;
|
|
1109
1224
|
return this;
|
|
1110
1225
|
} catch (error) {
|
|
1226
|
+
if (!revisionPersisted) this.updated_at = previousUpdatedAt;
|
|
1111
1227
|
if (error instanceof SmrtError) throw error;
|
|
1112
1228
|
if (this.isTenantContractError(error)) throw error;
|
|
1113
1229
|
throw RuntimeError.operationFailed("save", `${className}#${this.id}`, error instanceof Error ? error : new Error(String(error)));
|
|
1114
1230
|
}
|
|
1115
1231
|
}
|
|
1116
1232
|
/**
|
|
1233
|
+
* Atomically claim the current row revision without running model save hooks.
|
|
1234
|
+
*
|
|
1235
|
+
* Artifact-producing workflows use this immediately before persisting their
|
|
1236
|
+
* artifacts in the same transaction. It provides the same compare-and-swap
|
|
1237
|
+
* conflict contract as `save({ expectedUpdatedAt })`, while deliberately not
|
|
1238
|
+
* re-running domain mutation, publication, embedding, or relationship hooks.
|
|
1239
|
+
*/
|
|
1240
|
+
async claimRevision(expectedUpdatedAt) {
|
|
1241
|
+
const className = this.getResolvedClassName();
|
|
1242
|
+
if (!this._persisted || !this.id) throw RuntimeError.invalidState("A revision claim requires a persisted object", { className });
|
|
1243
|
+
await GlobalInterceptors.executeBeforeSave(this, createInterceptorContext(className, "save"));
|
|
1244
|
+
const updatedAt = this.nextRevisionTimestamp(expectedUpdatedAt);
|
|
1245
|
+
let result;
|
|
1246
|
+
let revisionMatched = true;
|
|
1247
|
+
const useEmbeddedRevisionFallback = usesEmbeddedRevisionFallback(this.db);
|
|
1248
|
+
await withEmbeddedWriteQueue(this.db, useEmbeddedRevisionFallback, async () => {
|
|
1249
|
+
let current = null;
|
|
1250
|
+
if (useEmbeddedRevisionFallback) {
|
|
1251
|
+
current = await this.getCanonicalPersistedRow({ id: this.id });
|
|
1252
|
+
if (!current || !this.revisionsEqual(current.updated_at, expectedUpdatedAt)) {
|
|
1253
|
+
revisionMatched = false;
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
result = useEmbeddedRevisionFallback ? await this.db.upsert(this.tableName, ["id"], {
|
|
1258
|
+
...current,
|
|
1259
|
+
updated_at: updatedAt.toISOString()
|
|
1260
|
+
}) : await this.db.update(this.tableName, {
|
|
1261
|
+
id: this.id,
|
|
1262
|
+
updated_at: this.revisionPredicateValue(expectedUpdatedAt)
|
|
1263
|
+
}, { updated_at: updatedAt.toISOString() });
|
|
1264
|
+
});
|
|
1265
|
+
if (!revisionMatched || result?.affected !== 1) throw new RuntimeError(`Revision conflict while claiming ${className}`, "RUNTIME_REVISION_CONFLICT", {
|
|
1266
|
+
className,
|
|
1267
|
+
id: this.id
|
|
1268
|
+
});
|
|
1269
|
+
this.updated_at = updatedAt;
|
|
1270
|
+
this.invalidateCollectionReadCache();
|
|
1271
|
+
await recordInstanceChange(this, "update");
|
|
1272
|
+
return this;
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Complete the observation lifecycle for a guarded update implemented by an
|
|
1276
|
+
* owning subclass outside save(). The confirmed durable row is hydrated
|
|
1277
|
+
* before observers run so cache invalidation, tenant-scoped change feeds,
|
|
1278
|
+
* and other after-save consumers all see authoritative state.
|
|
1279
|
+
*/
|
|
1280
|
+
async completePersistedUpdate(durableRow) {
|
|
1281
|
+
await this.loadDataFromDb(durableRow);
|
|
1282
|
+
this.invalidateCollectionReadCache();
|
|
1283
|
+
const context = createInterceptorContext(this.getResolvedClassName(), "save");
|
|
1284
|
+
context.metadata = {
|
|
1285
|
+
...context.metadata,
|
|
1286
|
+
[CHANGE_FEED_WAS_PERSISTED_KEY]: true
|
|
1287
|
+
};
|
|
1288
|
+
await GlobalInterceptors.executeAfterSave(this, context);
|
|
1289
|
+
}
|
|
1290
|
+
revisionPredicateValue(revision) {
|
|
1291
|
+
return revision instanceof Date ? revision.toISOString() : revision;
|
|
1292
|
+
}
|
|
1293
|
+
revisionsEqual(left, right) {
|
|
1294
|
+
const leftTime = left instanceof Date ? left.getTime() : typeof left === "string" ? Date.parse(left) : NaN;
|
|
1295
|
+
const rightTime = right instanceof Date ? right.getTime() : Date.parse(right);
|
|
1296
|
+
return Number.isFinite(leftTime) && leftTime === rightTime;
|
|
1297
|
+
}
|
|
1298
|
+
nextRevisionTimestamp(expectedUpdatedAt) {
|
|
1299
|
+
const revisionTimes = [this.updated_at, expectedUpdatedAt].filter((revision) => revision != null).map((revision) => revision instanceof Date ? revision.getTime() : Date.parse(revision)).filter(Number.isFinite);
|
|
1300
|
+
const revisionFloor = revisionTimes.length > 0 ? Math.max(...revisionTimes) : Number.NEGATIVE_INFINITY;
|
|
1301
|
+
const nextRevisionTime = Math.max(Date.now(), revisionFloor + 1);
|
|
1302
|
+
return new Date(nextRevisionTime);
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1117
1305
|
* Detects tenant-boundary errors that must propagate from `save()` unwrapped.
|
|
1118
1306
|
*
|
|
1119
1307
|
* Matches both core's {@link TenantIsolationError} and the duck-typed
|
|
@@ -1322,7 +1510,7 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1322
1510
|
await this.verifyStorageReady();
|
|
1323
1511
|
await ErrorUtils.withRetry(async () => {
|
|
1324
1512
|
try {
|
|
1325
|
-
const existing = await this.
|
|
1513
|
+
const existing = await this.getCanonicalPersistedRow(filter);
|
|
1326
1514
|
if (existing) await this.loadDataFromDb(existing);
|
|
1327
1515
|
} catch (error) {
|
|
1328
1516
|
throw DatabaseError.queryFailed(`get(${this.tableName}, { id: ${this._id} })`, error instanceof Error ? error : new Error(String(error)));
|
|
@@ -1354,10 +1542,11 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1354
1542
|
*/
|
|
1355
1543
|
async loadFromSlug() {
|
|
1356
1544
|
await this.verifyStorageReady();
|
|
1357
|
-
const
|
|
1545
|
+
const filter = await this.interceptGetFilter({
|
|
1358
1546
|
slug: this._slug,
|
|
1359
1547
|
context: this._context || ""
|
|
1360
|
-
})
|
|
1548
|
+
});
|
|
1549
|
+
const existing = await this.getCanonicalPersistedRow(filter);
|
|
1361
1550
|
if (existing) await this.loadDataFromDb(existing);
|
|
1362
1551
|
}
|
|
1363
1552
|
/**
|
|
@@ -1620,11 +1809,11 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1620
1809
|
await GlobalInterceptors.executeBeforeDelete(this, interceptorContext);
|
|
1621
1810
|
await this.runHook("beforeDelete");
|
|
1622
1811
|
await this.verifyStorageReady();
|
|
1623
|
-
const { affectedTables, affectedTableClasses } = await runCascadeDelete(this.db, ObjectRegistry, {
|
|
1812
|
+
const { affectedTables, affectedTableClasses } = await withEmbeddedWriteQueue(this.db, isEmbeddedDatabase(this.db), () => runCascadeDelete(this.db, ObjectRegistry, {
|
|
1624
1813
|
className: this.getResolvedQualifiedName(),
|
|
1625
1814
|
tableName: this.tableName,
|
|
1626
1815
|
id: this.id
|
|
1627
|
-
}, (db) => db.delete(this.tableName, { id: this.id }).then(() => void 0));
|
|
1816
|
+
}, (db) => db.delete(this.tableName, { id: this.id }).then(() => void 0)));
|
|
1628
1817
|
this._persisted = false;
|
|
1629
1818
|
this.invalidateCollectionReadCache();
|
|
1630
1819
|
for (const table of affectedTables) this.invalidateCollectionReadCache(table, affectedTableClasses.get(table));
|