js-bao 0.5.0 → 0.5.2

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.
@@ -44,6 +44,21 @@ function assertValidIdentifier(name, context) {
44
44
  function quoteIdentifier(name) {
45
45
  return `"${name.replace(/"/g, '""')}"`;
46
46
  }
47
+ function deterministicIdentifierHash(value) {
48
+ let hash = 0;
49
+ for (let i = 0; i < value.length; i++) {
50
+ hash = (hash << 5) - hash + value.charCodeAt(i);
51
+ hash |= 0;
52
+ }
53
+ const positiveHash = hash === 0 ? 0 : Math.abs(hash);
54
+ return positiveHash.toString(36);
55
+ }
56
+ function buildSafeAlias(prefix, rawDescriptor) {
57
+ const suffix = deterministicIdentifierHash(rawDescriptor);
58
+ const candidate = `${prefix}_${suffix}`;
59
+ assertValidIdentifier(candidate, "Alias generation");
60
+ return candidate;
61
+ }
47
62
  var IDENTIFIER_PATTERN;
48
63
  var init_sql = __esm({
49
64
  "src/utils/sql.ts"() {
@@ -241,21 +256,36 @@ var init_CursorManager = __esm({
241
256
  return fields;
242
257
  }
243
258
  /**
244
- * Build ORDER BY clause from sort specification
259
+ * Build ORDER BY clause from sort specification.
260
+ *
261
+ * `direction` is the pagination direction (1 = forward, -1 = backward).
262
+ * For backward pagination each field's effective ORDER BY direction is
263
+ * reversed so the cursor's flipped inequality (built by
264
+ * `buildPaginationConditions`) returns the page adjacent to the cursor,
265
+ * not the globally smallest/largest window. Without this,
266
+ * `field < cursor ... ORDER BY field ASC LIMIT n` returns the n smallest
267
+ * rows overall instead of the n rows just below the cursor.
268
+ *
269
+ * The returned `directions` array carries the *declared* per-field sort
270
+ * directions (unaffected by `direction`) — `buildPaginationConditions`
271
+ * needs the declared directions to pick each field's comparison operator.
245
272
  */
246
- static buildOrderClause(sort, fieldFormatter = (field) => field) {
273
+ static buildOrderClause(sort, fieldFormatter = (field) => field, direction = 1) {
247
274
  const fields = this.extractSortFields(sort);
248
275
  const clauses = [];
249
276
  const directions = [];
277
+ const sqlDir = (declared) => {
278
+ const effective = direction === -1 ? declared === 1 ? -1 : 1 : declared;
279
+ return effective === 1 ? "ASC" : "DESC";
280
+ };
250
281
  if (sort && Object.keys(sort).length > 0) {
251
282
  for (const [field, dir] of Object.entries(sort)) {
252
- const sqlDirection = dir === 1 ? "ASC" : "DESC";
253
- clauses.push(`${fieldFormatter(field)} ${sqlDirection}`);
283
+ clauses.push(`${fieldFormatter(field)} ${sqlDir(dir)}`);
254
284
  directions.push(dir);
255
285
  }
256
286
  }
257
287
  if (!sort || !sort.hasOwnProperty("id")) {
258
- clauses.push(`${fieldFormatter("id")} ASC`);
288
+ clauses.push(`${fieldFormatter("id")} ${sqlDir(1)}`);
259
289
  directions.push(1);
260
290
  } else if (sort && sort.hasOwnProperty("id")) {
261
291
  directions.push(sort["id"]);
@@ -278,18 +308,27 @@ var init_CursorManager = __esm({
278
308
  /**
279
309
  * Generate next and previous cursors from result set
280
310
  */
281
- static generateResultCursors(results, sortFields, _requestedDirection, hasMore, isFirstPage = false) {
311
+ static generateResultCursors(results, sortFields, requestedDirection, hasMore, isFirstPage = false) {
282
312
  if (results.length === 0) {
283
313
  return {};
284
314
  }
285
315
  const cursors = {};
286
316
  if (hasMore && results.length > 0) {
287
317
  const lastResult = results[results.length - 1];
288
- cursors.nextCursor = this.generateCursor(lastResult, sortFields, 1);
318
+ cursors.nextCursor = this.generateCursor(
319
+ lastResult,
320
+ sortFields,
321
+ requestedDirection
322
+ );
289
323
  }
290
324
  if (results.length > 0 && !isFirstPage) {
291
325
  const firstResult = results[0];
292
- cursors.prevCursor = this.generateCursor(firstResult, sortFields, -1);
326
+ const opposite = requestedDirection === 1 ? -1 : 1;
327
+ cursors.prevCursor = this.generateCursor(
328
+ firstResult,
329
+ sortFields,
330
+ opposite
331
+ );
293
332
  }
294
333
  return cursors;
295
334
  }
@@ -339,6 +378,22 @@ var init_patterns = __esm({
339
378
  }
340
379
  });
341
380
 
381
+ // src/utils/yjsValues.ts
382
+ function isStringSetYMap(value) {
383
+ if (!(value instanceof Y.Map)) return false;
384
+ for (const v of value.values()) {
385
+ if (v !== true) return false;
386
+ }
387
+ return true;
388
+ }
389
+ var Y;
390
+ var init_yjsValues = __esm({
391
+ "src/utils/yjsValues.ts"() {
392
+ "use strict";
393
+ Y = __toESM(require("yjs"), 1);
394
+ }
395
+ });
396
+
342
397
  // src/models/StringSet.ts
343
398
  var init_StringSet = __esm({
344
399
  "src/models/StringSet.ts"() {
@@ -364,9 +419,34 @@ var init_DocumentQueryTranslator = __esm({
364
419
  }
365
420
  });
366
421
 
422
+ // src/models/writeLifecycle.ts
423
+ function registerFunctionDefaultGenerator(name, fn) {
424
+ FUNCTION_DEFAULT_GENERATORS.set(name, fn);
425
+ }
426
+ var Y3, import_ulid2, KNOWN_FIELD_TYPE_MAP, KNOWN_FIELD_TYPES, FUNCTION_DEFAULT_GENERATORS, UNKNOWN_FUNCTION_DEFAULT;
427
+ var init_writeLifecycle = __esm({
428
+ "src/models/writeLifecycle.ts"() {
429
+ "use strict";
430
+ Y3 = __toESM(require("yjs"), 1);
431
+ import_ulid2 = require("ulid");
432
+ KNOWN_FIELD_TYPE_MAP = {
433
+ string: true,
434
+ number: true,
435
+ boolean: true,
436
+ date: true,
437
+ id: true,
438
+ stringset: true
439
+ };
440
+ KNOWN_FIELD_TYPES = new Set(Object.keys(KNOWN_FIELD_TYPE_MAP));
441
+ FUNCTION_DEFAULT_GENERATORS = /* @__PURE__ */ new Map();
442
+ registerFunctionDefaultGenerator("generate_ulid", () => (0, import_ulid2.ulid)());
443
+ UNKNOWN_FUNCTION_DEFAULT = "$unknown_function";
444
+ }
445
+ });
446
+
367
447
  // src/models/metaSync.ts
368
448
  function inferFieldType(value) {
369
- if (value instanceof Y2.Map) return "stringset";
449
+ if (isStringSetYMap(value)) return "stringset";
370
450
  switch (typeof value) {
371
451
  case "string":
372
452
  return "string";
@@ -385,7 +465,10 @@ function encodeDefault(value) {
385
465
  if (value === void 0 || value === null) return void 0;
386
466
  if (typeof value === "function") {
387
467
  const name = KNOWN_FUNCTION_DEFAULTS.get(value);
388
- return name ? `$${name}` : "$unknown_function";
468
+ return name ? `$${name}` : UNKNOWN_FUNCTION_DEFAULT;
469
+ }
470
+ if (typeof value === "string" && value.startsWith("$")) {
471
+ return `$${value}`;
389
472
  }
390
473
  return value;
391
474
  }
@@ -411,7 +494,7 @@ function syncModelMeta(yDoc, modelName, schema) {
411
494
  if (compoundConstraints.length > 0) {
412
495
  let constraints = meta.get("_constraints");
413
496
  if (!constraints) {
414
- constraints = new Y2.Map();
497
+ constraints = new Y4.Map();
415
498
  meta.set("_constraints", constraints);
416
499
  }
417
500
  for (const constraint of compoundConstraints) {
@@ -422,7 +505,7 @@ function syncModelMeta(yDoc, modelName, schema) {
422
505
  if (relationships && Object.keys(relationships).length > 0) {
423
506
  let rels = meta.get("_relationships");
424
507
  if (!rels) {
425
- rels = new Y2.Map();
508
+ rels = new Y4.Map();
426
509
  meta.set("_relationships", rels);
427
510
  }
428
511
  for (const [relName, relConfig] of Object.entries(relationships)) {
@@ -434,7 +517,7 @@ function syncModelMeta(yDoc, modelName, schema) {
434
517
  function syncFieldMeta(metaMap, fieldName, fieldOpts) {
435
518
  let fieldMeta = metaMap.get(fieldName);
436
519
  if (!fieldMeta) {
437
- fieldMeta = new Y2.Map();
520
+ fieldMeta = new Y4.Map();
438
521
  metaMap.set(fieldName, fieldMeta);
439
522
  }
440
523
  setIfChanged(fieldMeta, "type", fieldOpts.type);
@@ -442,6 +525,7 @@ function syncFieldMeta(metaMap, fieldName, fieldOpts) {
442
525
  if (fieldOpts.unique) setIfChanged(fieldMeta, "unique", true);
443
526
  if (fieldOpts.required) setIfChanged(fieldMeta, "required", true);
444
527
  if (fieldOpts.autoAssign) setIfChanged(fieldMeta, "autoAssign", true);
528
+ if (fieldOpts.autoStamp) setIfChanged(fieldMeta, "autoStamp", fieldOpts.autoStamp);
445
529
  if (fieldOpts.maxLength !== void 0) setIfChanged(fieldMeta, "maxLength", fieldOpts.maxLength);
446
530
  if (fieldOpts.maxCount !== void 0) setIfChanged(fieldMeta, "maxCount", fieldOpts.maxCount);
447
531
  const encoded = encodeDefault(fieldOpts.default);
@@ -450,7 +534,7 @@ function syncFieldMeta(metaMap, fieldName, fieldOpts) {
450
534
  function syncConstraintMeta(constraintsMap, constraint) {
451
535
  let cMeta = constraintsMap.get(constraint.name);
452
536
  if (!cMeta) {
453
- cMeta = new Y2.Map();
537
+ cMeta = new Y4.Map();
454
538
  constraintsMap.set(constraint.name, cMeta);
455
539
  }
456
540
  setIfChanged(cMeta, "type", "unique");
@@ -460,7 +544,7 @@ function syncConstraintMeta(constraintsMap, constraint) {
460
544
  function syncRelationshipMeta(relsMap, relName, relConfig) {
461
545
  let relMeta = relsMap.get(relName);
462
546
  if (!relMeta) {
463
- relMeta = new Y2.Map();
547
+ relMeta = new Y4.Map();
464
548
  relsMap.set(relName, relMeta);
465
549
  }
466
550
  for (const [key, value] of Object.entries(relConfig)) {
@@ -475,7 +559,7 @@ function syncInferredMeta(yDoc, modelName, recordData) {
475
559
  if (fieldName.startsWith("_")) continue;
476
560
  let fieldMeta = meta.get(fieldName);
477
561
  if (!fieldMeta) {
478
- fieldMeta = new Y2.Map();
562
+ fieldMeta = new Y4.Map();
479
563
  meta.set(fieldName, fieldMeta);
480
564
  }
481
565
  if (!fieldMeta.has("type")) {
@@ -491,32 +575,49 @@ function setIfChanged(map, key, value) {
491
575
  map.set(key, value);
492
576
  }
493
577
  }
494
- var Y2, KNOWN_FUNCTION_DEFAULTS, _syncedCache;
578
+ var Y4, KNOWN_FUNCTION_DEFAULTS, _syncedCache;
495
579
  var init_metaSync = __esm({
496
580
  "src/models/metaSync.ts"() {
497
581
  "use strict";
498
- Y2 = __toESM(require("yjs"), 1);
582
+ Y4 = __toESM(require("yjs"), 1);
583
+ init_yjsValues();
584
+ init_writeLifecycle();
499
585
  KNOWN_FUNCTION_DEFAULTS = /* @__PURE__ */ new WeakMap();
500
586
  _syncedCache = /* @__PURE__ */ new WeakMap();
501
587
  }
502
588
  });
503
589
 
590
+ // src/utils/uniqueIndex.ts
591
+ function fieldUniqueConstraintName(modelName, fieldName) {
592
+ return `${modelName}_${fieldName}_unique`;
593
+ }
594
+ var Y5;
595
+ var init_uniqueIndex = __esm({
596
+ "src/utils/uniqueIndex.ts"() {
597
+ "use strict";
598
+ Y5 = __toESM(require("yjs"), 1);
599
+ }
600
+ });
601
+
504
602
  // src/models/BaseModel.ts
505
603
  function generateULID() {
506
- return (0, import_ulid2.ulid)();
604
+ return (0, import_ulid3.ulid)();
507
605
  }
508
- var Y3, import_ulid2;
606
+ var Y6, import_ulid3;
509
607
  var init_BaseModel = __esm({
510
608
  "src/models/BaseModel.ts"() {
511
609
  "use strict";
512
- Y3 = __toESM(require("yjs"), 1);
513
- import_ulid2 = require("ulid");
610
+ Y6 = __toESM(require("yjs"), 1);
611
+ import_ulid3 = require("ulid");
514
612
  init_StringSet();
515
613
  init_documentTypes();
516
614
  init_DocumentQueryTranslator();
517
615
  init_CursorManager();
518
616
  init_sql();
519
617
  init_metaSync();
618
+ init_writeLifecycle();
619
+ init_yjsValues();
620
+ init_uniqueIndex();
520
621
  registerFunctionDefault(generateULID, "generate_ulid");
521
622
  }
522
623
  });
@@ -544,14 +645,21 @@ __export(cloudflare_do_exports, {
544
645
  DurableObjectEngine: () => DurableObjectEngine,
545
646
  JsonQueryTranslator: () => JsonQueryTranslator,
546
647
  JsonSchemaDDL: () => JsonSchemaDDL,
648
+ PROJECTED_JSON_ALIAS_PREFIX: () => PROJECTED_JSON_ALIAS_PREFIX,
649
+ buildSafeAlias: () => buildSafeAlias,
650
+ checkAutoStampsReservedFields: () => checkAutoStampsReservedFields,
651
+ checkReservedFieldName: () => checkReservedFieldName,
547
652
  clearMetaSyncCache: () => clearMetaSyncCache,
548
653
  createDatabaseDO: () => createDatabaseDO,
549
654
  createDocumentDO: () => createDocumentDO,
655
+ deterministicIdentifierHash: () => deterministicIdentifierHash,
550
656
  discoverModelNames: () => discoverModelNames,
551
657
  discoverSchema: () => discoverSchema,
552
658
  handleRequest: () => handleRequest,
553
659
  inferFieldType: () => inferFieldType,
554
660
  loadSchemaFromTomlString: () => loadSchemaFromTomlString,
661
+ parseRecordRow: () => parseRecordRow,
662
+ projectedJsonAlias: () => projectedJsonAlias,
555
663
  registerFunctionDefault: () => registerFunctionDefault,
556
664
  schemaToToml: () => schemaToToml,
557
665
  syncInferredMeta: () => syncInferredMeta,
@@ -1567,6 +1675,45 @@ var DurableObjectEngine = class extends JsonSchemaEngine {
1567
1675
  init_queryTypes();
1568
1676
  init_CursorManager();
1569
1677
  init_sql();
1678
+
1679
+ // src/query/recordRow.ts
1680
+ var PROJECTED_JSON_ALIAS_PREFIX = "_json_";
1681
+ function projectedJsonAlias(fieldName) {
1682
+ return `${PROJECTED_JSON_ALIAS_PREFIX}${fieldName}`;
1683
+ }
1684
+ function parseRecordRow(row) {
1685
+ const { _id, _type, _data, ...rest } = row;
1686
+ let parsed = {};
1687
+ if (_data) {
1688
+ try {
1689
+ parsed = JSON.parse(_data);
1690
+ } catch (e) {
1691
+ console.warn("Failed to parse _data:", e);
1692
+ }
1693
+ }
1694
+ return { id: _id, type: _type, ...parsed, ...decodeProjectedColumns(rest) };
1695
+ }
1696
+ function decodeProjectedColumns(columns) {
1697
+ const decoded = {};
1698
+ for (const [column, value] of Object.entries(columns)) {
1699
+ if (!column.startsWith(PROJECTED_JSON_ALIAS_PREFIX)) {
1700
+ decoded[column] = value;
1701
+ continue;
1702
+ }
1703
+ decoded[column.slice(PROJECTED_JSON_ALIAS_PREFIX.length)] = parseJsonColumn(value);
1704
+ }
1705
+ return decoded;
1706
+ }
1707
+ function parseJsonColumn(value) {
1708
+ if (typeof value !== "string") return value;
1709
+ try {
1710
+ return JSON.parse(value);
1711
+ } catch {
1712
+ return value;
1713
+ }
1714
+ }
1715
+
1716
+ // src/query/JsonQueryTranslator.ts
1570
1717
  init_patterns();
1571
1718
  var SYSTEM_FIELDS = /* @__PURE__ */ new Set(["id", "type"]);
1572
1719
  var JsonQueryTranslator = class {
@@ -1611,7 +1758,8 @@ var JsonQueryTranslator = class {
1611
1758
  const whereClause = this.translateFilter(filter);
1612
1759
  const orderClause = CursorManager.buildOrderClause(
1613
1760
  options?.sort,
1614
- (field) => this.getFieldSql(field)
1761
+ (field) => this.getFieldSql(field),
1762
+ options?.direction || 1
1615
1763
  );
1616
1764
  const limitClause = this.buildLimitClause(options);
1617
1765
  const paginationClause = this.buildPaginationClause(
@@ -2062,7 +2210,7 @@ var JsonQueryTranslator = class {
2062
2210
  for (const field of includeFields) {
2063
2211
  if (field === "id" || field === "type") continue;
2064
2212
  selectParts.push(
2065
- `json_extract(_data, '$.${field}') AS ${quoteIdentifier(field)}`
2213
+ `_data -> '$.${field}' AS ${quoteIdentifier(projectedJsonAlias(field))}`
2066
2214
  );
2067
2215
  }
2068
2216
  return selectParts.join(", ");
@@ -2249,6 +2397,86 @@ var JsonQueryTranslator = class {
2249
2397
  init_CursorManager();
2250
2398
  init_sql();
2251
2399
  var import_ulid = require("ulid");
2400
+ var AtomicBatchAbort = class extends Error {
2401
+ constructor(failedIndex) {
2402
+ super(`atomic batch aborted at operation ${failedIndex}`);
2403
+ this.failedIndex = failedIndex;
2404
+ this.name = "AtomicBatchAbort";
2405
+ }
2406
+ };
2407
+ function checkReservedFieldName(fieldName) {
2408
+ if (fieldName === "id") return null;
2409
+ if (fieldName === "type") {
2410
+ return `Field 'type' is reserved (maps to internal _type column in queries)`;
2411
+ }
2412
+ if (fieldName.startsWith("_")) {
2413
+ return `Field '${fieldName}' is reserved (fields starting with '_' are internal)`;
2414
+ }
2415
+ return null;
2416
+ }
2417
+ function checkAutoStampsReservedFields(autoStamps) {
2418
+ if (!autoStamps) return null;
2419
+ for (const field of autoStamps.create || []) {
2420
+ const err = checkReservedFieldName(field);
2421
+ if (err) return `autoStamps.create: ${err}`;
2422
+ }
2423
+ for (const field of autoStamps.update || []) {
2424
+ const err = checkReservedFieldName(field);
2425
+ if (err) return `autoStamps.update: ${err}`;
2426
+ }
2427
+ return null;
2428
+ }
2429
+ function applyAutoStamps(data, autoStamps, isNew) {
2430
+ if (!autoStamps || !data) return;
2431
+ const reservedError = checkAutoStampsReservedFields(autoStamps);
2432
+ if (reservedError) {
2433
+ throw new Error(reservedError);
2434
+ }
2435
+ const now = Date.now();
2436
+ if (isNew && autoStamps.create) {
2437
+ for (const field of autoStamps.create) {
2438
+ if (data[field] === void 0 || data[field] === null) {
2439
+ data[field] = now;
2440
+ }
2441
+ }
2442
+ }
2443
+ if (autoStamps.update) {
2444
+ for (const field of autoStamps.update) {
2445
+ if (data[field] === void 0 || data[field] === null) {
2446
+ data[field] = now;
2447
+ }
2448
+ }
2449
+ }
2450
+ }
2451
+ function collectAppliedStamps(data, autoStamps, isNew) {
2452
+ if (!autoStamps || !data) return void 0;
2453
+ const stamps = {};
2454
+ const take = (fields) => {
2455
+ if (!fields) return;
2456
+ for (const field of fields) {
2457
+ const value = data[field];
2458
+ if (typeof value === "number" && Number.isFinite(value)) {
2459
+ stamps[field] = value;
2460
+ }
2461
+ }
2462
+ };
2463
+ if (isNew) take(autoStamps.create);
2464
+ take(autoStamps.update);
2465
+ return Object.keys(stamps).length > 0 ? stamps : void 0;
2466
+ }
2467
+ function collectAppliedValues(data, fieldNames) {
2468
+ if (!data || !fieldNames || fieldNames.length === 0) return void 0;
2469
+ const out = {};
2470
+ for (const name of fieldNames) {
2471
+ const value = data[name];
2472
+ if (value !== void 0) out[name] = value;
2473
+ }
2474
+ return Object.keys(out).length > 0 ? out : void 0;
2475
+ }
2476
+ function mergeAppliedFields(stamps, applied) {
2477
+ if (!stamps && !applied) return void 0;
2478
+ return { ...stamps ?? {}, ...applied ?? {} };
2479
+ }
2252
2480
  function createDatabaseDO(config = {}) {
2253
2481
  const hooks = config.hooks;
2254
2482
  return class DocumentDO {
@@ -2406,6 +2634,16 @@ function createDatabaseDO(config = {}) {
2406
2634
  hasMore,
2407
2635
  isFirstPage
2408
2636
  );
2637
+ if (options?.projection && Object.keys(options.projection).length > 0) {
2638
+ const isExclusion = Object.values(options.projection).some(
2639
+ (v) => v === 0
2640
+ );
2641
+ if (isExclusion) {
2642
+ data = data.map(
2643
+ (rec) => this._applyProjection(rec, options.projection)
2644
+ );
2645
+ }
2646
+ }
2409
2647
  const response = {
2410
2648
  data,
2411
2649
  hasMore,
@@ -2429,7 +2667,15 @@ function createDatabaseDO(config = {}) {
2429
2667
  /** @internal */
2430
2668
  async _handleSave(request, docId) {
2431
2669
  const body = await request.json();
2432
- const { modelName, data, stringSets, ifNotExists, condition, upsertOn } = body;
2670
+ const {
2671
+ modelName,
2672
+ data,
2673
+ stringSets,
2674
+ ifNotExists,
2675
+ condition,
2676
+ upsertOn,
2677
+ autoStamps
2678
+ } = body;
2433
2679
  let id = body.id;
2434
2680
  if (!modelName || !id && !upsertOn) {
2435
2681
  return this._errorResponse("modelName is required, and either id or upsertOn must be provided", 400);
@@ -2443,6 +2689,12 @@ function createDatabaseDO(config = {}) {
2443
2689
  return this._errorResponse(reservedError, 400);
2444
2690
  }
2445
2691
  }
2692
+ {
2693
+ const autoStampsReservedError = checkAutoStampsReservedFields(autoStamps);
2694
+ if (autoStampsReservedError) {
2695
+ return this._errorResponse(autoStampsReservedError, 400);
2696
+ }
2697
+ }
2446
2698
  if (upsertOn) {
2447
2699
  if (data[upsertOn] === void 0 || data[upsertOn] === null || data[upsertOn] === "") {
2448
2700
  return this._errorResponse(
@@ -2492,8 +2744,10 @@ function createDatabaseDO(config = {}) {
2492
2744
  409
2493
2745
  );
2494
2746
  }
2747
+ const isNew = !this._engine.recordExists(modelName, id);
2748
+ applyAutoStamps(data, autoStamps, isNew);
2749
+ let hookAppliedNames;
2495
2750
  if (hooks?.beforeSave) {
2496
- const isNew = !this._engine.recordExists(modelName, id);
2497
2751
  const ctx = {
2498
2752
  modelName,
2499
2753
  docId,
@@ -2508,6 +2762,7 @@ function createDatabaseDO(config = {}) {
2508
2762
  if (!result.allow) {
2509
2763
  return this._errorResponse(result.reason || "Save denied", 403);
2510
2764
  }
2765
+ hookAppliedNames = result.serverAppliedFieldNames;
2511
2766
  }
2512
2767
  const existingForUnique = this._engine.execSqlSync(
2513
2768
  "SELECT _data FROM records WHERE _id = ? AND _type = ?",
@@ -2537,12 +2792,17 @@ function createDatabaseDO(config = {}) {
2537
2792
  }
2538
2793
  this._engine.trackModelFields(modelName, data);
2539
2794
  const response = { success: true, id };
2795
+ const appliedFields = mergeAppliedFields(
2796
+ collectAppliedStamps(data, autoStamps, isNew),
2797
+ collectAppliedValues(data, hookAppliedNames)
2798
+ );
2799
+ if (appliedFields) response.appliedFields = appliedFields;
2540
2800
  return Response.json(response);
2541
2801
  }
2542
2802
  /** @internal — Partial update: merge provided fields into existing record */
2543
2803
  async _handlePatch(request, docId) {
2544
2804
  const body = await request.json();
2545
- const { modelName, id, data, stringSets, condition } = body;
2805
+ const { modelName, id, data, stringSets, condition, autoStamps } = body;
2546
2806
  if (!modelName || !id || !data) {
2547
2807
  return this._errorResponse("modelName, id, and data are required", 400);
2548
2808
  }
@@ -2550,6 +2810,12 @@ function createDatabaseDO(config = {}) {
2550
2810
  if (reservedError) {
2551
2811
  return this._errorResponse(reservedError, 400);
2552
2812
  }
2813
+ {
2814
+ const autoStampsReservedError = checkAutoStampsReservedFields(autoStamps);
2815
+ if (autoStampsReservedError) {
2816
+ return this._errorResponse(autoStampsReservedError, 400);
2817
+ }
2818
+ }
2553
2819
  if (!this._engine.recordExists(modelName, id)) {
2554
2820
  return this._errorResponse(`Record ${modelName}/${id} not found`, 404);
2555
2821
  }
@@ -2559,6 +2825,8 @@ function createDatabaseDO(config = {}) {
2559
2825
  409
2560
2826
  );
2561
2827
  }
2828
+ applyAutoStamps(data, autoStamps, false);
2829
+ let hookAppliedNames;
2562
2830
  if (hooks?.beforeSave) {
2563
2831
  const ctx = {
2564
2832
  modelName,
@@ -2574,6 +2842,7 @@ function createDatabaseDO(config = {}) {
2574
2842
  if (!result.allow) {
2575
2843
  return this._errorResponse(result.reason || "Save denied", 403);
2576
2844
  }
2845
+ hookAppliedNames = result.serverAppliedFieldNames;
2577
2846
  }
2578
2847
  const existing = this._engine.execSqlSync(
2579
2848
  "SELECT _data FROM records WHERE _id = ? AND _type = ?",
@@ -2597,6 +2866,11 @@ function createDatabaseDO(config = {}) {
2597
2866
  }
2598
2867
  this._engine.trackModelFields(modelName, data);
2599
2868
  const response = { success: true, id };
2869
+ const appliedFields = mergeAppliedFields(
2870
+ collectAppliedStamps(data, autoStamps, false),
2871
+ collectAppliedValues(data, hookAppliedNames)
2872
+ );
2873
+ if (appliedFields) response.appliedFields = appliedFields;
2600
2874
  return Response.json(response);
2601
2875
  }
2602
2876
  /** @internal */
@@ -2638,13 +2912,16 @@ function createDatabaseDO(config = {}) {
2638
2912
  /** @internal — Execute multiple save/patch/delete operations in a single transaction */
2639
2913
  async _handleBatch(request, docId) {
2640
2914
  const body = await request.json();
2641
- const { operations } = body;
2915
+ const { operations, atomic = false } = body;
2642
2916
  if (!operations || !Array.isArray(operations) || operations.length === 0) {
2643
2917
  return this._errorResponse("operations array is required and must not be empty", 400);
2644
2918
  }
2645
2919
  const hookDenials = /* @__PURE__ */ new Map();
2646
2920
  const resolvedBatchIds = /* @__PURE__ */ new Map();
2647
2921
  const opErrors = /* @__PURE__ */ new Map();
2922
+ const batchCreatedIds = /* @__PURE__ */ new Set();
2923
+ const batchIsNew = /* @__PURE__ */ new Map();
2924
+ const batchAppliedNames = /* @__PURE__ */ new Map();
2648
2925
  for (let i = 0; i < operations.length; i++) {
2649
2926
  const op = operations[i];
2650
2927
  if (!op.modelName || !op.id && !(op.op === "save" && op.upsertOn) || !op.op) {
@@ -2696,9 +2973,31 @@ function createDatabaseDO(config = {}) {
2696
2973
  400
2697
2974
  );
2698
2975
  }
2976
+ const autoStampsReservedError = checkAutoStampsReservedFields(
2977
+ op.autoStamps
2978
+ );
2979
+ if (autoStampsReservedError) {
2980
+ return this._errorResponse(
2981
+ `Operation ${i}: ${autoStampsReservedError}`,
2982
+ 400
2983
+ );
2984
+ }
2985
+ const effectiveId = resolvedBatchIds.get(i) || op.id || "";
2986
+ const isNew = op.op === "save" ? (
2987
+ // Codex P2 #3: also treat the id as not-new if a previous
2988
+ // op in this batch already resolved to a save for the same
2989
+ // model+id. Without this, two saves to the same fresh id
2990
+ // both compute isNew=true against the pre-transaction
2991
+ // database, so a `'create'` autoStamp on the second op
2992
+ // would overwrite the first op's stamp.
2993
+ !batchCreatedIds.has(`${op.modelName}/${effectiveId}`) && !this._engine.recordExists(op.modelName, effectiveId)
2994
+ ) : false;
2995
+ if (op.op === "save" && isNew && effectiveId) {
2996
+ batchCreatedIds.add(`${op.modelName}/${effectiveId}`);
2997
+ }
2998
+ batchIsNew.set(i, isNew);
2999
+ applyAutoStamps(op.data, op.autoStamps, isNew);
2699
3000
  if (hooks?.beforeSave) {
2700
- const effectiveId = resolvedBatchIds.get(i) || op.id || "";
2701
- const isNew = op.op === "save" ? !this._engine.recordExists(op.modelName, effectiveId) : false;
2702
3001
  const ctx = {
2703
3002
  modelName: op.modelName,
2704
3003
  docId,
@@ -2712,6 +3011,8 @@ function createDatabaseDO(config = {}) {
2712
3011
  const result = await hooks.beforeSave(ctx);
2713
3012
  if (!result.allow) {
2714
3013
  hookDenials.set(i, result.reason || "Save denied");
3014
+ } else if (result.serverAppliedFieldNames) {
3015
+ batchAppliedNames.set(i, result.serverAppliedFieldNames);
2715
3016
  }
2716
3017
  }
2717
3018
  } else if (op.op === "delete") {
@@ -2760,205 +3061,263 @@ function createDatabaseDO(config = {}) {
2760
3061
  }
2761
3062
  const results = [];
2762
3063
  const fieldsToTrack = [];
2763
- this._engine.transactionSync(() => {
2764
- for (let i = 0; i < operations.length; i++) {
2765
- const op = operations[i];
2766
- if (hookDenials.has(i)) {
2767
- const effectiveId = resolvedBatchIds.get(i) || op.id || "";
2768
- results.push({
2769
- success: false,
2770
- id: effectiveId,
2771
- error: hookDenials.get(i)
2772
- });
2773
- continue;
2774
- }
2775
- if (opErrors.has(i)) {
2776
- results.push({
2777
- success: false,
2778
- id: op.id || "",
2779
- error: opErrors.get(i)
2780
- });
2781
- continue;
2782
- }
2783
- const conditionId = resolvedBatchIds.get(i) || op.id;
2784
- if (op.condition && !this._checkCondition(op.modelName, conditionId, op.condition)) {
2785
- results.push({
2786
- success: false,
2787
- id: conditionId,
2788
- error: `Condition not met for ${op.modelName}/${conditionId}`
2789
- });
2790
- continue;
2791
- }
2792
- if (op.op === "save") {
2793
- let opId = resolvedBatchIds.get(i) || op.id;
2794
- if (op.ifNotExists && this._engine.recordExists(op.modelName, opId)) {
2795
- results.push({
2796
- success: false,
2797
- id: opId,
2798
- error: `Record ${op.modelName}/${opId} already exists`
3064
+ const pushFailure = (index, failure) => {
3065
+ results.push({
3066
+ success: false,
3067
+ id: failure.id,
3068
+ error: failure.error,
3069
+ status: failure.status,
3070
+ code: failure.code
3071
+ });
3072
+ if (atomic) throw new AtomicBatchAbort(index);
3073
+ };
3074
+ try {
3075
+ this._engine.transactionSync(() => {
3076
+ for (let i = 0; i < operations.length; i++) {
3077
+ const op = operations[i];
3078
+ if (hookDenials.has(i)) {
3079
+ const effectiveId = resolvedBatchIds.get(i) || op.id || "";
3080
+ pushFailure(i, {
3081
+ id: effectiveId,
3082
+ error: hookDenials.get(i),
3083
+ status: 403,
3084
+ code: "HOOK_DENIED"
2799
3085
  });
2800
3086
  continue;
2801
3087
  }
2802
- if (op.data) {
2803
- const existingForUnique = this._engine.execSqlSync(
2804
- "SELECT _data FROM records WHERE _id = ? AND _type = ?",
2805
- [opId, op.modelName]
2806
- );
2807
- let dataForUnique = op.data;
2808
- if (existingForUnique.length > 0) {
2809
- const existingData = JSON.parse(existingForUnique[0]._data);
2810
- dataForUnique = { ...existingData, ...op.data };
2811
- }
2812
- const violation = this._engine.checkUniqueConstraints(
2813
- op.modelName,
2814
- opId,
2815
- dataForUnique
2816
- );
2817
- if (violation) {
2818
- results.push({
2819
- success: false,
3088
+ if (opErrors.has(i)) {
3089
+ pushFailure(i, {
3090
+ id: op.id || "",
3091
+ error: opErrors.get(i),
3092
+ status: 400,
3093
+ code: "VALIDATION_ERROR"
3094
+ });
3095
+ continue;
3096
+ }
3097
+ const conditionId = resolvedBatchIds.get(i) || op.id;
3098
+ if (op.condition && !this._checkCondition(op.modelName, conditionId, op.condition)) {
3099
+ pushFailure(i, {
3100
+ id: conditionId,
3101
+ error: `Condition not met for ${op.modelName}/${conditionId}`,
3102
+ status: 409,
3103
+ code: "CONDITION_NOT_MET"
3104
+ });
3105
+ continue;
3106
+ }
3107
+ if (op.op === "save") {
3108
+ let opId = resolvedBatchIds.get(i) || op.id;
3109
+ if (op.ifNotExists && this._engine.recordExists(op.modelName, opId)) {
3110
+ pushFailure(i, {
2820
3111
  id: opId,
2821
- error: violation
3112
+ error: `Record ${op.modelName}/${opId} already exists`,
3113
+ status: 409,
3114
+ code: "ALREADY_EXISTS"
2822
3115
  });
2823
3116
  continue;
2824
3117
  }
2825
- }
2826
- if (op.stringSets && Object.keys(op.stringSets).length > 0) {
2827
- const { id: _id, ...fieldsForJson } = op.data;
2828
- const jsonFields = { ...fieldsForJson, ...op.stringSets };
2829
- const dataJson = JSON.stringify(jsonFields);
2830
- this._engine.execSqlSync(
2831
- "INSERT INTO records (_id, _type, _data) VALUES (?, ?, ?) ON CONFLICT(_type, _id) DO UPDATE SET _data = json_patch(records._data, excluded._data)",
2832
- [opId, op.modelName, dataJson]
2833
- );
2834
- for (const [fieldName, values] of Object.entries(op.stringSets)) {
2835
- this._engine.execSqlSync(
2836
- "DELETE FROM stringset_index WHERE _type = ? AND _record_id = ? AND field = ?",
2837
- [op.modelName, opId, fieldName]
3118
+ if (op.data) {
3119
+ const existingForUnique = this._engine.execSqlSync(
3120
+ "SELECT _data FROM records WHERE _id = ? AND _type = ?",
3121
+ [opId, op.modelName]
2838
3122
  );
2839
- for (const value of values) {
2840
- this._engine.execSqlSync(
2841
- "INSERT OR IGNORE INTO stringset_index (_record_id, _type, field, value) VALUES (?, ?, ?, ?)",
2842
- [opId, op.modelName, fieldName, value]
2843
- );
3123
+ let dataForUnique = op.data;
3124
+ if (existingForUnique.length > 0) {
3125
+ const existingData = JSON.parse(existingForUnique[0]._data);
3126
+ dataForUnique = { ...existingData, ...op.data };
2844
3127
  }
2845
- }
2846
- } else {
2847
- const { id: _id, ...fieldsForJson } = op.data;
2848
- const dataJson = JSON.stringify(fieldsForJson);
2849
- this._engine.execSqlSync(
2850
- "INSERT INTO records (_id, _type, _data) VALUES (?, ?, ?) ON CONFLICT(_type, _id) DO UPDATE SET _data = json_patch(records._data, excluded._data)",
2851
- [opId, op.modelName, dataJson]
2852
- );
2853
- }
2854
- if (op.data) fieldsToTrack.push({ modelName: op.modelName, data: op.data });
2855
- results.push({ success: true, id: opId });
2856
- } else if (op.op === "patch") {
2857
- if (!this._engine.recordExists(op.modelName, op.id)) {
2858
- results.push({
2859
- success: false,
2860
- id: op.id,
2861
- error: `Record ${op.modelName}/${op.id} not found`
2862
- });
2863
- continue;
2864
- }
2865
- if (op.data) {
2866
- const existing = this._engine.execSqlSync(
2867
- "SELECT _data FROM records WHERE _id = ? AND _type = ?",
2868
- [op.id, op.modelName]
2869
- );
2870
- if (existing.length > 0) {
2871
- const existingData = JSON.parse(existing[0]._data);
2872
- const mergedData = { ...existingData, ...op.data };
2873
3128
  const violation = this._engine.checkUniqueConstraints(
2874
3129
  op.modelName,
2875
- op.id,
2876
- mergedData
3130
+ opId,
3131
+ dataForUnique
2877
3132
  );
2878
3133
  if (violation) {
2879
- results.push({
2880
- success: false,
2881
- id: op.id,
2882
- error: violation
3134
+ pushFailure(i, {
3135
+ id: opId,
3136
+ error: violation,
3137
+ status: 409,
3138
+ code: "UNIQUE_VIOLATION"
2883
3139
  });
2884
3140
  continue;
2885
3141
  }
2886
3142
  }
2887
- }
2888
- const found = this._engine.patchRecordRaw(
2889
- op.modelName,
2890
- op.id,
2891
- op.data,
2892
- op.stringSets
2893
- );
2894
- if (!found) {
2895
- results.push({
2896
- success: false,
2897
- id: op.id,
2898
- error: `Record ${op.modelName}/${op.id} not found`
2899
- });
2900
- } else {
3143
+ if (op.stringSets && Object.keys(op.stringSets).length > 0) {
3144
+ const { id: _id, ...fieldsForJson } = op.data;
3145
+ const jsonFields = { ...fieldsForJson, ...op.stringSets };
3146
+ const dataJson = JSON.stringify(jsonFields);
3147
+ this._engine.execSqlSync(
3148
+ "INSERT INTO records (_id, _type, _data) VALUES (?, ?, ?) ON CONFLICT(_type, _id) DO UPDATE SET _data = json_patch(records._data, excluded._data)",
3149
+ [opId, op.modelName, dataJson]
3150
+ );
3151
+ for (const [fieldName, values] of Object.entries(op.stringSets)) {
3152
+ this._engine.execSqlSync(
3153
+ "DELETE FROM stringset_index WHERE _type = ? AND _record_id = ? AND field = ?",
3154
+ [op.modelName, opId, fieldName]
3155
+ );
3156
+ for (const value of values) {
3157
+ this._engine.execSqlSync(
3158
+ "INSERT OR IGNORE INTO stringset_index (_record_id, _type, field, value) VALUES (?, ?, ?, ?)",
3159
+ [opId, op.modelName, fieldName, value]
3160
+ );
3161
+ }
3162
+ }
3163
+ } else {
3164
+ const { id: _id, ...fieldsForJson } = op.data;
3165
+ const dataJson = JSON.stringify(fieldsForJson);
3166
+ this._engine.execSqlSync(
3167
+ "INSERT INTO records (_id, _type, _data) VALUES (?, ?, ?) ON CONFLICT(_type, _id) DO UPDATE SET _data = json_patch(records._data, excluded._data)",
3168
+ [opId, op.modelName, dataJson]
3169
+ );
3170
+ }
2901
3171
  if (op.data) fieldsToTrack.push({ modelName: op.modelName, data: op.data });
3172
+ const saveResult = { success: true, id: opId };
3173
+ const saveApplied = mergeAppliedFields(
3174
+ collectAppliedStamps(
3175
+ op.data,
3176
+ op.autoStamps,
3177
+ batchIsNew.get(i) ?? false
3178
+ ),
3179
+ collectAppliedValues(op.data, batchAppliedNames.get(i))
3180
+ );
3181
+ if (saveApplied) saveResult.appliedFields = saveApplied;
3182
+ results.push(saveResult);
3183
+ } else if (op.op === "patch") {
3184
+ if (!this._engine.recordExists(op.modelName, op.id)) {
3185
+ pushFailure(i, {
3186
+ id: op.id,
3187
+ error: `Record ${op.modelName}/${op.id} not found`,
3188
+ status: 404,
3189
+ code: "NOT_FOUND"
3190
+ });
3191
+ continue;
3192
+ }
3193
+ if (op.data) {
3194
+ const existing = this._engine.execSqlSync(
3195
+ "SELECT _data FROM records WHERE _id = ? AND _type = ?",
3196
+ [op.id, op.modelName]
3197
+ );
3198
+ if (existing.length > 0) {
3199
+ const existingData = JSON.parse(existing[0]._data);
3200
+ const mergedData = { ...existingData, ...op.data };
3201
+ const violation = this._engine.checkUniqueConstraints(
3202
+ op.modelName,
3203
+ op.id,
3204
+ mergedData
3205
+ );
3206
+ if (violation) {
3207
+ pushFailure(i, {
3208
+ id: op.id,
3209
+ error: violation,
3210
+ status: 409,
3211
+ code: "UNIQUE_VIOLATION"
3212
+ });
3213
+ continue;
3214
+ }
3215
+ }
3216
+ }
3217
+ const found = this._engine.patchRecordRaw(
3218
+ op.modelName,
3219
+ op.id,
3220
+ op.data,
3221
+ op.stringSets
3222
+ );
3223
+ if (!found) {
3224
+ pushFailure(i, {
3225
+ id: op.id,
3226
+ error: `Record ${op.modelName}/${op.id} not found`,
3227
+ status: 404,
3228
+ code: "NOT_FOUND"
3229
+ });
3230
+ } else {
3231
+ if (op.data) fieldsToTrack.push({ modelName: op.modelName, data: op.data });
3232
+ const patchResult = {
3233
+ success: true,
3234
+ id: op.id
3235
+ };
3236
+ const patchApplied = mergeAppliedFields(
3237
+ collectAppliedStamps(op.data, op.autoStamps, false),
3238
+ collectAppliedValues(op.data, batchAppliedNames.get(i))
3239
+ );
3240
+ if (patchApplied) patchResult.appliedFields = patchApplied;
3241
+ results.push(patchResult);
3242
+ }
3243
+ } else if (op.op === "delete") {
3244
+ this._engine.execSqlSync(
3245
+ "DELETE FROM records WHERE _id = ? AND _type = ?",
3246
+ [op.id, op.modelName]
3247
+ );
3248
+ this._engine.execSqlSync(
3249
+ "DELETE FROM stringset_index WHERE _record_id = ? AND _type = ?",
3250
+ [op.id, op.modelName]
3251
+ );
3252
+ results.push({ success: true, id: op.id });
3253
+ } else if (op.op === "increment") {
3254
+ const newValues = this._engine.incrementFieldsRaw(
3255
+ op.modelName,
3256
+ op.id,
3257
+ op.fields
3258
+ );
3259
+ if (!newValues) {
3260
+ pushFailure(i, {
3261
+ id: op.id,
3262
+ error: `Record ${op.modelName}/${op.id} not found`,
3263
+ status: 404,
3264
+ code: "NOT_FOUND"
3265
+ });
3266
+ } else {
3267
+ results.push({ success: true, id: op.id, values: newValues });
3268
+ }
3269
+ } else if (op.op === "addToSet") {
3270
+ if (!this._engine.recordExists(op.modelName, op.id)) {
3271
+ pushFailure(i, {
3272
+ id: op.id,
3273
+ error: `Record ${op.modelName}/${op.id} not found`,
3274
+ status: 404,
3275
+ code: "NOT_FOUND"
3276
+ });
3277
+ continue;
3278
+ }
3279
+ this._engine.addToStringSetsRaw(
3280
+ op.modelName,
3281
+ op.id,
3282
+ op.stringSets
3283
+ );
3284
+ results.push({ success: true, id: op.id });
3285
+ } else if (op.op === "removeFromSet") {
3286
+ if (!this._engine.recordExists(op.modelName, op.id)) {
3287
+ pushFailure(i, {
3288
+ id: op.id,
3289
+ error: `Record ${op.modelName}/${op.id} not found`,
3290
+ status: 404,
3291
+ code: "NOT_FOUND"
3292
+ });
3293
+ continue;
3294
+ }
3295
+ this._engine.removeFromStringSetsRaw(
3296
+ op.modelName,
3297
+ op.id,
3298
+ op.stringSets
3299
+ );
2902
3300
  results.push({ success: true, id: op.id });
2903
3301
  }
2904
- } else if (op.op === "delete") {
2905
- this._engine.execSqlSync(
2906
- "DELETE FROM records WHERE _id = ? AND _type = ?",
2907
- [op.id, op.modelName]
2908
- );
2909
- this._engine.execSqlSync(
2910
- "DELETE FROM stringset_index WHERE _record_id = ? AND _type = ?",
2911
- [op.id, op.modelName]
2912
- );
2913
- results.push({ success: true, id: op.id });
2914
- } else if (op.op === "increment") {
2915
- const newValues = this._engine.incrementFieldsRaw(
2916
- op.modelName,
2917
- op.id,
2918
- op.fields
2919
- );
2920
- if (!newValues) {
2921
- results.push({
2922
- success: false,
2923
- id: op.id,
2924
- error: `Record ${op.modelName}/${op.id} not found`
2925
- });
2926
- } else {
2927
- results.push({ success: true, id: op.id, values: newValues });
2928
- }
2929
- } else if (op.op === "addToSet") {
2930
- if (!this._engine.recordExists(op.modelName, op.id)) {
2931
- results.push({
2932
- success: false,
2933
- id: op.id,
2934
- error: `Record ${op.modelName}/${op.id} not found`
2935
- });
2936
- continue;
2937
- }
2938
- this._engine.addToStringSetsRaw(
2939
- op.modelName,
2940
- op.id,
2941
- op.stringSets
2942
- );
2943
- results.push({ success: true, id: op.id });
2944
- } else if (op.op === "removeFromSet") {
2945
- if (!this._engine.recordExists(op.modelName, op.id)) {
2946
- results.push({
2947
- success: false,
2948
- id: op.id,
2949
- error: `Record ${op.modelName}/${op.id} not found`
2950
- });
2951
- continue;
2952
- }
2953
- this._engine.removeFromStringSetsRaw(
2954
- op.modelName,
2955
- op.id,
2956
- op.stringSets
2957
- );
2958
- results.push({ success: true, id: op.id });
2959
3302
  }
3303
+ });
3304
+ } catch (err) {
3305
+ if (err instanceof AtomicBatchAbort) {
3306
+ const failed = results[err.failedIndex];
3307
+ const failedOp = operations[err.failedIndex];
3308
+ console.warn(
3309
+ `[js-bao batch] atomic rollback: operation ${err.failedIndex} (${failedOp?.op}) failed \u2014 ${failed?.error ?? "unknown error"}`
3310
+ );
3311
+ const errorBody = {
3312
+ error: `Operation ${err.failedIndex} (${failedOp?.op}) failed: ${failed?.error ?? "unknown error"}`,
3313
+ failedIndex: err.failedIndex,
3314
+ op: failedOp?.op,
3315
+ code: failed?.code
3316
+ };
3317
+ return Response.json(errorBody, { status: failed?.status ?? 409 });
2960
3318
  }
2961
- });
3319
+ throw err;
3320
+ }
2962
3321
  for (const entry of fieldsToTrack) {
2963
3322
  this._engine.trackModelFields(entry.modelName, entry.data);
2964
3323
  }
@@ -3328,6 +3687,18 @@ function createDatabaseDO(config = {}) {
3328
3687
  }
3329
3688
  return facetResult;
3330
3689
  }
3690
+ if (aggOptions.groupBy.length === 0) {
3691
+ const row = results[0];
3692
+ const flat = {};
3693
+ for (const op of aggOptions.operations) {
3694
+ if (op.type === "count") {
3695
+ flat.count = row.count;
3696
+ } else {
3697
+ flat[`${op.type}_${op.field}`] = row[`${op.type}_${op.field}`];
3698
+ }
3699
+ }
3700
+ return flat;
3701
+ }
3331
3702
  const nestedResult = {};
3332
3703
  const aliasLookup = /* @__PURE__ */ new Map();
3333
3704
  for (const entry of aliasMap) {
@@ -3655,16 +4026,7 @@ function createDatabaseDO(config = {}) {
3655
4026
  }
3656
4027
  /** @internal */
3657
4028
  _parseRow(row) {
3658
- const { _id, _type, _data, ...rest } = row;
3659
- let parsed = {};
3660
- if (_data) {
3661
- try {
3662
- parsed = JSON.parse(_data);
3663
- } catch (e) {
3664
- console.warn("Failed to parse _data:", e);
3665
- }
3666
- }
3667
- return { id: _id, type: _type, ...parsed, ...rest };
4029
+ return parseRecordRow(row);
3668
4030
  }
3669
4031
  // ─── Include (Related Data Loading) ──────────────────────────────
3670
4032
  /** @internal — Validate an IncludeSpec, returning an error message or null */
@@ -3794,8 +4156,9 @@ function createDatabaseDO(config = {}) {
3794
4156
  spec.filter,
3795
4157
  { projection: spec.projection }
3796
4158
  );
4159
+ const relatedProjected = spec.projection ? related.map((r) => this._applyProjection(r, spec.projection)) : related;
3797
4160
  const lookupMap = /* @__PURE__ */ new Map();
3798
- for (const r of related) {
4161
+ for (const r of relatedProjected) {
3799
4162
  lookupMap.set(r.id, r);
3800
4163
  }
3801
4164
  for (const rec of records) {
@@ -3828,8 +4191,9 @@ function createDatabaseDO(config = {}) {
3828
4191
  spec.filter,
3829
4192
  { projection: spec.projection }
3830
4193
  );
4194
+ const targetsProjected = spec.projection ? targets.map((t) => this._applyProjection(t, spec.projection)) : targets;
3831
4195
  const targetLookup = /* @__PURE__ */ new Map();
3832
- for (const t of targets) {
4196
+ for (const t of targetsProjected) {
3833
4197
  targetLookup.set(t.id, t);
3834
4198
  }
3835
4199
  for (const rec of records) {
@@ -3973,6 +4337,14 @@ function createDatabaseDO(config = {}) {
3973
4337
  return allResults;
3974
4338
  }
3975
4339
  /** @internal — Apply projection to a single record */
4340
+ // Apply a projection to a single record. Assumes the projection has
4341
+ // already been validated upstream (JsonQueryTranslator.validateProjection /
4342
+ // buildSelectClause reject mixed inclusion+exclusion): `isInclusion` treats
4343
+ // "any value === 1" as inclusion mode, so an unvalidated mixed projection
4344
+ // would silently ignore the excludes. `id` and `type` are system-identity
4345
+ // fields and are always kept in BOTH modes (a record without its `id` is
4346
+ // unusable downstream). `_meta` is NOT forced — an explicit `{ _meta: 0 }`
4347
+ // is honored.
3976
4348
  _applyProjection(record, projection) {
3977
4349
  const fields = Object.entries(projection);
3978
4350
  if (fields.length === 0) return record;
@@ -3991,7 +4363,7 @@ function createDatabaseDO(config = {}) {
3991
4363
  } else {
3992
4364
  const result = { ...record };
3993
4365
  for (const [f, v] of fields) {
3994
- if (v === 0) delete result[f];
4366
+ if (v === 0 && f !== "id" && f !== "type") delete result[f];
3995
4367
  }
3996
4368
  return result;
3997
4369
  }
@@ -4059,7 +4431,8 @@ async function handleRequest(request, env) {
4059
4431
  }
4060
4432
 
4061
4433
  // src/utils/yDocSchema.ts
4062
- var Y = __toESM(require("yjs"), 1);
4434
+ var Y2 = __toESM(require("yjs"), 1);
4435
+ init_yjsValues();
4063
4436
  function discoverSchema(yDoc) {
4064
4437
  const models = {};
4065
4438
  const metaNames = /* @__PURE__ */ new Set();
@@ -4093,7 +4466,7 @@ function discoverModelNames(yDoc) {
4093
4466
  function materializeMap(yDoc, key) {
4094
4467
  try {
4095
4468
  const map = yDoc.getMap(key);
4096
- return map instanceof Y.Map ? map : null;
4469
+ return map instanceof Y2.Map ? map : null;
4097
4470
  } catch {
4098
4471
  return null;
4099
4472
  }
@@ -4103,11 +4476,11 @@ function readModelMeta(metaMap) {
4103
4476
  let constraints;
4104
4477
  let relationships;
4105
4478
  for (const [key, value] of metaMap.entries()) {
4106
- if (key === "_constraints" && value instanceof Y.Map) {
4479
+ if (key === "_constraints" && value instanceof Y2.Map) {
4107
4480
  constraints = readConstraints(value);
4108
- } else if (key === "_relationships" && value instanceof Y.Map) {
4481
+ } else if (key === "_relationships" && value instanceof Y2.Map) {
4109
4482
  relationships = readRelationships(value);
4110
- } else if (value instanceof Y.Map) {
4483
+ } else if (value instanceof Y2.Map) {
4111
4484
  fields[key] = readFieldMeta(value);
4112
4485
  }
4113
4486
  }
@@ -4126,6 +4499,10 @@ function readFieldMeta(fieldMap) {
4126
4499
  if (fieldMap.get("unique") === true) field.unique = true;
4127
4500
  if (fieldMap.get("required") === true) field.required = true;
4128
4501
  if (fieldMap.get("autoAssign") === true) field.autoAssign = true;
4502
+ const autoStamp = fieldMap.get("autoStamp");
4503
+ if (typeof autoStamp === "string" && (autoStamp === "create" || autoStamp === "update" || autoStamp === "both")) {
4504
+ field.autoStamp = autoStamp;
4505
+ }
4129
4506
  const def = fieldMap.get("default");
4130
4507
  if (def !== void 0) field.default = def;
4131
4508
  const maxLength = fieldMap.get("maxLength");
@@ -4138,7 +4515,7 @@ function inferModelFromData(dataMap) {
4138
4515
  const fields = {};
4139
4516
  let sampled = 0;
4140
4517
  for (const [_recordId, recordValue] of dataMap.entries()) {
4141
- if (!(recordValue instanceof Y.Map)) continue;
4518
+ if (!(recordValue instanceof Y2.Map)) continue;
4142
4519
  if (++sampled > 5) break;
4143
4520
  for (const [fieldName, value] of recordValue.entries()) {
4144
4521
  if (fieldName.startsWith("_")) continue;
@@ -4151,7 +4528,7 @@ function inferModelFromData(dataMap) {
4151
4528
  return { fields };
4152
4529
  }
4153
4530
  function inferTypeFromValue(value) {
4154
- if (value instanceof Y.Map) return "stringset";
4531
+ if (isStringSetYMap(value)) return "stringset";
4155
4532
  switch (typeof value) {
4156
4533
  case "string":
4157
4534
  return "string";
@@ -4166,7 +4543,7 @@ function inferTypeFromValue(value) {
4166
4543
  function readConstraints(constraintsMap) {
4167
4544
  const out = {};
4168
4545
  for (const [name, value] of constraintsMap.entries()) {
4169
- if (!(value instanceof Y.Map)) continue;
4546
+ if (!(value instanceof Y2.Map)) continue;
4170
4547
  let fields = [];
4171
4548
  const rawFields = value.get("fields");
4172
4549
  if (typeof rawFields === "string") {
@@ -4185,7 +4562,7 @@ function readConstraints(constraintsMap) {
4185
4562
  function readRelationships(relsMap) {
4186
4563
  const out = {};
4187
4564
  for (const [name, value] of relsMap.entries()) {
4188
- if (!(value instanceof Y.Map)) continue;
4565
+ if (!(value instanceof Y2.Map)) continue;
4189
4566
  const rel = {};
4190
4567
  for (const [k, v] of value.entries()) {
4191
4568
  rel[k] = v;
@@ -4196,6 +4573,7 @@ function readRelationships(relsMap) {
4196
4573
  }
4197
4574
  var CAMEL_TO_SNAKE = {
4198
4575
  autoAssign: "auto_assign",
4576
+ autoStamp: "auto_stamp",
4199
4577
  maxLength: "max_length",
4200
4578
  maxCount: "max_count",
4201
4579
  relatedIdField: "related_id_field",
@@ -4226,6 +4604,7 @@ function schemaToToml(schema) {
4226
4604
  lines.push(`[models.${modelName}.fields.${fieldName}]`);
4227
4605
  lines.push(`type = ${tomlValue(field.type)}`);
4228
4606
  if (field.autoAssign) lines.push("auto_assign = true");
4607
+ if (field.autoStamp) lines.push(`auto_stamp = ${tomlValue(field.autoStamp)}`);
4229
4608
  if (field.indexed) lines.push("indexed = true");
4230
4609
  if (field.unique) lines.push("unique = true");
4231
4610
  if (field.required) lines.push("required = true");
@@ -4262,6 +4641,7 @@ var import_smol_toml = require("smol-toml");
4262
4641
  // src/models/schema.ts
4263
4642
  init_BaseModel();
4264
4643
  init_ModelRegistry();
4644
+ init_uniqueIndex();
4265
4645
  function defineModelSchema(input) {
4266
4646
  const { name, fields } = input;
4267
4647
  const options = {
@@ -4304,7 +4684,7 @@ function resolveUniqueConstraints(modelName, fields, customConstraints) {
4304
4684
  for (const [fieldName, options] of fields.entries()) {
4305
4685
  if (options.unique) {
4306
4686
  resolved.push({
4307
- name: `${modelName}_${fieldName}_unique`,
4687
+ name: fieldUniqueConstraintName(modelName, fieldName),
4308
4688
  fields: [fieldName]
4309
4689
  });
4310
4690
  }
@@ -4349,10 +4729,13 @@ var KNOWN_FIELD_KEYS = /* @__PURE__ */ new Set([
4349
4729
  "unique",
4350
4730
  "required",
4351
4731
  "auto_assign",
4732
+ "auto_stamp",
4352
4733
  "max_length",
4353
4734
  "max_count",
4354
- "default"
4735
+ "default",
4736
+ "enum"
4355
4737
  ]);
4738
+ var VALID_AUTO_STAMP_VALUES = /* @__PURE__ */ new Set(["create", "update", "both"]);
4356
4739
  var KNOWN_MODEL_KEYS = /* @__PURE__ */ new Set([
4357
4740
  "fields",
4358
4741
  "relationships",
@@ -4394,9 +4777,37 @@ function parseFieldOptions(raw, context, strict) {
4394
4777
  if (raw.unique === true) opts.unique = true;
4395
4778
  if (raw.required === true) opts.required = true;
4396
4779
  if (raw.auto_assign === true) opts.autoAssign = true;
4780
+ if (raw.auto_stamp !== void 0) {
4781
+ if (typeof raw.auto_stamp !== "string" || !VALID_AUTO_STAMP_VALUES.has(raw.auto_stamp)) {
4782
+ throw new Error(
4783
+ `${context}: invalid auto_stamp value "${raw.auto_stamp}". Must be one of: ${[
4784
+ ...VALID_AUTO_STAMP_VALUES
4785
+ ].join(", ")}`
4786
+ );
4787
+ }
4788
+ opts.autoStamp = raw.auto_stamp;
4789
+ }
4397
4790
  if (raw.max_length !== void 0) opts.maxLength = raw.max_length;
4398
4791
  if (raw.max_count !== void 0) opts.maxCount = raw.max_count;
4399
4792
  if (raw.default !== void 0) opts.default = raw.default;
4793
+ if (raw.enum !== void 0) {
4794
+ if (raw.type !== "string") {
4795
+ throw new Error(
4796
+ `${context}: \`enum\` is only valid on a "string" field, not "${raw.type}".`
4797
+ );
4798
+ }
4799
+ if (!Array.isArray(raw.enum) || raw.enum.length === 0) {
4800
+ throw new Error(
4801
+ `${context}: \`enum\` must be a non-empty array of strings.`
4802
+ );
4803
+ }
4804
+ if (!raw.enum.every((v) => typeof v === "string")) {
4805
+ throw new Error(
4806
+ `${context}: \`enum\` values must all be strings.`
4807
+ );
4808
+ }
4809
+ opts.enum = [...raw.enum];
4810
+ }
4400
4811
  return opts;
4401
4812
  }
4402
4813
  function requireField(raw, field, context) {
@@ -4520,3 +4931,4 @@ function loadSchemaFromTomlString(tomlString, options = {}) {
4520
4931
 
4521
4932
  // src/cloudflare-do.ts
4522
4933
  init_metaSync();
4934
+ init_sql();