@objectstack/objectql 4.1.1 → 4.2.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/dist/index.d.mts CHANGED
@@ -418,6 +418,7 @@ declare class ObjectStackProtocolImplementation implements ObjectStackProtocol {
418
418
  object: string;
419
419
  id: string;
420
420
  data: any;
421
+ expectedVersion?: string;
421
422
  context?: any;
422
423
  }): Promise<{
423
424
  object: string;
@@ -427,12 +428,33 @@ declare class ObjectStackProtocolImplementation implements ObjectStackProtocol {
427
428
  deleteData(request: {
428
429
  object: string;
429
430
  id: string;
431
+ expectedVersion?: string;
430
432
  context?: any;
431
433
  }): Promise<{
432
434
  object: string;
433
435
  id: string;
434
436
  success: boolean;
435
437
  }>;
438
+ /**
439
+ * Optimistic Concurrency Control gate shared by updateData/deleteData.
440
+ *
441
+ * When the caller passes a non-empty `expectedVersion` token (typically
442
+ * the `updated_at` value they read), this fetches the current record
443
+ * and compares its `updated_at` against the token. Mismatch → throw
444
+ * `ConcurrentUpdateError` which the REST layer maps to 409.
445
+ *
446
+ * Behaviour:
447
+ * - Empty/missing token → no check (opt-in semantics; existing callers
448
+ * that haven't yet adopted OCC are unaffected).
449
+ * - Record not found → no check; downstream `engine.update` will
450
+ * surface the usual `RECORD_NOT_FOUND` 404. We intentionally do not
451
+ * treat "missing record" as a concurrency conflict.
452
+ * - Record has no `updated_at` field (timestamps disabled) → no check.
453
+ * Logging would be noisy here; OCC is opt-in and the absence of a
454
+ * version column is an explicit "this object doesn't support OCC"
455
+ * signal.
456
+ */
457
+ private assertVersionMatch;
436
458
  /**
437
459
  * Cross-object substring search across all registered objects that opt in
438
460
  * via `enable.searchable !== false` and `enable.apiEnabled !== false`.
package/dist/index.d.ts CHANGED
@@ -418,6 +418,7 @@ declare class ObjectStackProtocolImplementation implements ObjectStackProtocol {
418
418
  object: string;
419
419
  id: string;
420
420
  data: any;
421
+ expectedVersion?: string;
421
422
  context?: any;
422
423
  }): Promise<{
423
424
  object: string;
@@ -427,12 +428,33 @@ declare class ObjectStackProtocolImplementation implements ObjectStackProtocol {
427
428
  deleteData(request: {
428
429
  object: string;
429
430
  id: string;
431
+ expectedVersion?: string;
430
432
  context?: any;
431
433
  }): Promise<{
432
434
  object: string;
433
435
  id: string;
434
436
  success: boolean;
435
437
  }>;
438
+ /**
439
+ * Optimistic Concurrency Control gate shared by updateData/deleteData.
440
+ *
441
+ * When the caller passes a non-empty `expectedVersion` token (typically
442
+ * the `updated_at` value they read), this fetches the current record
443
+ * and compares its `updated_at` against the token. Mismatch → throw
444
+ * `ConcurrentUpdateError` which the REST layer maps to 409.
445
+ *
446
+ * Behaviour:
447
+ * - Empty/missing token → no check (opt-in semantics; existing callers
448
+ * that haven't yet adopted OCC are unaffected).
449
+ * - Record not found → no check; downstream `engine.update` will
450
+ * surface the usual `RECORD_NOT_FOUND` 404. We intentionally do not
451
+ * treat "missing record" as a concurrency conflict.
452
+ * - Record has no `updated_at` field (timestamps disabled) → no check.
453
+ * Logging would be noisy here; OCC is opt-in and the absence of a
454
+ * version column is an explicit "this object doesn't support OCC"
455
+ * signal.
456
+ */
457
+ private assertVersionMatch;
436
458
  /**
437
459
  * Cross-object substring search across all registered objects that opt in
438
460
  * via `enable.searchable !== false` and `enable.apiEnabled !== false`.
package/dist/index.js CHANGED
@@ -668,6 +668,25 @@ function simpleHash(str) {
668
668
  }
669
669
  return Math.abs(hash).toString(16);
670
670
  }
671
+ var ConcurrentUpdateError = class extends Error {
672
+ constructor(opts) {
673
+ super(opts.message ?? "Record was modified by another user");
674
+ this.code = "CONCURRENT_UPDATE";
675
+ this.status = 409;
676
+ this.name = "ConcurrentUpdateError";
677
+ this.currentVersion = opts.currentVersion;
678
+ this.currentRecord = opts.currentRecord;
679
+ }
680
+ };
681
+ function normaliseVersionToken(v) {
682
+ if (v === null || v === void 0) return null;
683
+ const s = String(v).trim();
684
+ if (!s) return null;
685
+ if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
686
+ return s.slice(1, -1);
687
+ }
688
+ return s;
689
+ }
671
690
  var SERVICE_CONFIG = {
672
691
  auth: { route: "/api/v1/auth", plugin: "plugin-auth" },
673
692
  automation: { route: "/api/v1/automation", plugin: "plugin-automation" },
@@ -1261,6 +1280,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1261
1280
  };
1262
1281
  }
1263
1282
  async updateData(request) {
1283
+ await this.assertVersionMatch(request.object, request.id, request.expectedVersion, request.context);
1264
1284
  const opts = { where: { id: request.id } };
1265
1285
  if (request.context !== void 0) opts.context = request.context;
1266
1286
  const result = await this.engine.update(request.object, request.data, opts);
@@ -1271,6 +1291,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1271
1291
  };
1272
1292
  }
1273
1293
  async deleteData(request) {
1294
+ await this.assertVersionMatch(request.object, request.id, request.expectedVersion, request.context);
1274
1295
  const opts = { where: { id: request.id } };
1275
1296
  if (request.context !== void 0) opts.context = request.context;
1276
1297
  await this.engine.delete(request.object, opts);
@@ -1280,6 +1301,42 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1280
1301
  success: true
1281
1302
  };
1282
1303
  }
1304
+ /**
1305
+ * Optimistic Concurrency Control gate shared by updateData/deleteData.
1306
+ *
1307
+ * When the caller passes a non-empty `expectedVersion` token (typically
1308
+ * the `updated_at` value they read), this fetches the current record
1309
+ * and compares its `updated_at` against the token. Mismatch → throw
1310
+ * `ConcurrentUpdateError` which the REST layer maps to 409.
1311
+ *
1312
+ * Behaviour:
1313
+ * - Empty/missing token → no check (opt-in semantics; existing callers
1314
+ * that haven't yet adopted OCC are unaffected).
1315
+ * - Record not found → no check; downstream `engine.update` will
1316
+ * surface the usual `RECORD_NOT_FOUND` 404. We intentionally do not
1317
+ * treat "missing record" as a concurrency conflict.
1318
+ * - Record has no `updated_at` field (timestamps disabled) → no check.
1319
+ * Logging would be noisy here; OCC is opt-in and the absence of a
1320
+ * version column is an explicit "this object doesn't support OCC"
1321
+ * signal.
1322
+ */
1323
+ async assertVersionMatch(object, id, expectedVersion, context) {
1324
+ const expected = normaliseVersionToken(expectedVersion);
1325
+ if (!expected) return;
1326
+ const findOpts = { where: { id } };
1327
+ if (context !== void 0) findOpts.context = context;
1328
+ const current = await this.engine.findOne(object, findOpts);
1329
+ if (!current) return;
1330
+ const currentVersion = normaliseVersionToken(current.updated_at);
1331
+ if (!currentVersion) return;
1332
+ if (currentVersion !== expected) {
1333
+ throw new ConcurrentUpdateError({
1334
+ currentVersion,
1335
+ currentRecord: current,
1336
+ message: `Record ${object}/${id} was modified by another user (current version ${currentVersion}, expected ${expected})`
1337
+ });
1338
+ }
1339
+ }
1283
1340
  // ==========================================
1284
1341
  // Global Search (M10.5)
1285
1342
  // ==========================================