@happyvertical/smrt-core 0.43.8 → 0.43.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.
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 saved = await this.db.get(this.tableName, await this.interceptGetFilter({
897
+ const filter = await this.interceptGetFilter({
818
898
  slug: this.slug,
819
899
  context: this.context
820
- }));
821
- if (saved) this.id = saved.id;
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 byId = await this.db.get(this.tableName, await this.interceptGetFilter({ id: this.id }));
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 bySlug = await this.db.get(this.tableName, await this.interceptGetFilter({ slug: this.slug }));
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 = /* @__PURE__ */ new Date();
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 serializeEmbeddedWrite = writePlan.type !== "updateById" && !(this._insertOnly && !this._persisted) && isEmbeddedDatabase(this.db) && upsertConflictColumns.some((column) => data[column] == null);
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
- await this.db.update(this.tableName, { id: data.id }, updateData);
1076
- this.setMetaType(writePlan.qualifiedMetaType);
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.db.get(this.tableName, filter);
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 existing = await this.db.get(this.tableName, await this.interceptGetFilter({
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));