js-bao 0.5.1 → 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.
@@ -256,21 +256,36 @@ var init_CursorManager = __esm({
256
256
  return fields;
257
257
  }
258
258
  /**
259
- * 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.
260
272
  */
261
- static buildOrderClause(sort, fieldFormatter = (field) => field) {
273
+ static buildOrderClause(sort, fieldFormatter = (field) => field, direction = 1) {
262
274
  const fields = this.extractSortFields(sort);
263
275
  const clauses = [];
264
276
  const directions = [];
277
+ const sqlDir = (declared) => {
278
+ const effective = direction === -1 ? declared === 1 ? -1 : 1 : declared;
279
+ return effective === 1 ? "ASC" : "DESC";
280
+ };
265
281
  if (sort && Object.keys(sort).length > 0) {
266
282
  for (const [field, dir] of Object.entries(sort)) {
267
- const sqlDirection = dir === 1 ? "ASC" : "DESC";
268
- clauses.push(`${fieldFormatter(field)} ${sqlDirection}`);
283
+ clauses.push(`${fieldFormatter(field)} ${sqlDir(dir)}`);
269
284
  directions.push(dir);
270
285
  }
271
286
  }
272
287
  if (!sort || !sort.hasOwnProperty("id")) {
273
- clauses.push(`${fieldFormatter("id")} ASC`);
288
+ clauses.push(`${fieldFormatter("id")} ${sqlDir(1)}`);
274
289
  directions.push(1);
275
290
  } else if (sort && sort.hasOwnProperty("id")) {
276
291
  directions.push(sort["id"]);
@@ -293,18 +308,27 @@ var init_CursorManager = __esm({
293
308
  /**
294
309
  * Generate next and previous cursors from result set
295
310
  */
296
- static generateResultCursors(results, sortFields, _requestedDirection, hasMore, isFirstPage = false) {
311
+ static generateResultCursors(results, sortFields, requestedDirection, hasMore, isFirstPage = false) {
297
312
  if (results.length === 0) {
298
313
  return {};
299
314
  }
300
315
  const cursors = {};
301
316
  if (hasMore && results.length > 0) {
302
317
  const lastResult = results[results.length - 1];
303
- cursors.nextCursor = this.generateCursor(lastResult, sortFields, 1);
318
+ cursors.nextCursor = this.generateCursor(
319
+ lastResult,
320
+ sortFields,
321
+ requestedDirection
322
+ );
304
323
  }
305
324
  if (results.length > 0 && !isFirstPage) {
306
325
  const firstResult = results[0];
307
- 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
+ );
308
332
  }
309
333
  return cursors;
310
334
  }
@@ -395,6 +419,31 @@ var init_DocumentQueryTranslator = __esm({
395
419
  }
396
420
  });
397
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
+
398
447
  // src/models/metaSync.ts
399
448
  function inferFieldType(value) {
400
449
  if (isStringSetYMap(value)) return "stringset";
@@ -416,7 +465,10 @@ function encodeDefault(value) {
416
465
  if (value === void 0 || value === null) return void 0;
417
466
  if (typeof value === "function") {
418
467
  const name = KNOWN_FUNCTION_DEFAULTS.get(value);
419
- return name ? `$${name}` : "$unknown_function";
468
+ return name ? `$${name}` : UNKNOWN_FUNCTION_DEFAULT;
469
+ }
470
+ if (typeof value === "string" && value.startsWith("$")) {
471
+ return `$${value}`;
420
472
  }
421
473
  return value;
422
474
  }
@@ -442,7 +494,7 @@ function syncModelMeta(yDoc, modelName, schema) {
442
494
  if (compoundConstraints.length > 0) {
443
495
  let constraints = meta.get("_constraints");
444
496
  if (!constraints) {
445
- constraints = new Y3.Map();
497
+ constraints = new Y4.Map();
446
498
  meta.set("_constraints", constraints);
447
499
  }
448
500
  for (const constraint of compoundConstraints) {
@@ -453,7 +505,7 @@ function syncModelMeta(yDoc, modelName, schema) {
453
505
  if (relationships && Object.keys(relationships).length > 0) {
454
506
  let rels = meta.get("_relationships");
455
507
  if (!rels) {
456
- rels = new Y3.Map();
508
+ rels = new Y4.Map();
457
509
  meta.set("_relationships", rels);
458
510
  }
459
511
  for (const [relName, relConfig] of Object.entries(relationships)) {
@@ -465,7 +517,7 @@ function syncModelMeta(yDoc, modelName, schema) {
465
517
  function syncFieldMeta(metaMap, fieldName, fieldOpts) {
466
518
  let fieldMeta = metaMap.get(fieldName);
467
519
  if (!fieldMeta) {
468
- fieldMeta = new Y3.Map();
520
+ fieldMeta = new Y4.Map();
469
521
  metaMap.set(fieldName, fieldMeta);
470
522
  }
471
523
  setIfChanged(fieldMeta, "type", fieldOpts.type);
@@ -482,7 +534,7 @@ function syncFieldMeta(metaMap, fieldName, fieldOpts) {
482
534
  function syncConstraintMeta(constraintsMap, constraint) {
483
535
  let cMeta = constraintsMap.get(constraint.name);
484
536
  if (!cMeta) {
485
- cMeta = new Y3.Map();
537
+ cMeta = new Y4.Map();
486
538
  constraintsMap.set(constraint.name, cMeta);
487
539
  }
488
540
  setIfChanged(cMeta, "type", "unique");
@@ -492,7 +544,7 @@ function syncConstraintMeta(constraintsMap, constraint) {
492
544
  function syncRelationshipMeta(relsMap, relName, relConfig) {
493
545
  let relMeta = relsMap.get(relName);
494
546
  if (!relMeta) {
495
- relMeta = new Y3.Map();
547
+ relMeta = new Y4.Map();
496
548
  relsMap.set(relName, relMeta);
497
549
  }
498
550
  for (const [key, value] of Object.entries(relConfig)) {
@@ -507,7 +559,7 @@ function syncInferredMeta(yDoc, modelName, recordData) {
507
559
  if (fieldName.startsWith("_")) continue;
508
560
  let fieldMeta = meta.get(fieldName);
509
561
  if (!fieldMeta) {
510
- fieldMeta = new Y3.Map();
562
+ fieldMeta = new Y4.Map();
511
563
  meta.set(fieldName, fieldMeta);
512
564
  }
513
565
  if (!fieldMeta.has("type")) {
@@ -523,34 +575,49 @@ function setIfChanged(map, key, value) {
523
575
  map.set(key, value);
524
576
  }
525
577
  }
526
- var Y3, KNOWN_FUNCTION_DEFAULTS, _syncedCache;
578
+ var Y4, KNOWN_FUNCTION_DEFAULTS, _syncedCache;
527
579
  var init_metaSync = __esm({
528
580
  "src/models/metaSync.ts"() {
529
581
  "use strict";
530
- Y3 = __toESM(require("yjs"), 1);
582
+ Y4 = __toESM(require("yjs"), 1);
531
583
  init_yjsValues();
584
+ init_writeLifecycle();
532
585
  KNOWN_FUNCTION_DEFAULTS = /* @__PURE__ */ new WeakMap();
533
586
  _syncedCache = /* @__PURE__ */ new WeakMap();
534
587
  }
535
588
  });
536
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
+
537
602
  // src/models/BaseModel.ts
538
603
  function generateULID() {
539
- return (0, import_ulid2.ulid)();
604
+ return (0, import_ulid3.ulid)();
540
605
  }
541
- var Y4, import_ulid2;
606
+ var Y6, import_ulid3;
542
607
  var init_BaseModel = __esm({
543
608
  "src/models/BaseModel.ts"() {
544
609
  "use strict";
545
- Y4 = __toESM(require("yjs"), 1);
546
- import_ulid2 = require("ulid");
610
+ Y6 = __toESM(require("yjs"), 1);
611
+ import_ulid3 = require("ulid");
547
612
  init_StringSet();
548
613
  init_documentTypes();
549
614
  init_DocumentQueryTranslator();
550
615
  init_CursorManager();
551
616
  init_sql();
552
617
  init_metaSync();
618
+ init_writeLifecycle();
553
619
  init_yjsValues();
620
+ init_uniqueIndex();
554
621
  registerFunctionDefault(generateULID, "generate_ulid");
555
622
  }
556
623
  });
@@ -578,6 +645,7 @@ __export(cloudflare_do_exports, {
578
645
  DurableObjectEngine: () => DurableObjectEngine,
579
646
  JsonQueryTranslator: () => JsonQueryTranslator,
580
647
  JsonSchemaDDL: () => JsonSchemaDDL,
648
+ PROJECTED_JSON_ALIAS_PREFIX: () => PROJECTED_JSON_ALIAS_PREFIX,
581
649
  buildSafeAlias: () => buildSafeAlias,
582
650
  checkAutoStampsReservedFields: () => checkAutoStampsReservedFields,
583
651
  checkReservedFieldName: () => checkReservedFieldName,
@@ -590,6 +658,8 @@ __export(cloudflare_do_exports, {
590
658
  handleRequest: () => handleRequest,
591
659
  inferFieldType: () => inferFieldType,
592
660
  loadSchemaFromTomlString: () => loadSchemaFromTomlString,
661
+ parseRecordRow: () => parseRecordRow,
662
+ projectedJsonAlias: () => projectedJsonAlias,
593
663
  registerFunctionDefault: () => registerFunctionDefault,
594
664
  schemaToToml: () => schemaToToml,
595
665
  syncInferredMeta: () => syncInferredMeta,
@@ -1605,6 +1675,45 @@ var DurableObjectEngine = class extends JsonSchemaEngine {
1605
1675
  init_queryTypes();
1606
1676
  init_CursorManager();
1607
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
1608
1717
  init_patterns();
1609
1718
  var SYSTEM_FIELDS = /* @__PURE__ */ new Set(["id", "type"]);
1610
1719
  var JsonQueryTranslator = class {
@@ -1649,7 +1758,8 @@ var JsonQueryTranslator = class {
1649
1758
  const whereClause = this.translateFilter(filter);
1650
1759
  const orderClause = CursorManager.buildOrderClause(
1651
1760
  options?.sort,
1652
- (field) => this.getFieldSql(field)
1761
+ (field) => this.getFieldSql(field),
1762
+ options?.direction || 1
1653
1763
  );
1654
1764
  const limitClause = this.buildLimitClause(options);
1655
1765
  const paginationClause = this.buildPaginationClause(
@@ -2100,7 +2210,7 @@ var JsonQueryTranslator = class {
2100
2210
  for (const field of includeFields) {
2101
2211
  if (field === "id" || field === "type") continue;
2102
2212
  selectParts.push(
2103
- `json_extract(_data, '$.${field}') AS ${quoteIdentifier(field)}`
2213
+ `_data -> '$.${field}' AS ${quoteIdentifier(projectedJsonAlias(field))}`
2104
2214
  );
2105
2215
  }
2106
2216
  return selectParts.join(", ");
@@ -2287,6 +2397,13 @@ var JsonQueryTranslator = class {
2287
2397
  init_CursorManager();
2288
2398
  init_sql();
2289
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
+ };
2290
2407
  function checkReservedFieldName(fieldName) {
2291
2408
  if (fieldName === "id") return null;
2292
2409
  if (fieldName === "type") {
@@ -2331,6 +2448,35 @@ function applyAutoStamps(data, autoStamps, isNew) {
2331
2448
  }
2332
2449
  }
2333
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
+ }
2334
2480
  function createDatabaseDO(config = {}) {
2335
2481
  const hooks = config.hooks;
2336
2482
  return class DocumentDO {
@@ -2488,6 +2634,16 @@ function createDatabaseDO(config = {}) {
2488
2634
  hasMore,
2489
2635
  isFirstPage
2490
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
+ }
2491
2647
  const response = {
2492
2648
  data,
2493
2649
  hasMore,
@@ -2590,6 +2746,7 @@ function createDatabaseDO(config = {}) {
2590
2746
  }
2591
2747
  const isNew = !this._engine.recordExists(modelName, id);
2592
2748
  applyAutoStamps(data, autoStamps, isNew);
2749
+ let hookAppliedNames;
2593
2750
  if (hooks?.beforeSave) {
2594
2751
  const ctx = {
2595
2752
  modelName,
@@ -2605,6 +2762,7 @@ function createDatabaseDO(config = {}) {
2605
2762
  if (!result.allow) {
2606
2763
  return this._errorResponse(result.reason || "Save denied", 403);
2607
2764
  }
2765
+ hookAppliedNames = result.serverAppliedFieldNames;
2608
2766
  }
2609
2767
  const existingForUnique = this._engine.execSqlSync(
2610
2768
  "SELECT _data FROM records WHERE _id = ? AND _type = ?",
@@ -2634,6 +2792,11 @@ function createDatabaseDO(config = {}) {
2634
2792
  }
2635
2793
  this._engine.trackModelFields(modelName, data);
2636
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;
2637
2800
  return Response.json(response);
2638
2801
  }
2639
2802
  /** @internal — Partial update: merge provided fields into existing record */
@@ -2663,6 +2826,7 @@ function createDatabaseDO(config = {}) {
2663
2826
  );
2664
2827
  }
2665
2828
  applyAutoStamps(data, autoStamps, false);
2829
+ let hookAppliedNames;
2666
2830
  if (hooks?.beforeSave) {
2667
2831
  const ctx = {
2668
2832
  modelName,
@@ -2678,6 +2842,7 @@ function createDatabaseDO(config = {}) {
2678
2842
  if (!result.allow) {
2679
2843
  return this._errorResponse(result.reason || "Save denied", 403);
2680
2844
  }
2845
+ hookAppliedNames = result.serverAppliedFieldNames;
2681
2846
  }
2682
2847
  const existing = this._engine.execSqlSync(
2683
2848
  "SELECT _data FROM records WHERE _id = ? AND _type = ?",
@@ -2701,6 +2866,11 @@ function createDatabaseDO(config = {}) {
2701
2866
  }
2702
2867
  this._engine.trackModelFields(modelName, data);
2703
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;
2704
2874
  return Response.json(response);
2705
2875
  }
2706
2876
  /** @internal */
@@ -2742,7 +2912,7 @@ function createDatabaseDO(config = {}) {
2742
2912
  /** @internal — Execute multiple save/patch/delete operations in a single transaction */
2743
2913
  async _handleBatch(request, docId) {
2744
2914
  const body = await request.json();
2745
- const { operations } = body;
2915
+ const { operations, atomic = false } = body;
2746
2916
  if (!operations || !Array.isArray(operations) || operations.length === 0) {
2747
2917
  return this._errorResponse("operations array is required and must not be empty", 400);
2748
2918
  }
@@ -2750,6 +2920,8 @@ function createDatabaseDO(config = {}) {
2750
2920
  const resolvedBatchIds = /* @__PURE__ */ new Map();
2751
2921
  const opErrors = /* @__PURE__ */ new Map();
2752
2922
  const batchCreatedIds = /* @__PURE__ */ new Set();
2923
+ const batchIsNew = /* @__PURE__ */ new Map();
2924
+ const batchAppliedNames = /* @__PURE__ */ new Map();
2753
2925
  for (let i = 0; i < operations.length; i++) {
2754
2926
  const op = operations[i];
2755
2927
  if (!op.modelName || !op.id && !(op.op === "save" && op.upsertOn) || !op.op) {
@@ -2823,6 +2995,7 @@ function createDatabaseDO(config = {}) {
2823
2995
  if (op.op === "save" && isNew && effectiveId) {
2824
2996
  batchCreatedIds.add(`${op.modelName}/${effectiveId}`);
2825
2997
  }
2998
+ batchIsNew.set(i, isNew);
2826
2999
  applyAutoStamps(op.data, op.autoStamps, isNew);
2827
3000
  if (hooks?.beforeSave) {
2828
3001
  const ctx = {
@@ -2838,6 +3011,8 @@ function createDatabaseDO(config = {}) {
2838
3011
  const result = await hooks.beforeSave(ctx);
2839
3012
  if (!result.allow) {
2840
3013
  hookDenials.set(i, result.reason || "Save denied");
3014
+ } else if (result.serverAppliedFieldNames) {
3015
+ batchAppliedNames.set(i, result.serverAppliedFieldNames);
2841
3016
  }
2842
3017
  }
2843
3018
  } else if (op.op === "delete") {
@@ -2886,205 +3061,263 @@ function createDatabaseDO(config = {}) {
2886
3061
  }
2887
3062
  const results = [];
2888
3063
  const fieldsToTrack = [];
2889
- this._engine.transactionSync(() => {
2890
- for (let i = 0; i < operations.length; i++) {
2891
- const op = operations[i];
2892
- if (hookDenials.has(i)) {
2893
- const effectiveId = resolvedBatchIds.get(i) || op.id || "";
2894
- results.push({
2895
- success: false,
2896
- id: effectiveId,
2897
- error: hookDenials.get(i)
2898
- });
2899
- continue;
2900
- }
2901
- if (opErrors.has(i)) {
2902
- results.push({
2903
- success: false,
2904
- id: op.id || "",
2905
- error: opErrors.get(i)
2906
- });
2907
- continue;
2908
- }
2909
- const conditionId = resolvedBatchIds.get(i) || op.id;
2910
- if (op.condition && !this._checkCondition(op.modelName, conditionId, op.condition)) {
2911
- results.push({
2912
- success: false,
2913
- id: conditionId,
2914
- error: `Condition not met for ${op.modelName}/${conditionId}`
2915
- });
2916
- continue;
2917
- }
2918
- if (op.op === "save") {
2919
- let opId = resolvedBatchIds.get(i) || op.id;
2920
- if (op.ifNotExists && this._engine.recordExists(op.modelName, opId)) {
2921
- results.push({
2922
- success: false,
2923
- id: opId,
2924
- 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"
2925
3085
  });
2926
3086
  continue;
2927
3087
  }
2928
- if (op.data) {
2929
- const existingForUnique = this._engine.execSqlSync(
2930
- "SELECT _data FROM records WHERE _id = ? AND _type = ?",
2931
- [opId, op.modelName]
2932
- );
2933
- let dataForUnique = op.data;
2934
- if (existingForUnique.length > 0) {
2935
- const existingData = JSON.parse(existingForUnique[0]._data);
2936
- dataForUnique = { ...existingData, ...op.data };
2937
- }
2938
- const violation = this._engine.checkUniqueConstraints(
2939
- op.modelName,
2940
- opId,
2941
- dataForUnique
2942
- );
2943
- if (violation) {
2944
- results.push({
2945
- 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, {
2946
3111
  id: opId,
2947
- error: violation
3112
+ error: `Record ${op.modelName}/${opId} already exists`,
3113
+ status: 409,
3114
+ code: "ALREADY_EXISTS"
2948
3115
  });
2949
3116
  continue;
2950
3117
  }
2951
- }
2952
- if (op.stringSets && Object.keys(op.stringSets).length > 0) {
2953
- const { id: _id, ...fieldsForJson } = op.data;
2954
- const jsonFields = { ...fieldsForJson, ...op.stringSets };
2955
- const dataJson = JSON.stringify(jsonFields);
2956
- this._engine.execSqlSync(
2957
- "INSERT INTO records (_id, _type, _data) VALUES (?, ?, ?) ON CONFLICT(_type, _id) DO UPDATE SET _data = json_patch(records._data, excluded._data)",
2958
- [opId, op.modelName, dataJson]
2959
- );
2960
- for (const [fieldName, values] of Object.entries(op.stringSets)) {
2961
- this._engine.execSqlSync(
2962
- "DELETE FROM stringset_index WHERE _type = ? AND _record_id = ? AND field = ?",
2963
- [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]
2964
3122
  );
2965
- for (const value of values) {
2966
- this._engine.execSqlSync(
2967
- "INSERT OR IGNORE INTO stringset_index (_record_id, _type, field, value) VALUES (?, ?, ?, ?)",
2968
- [opId, op.modelName, fieldName, value]
2969
- );
3123
+ let dataForUnique = op.data;
3124
+ if (existingForUnique.length > 0) {
3125
+ const existingData = JSON.parse(existingForUnique[0]._data);
3126
+ dataForUnique = { ...existingData, ...op.data };
2970
3127
  }
2971
- }
2972
- } else {
2973
- const { id: _id, ...fieldsForJson } = op.data;
2974
- const dataJson = JSON.stringify(fieldsForJson);
2975
- this._engine.execSqlSync(
2976
- "INSERT INTO records (_id, _type, _data) VALUES (?, ?, ?) ON CONFLICT(_type, _id) DO UPDATE SET _data = json_patch(records._data, excluded._data)",
2977
- [opId, op.modelName, dataJson]
2978
- );
2979
- }
2980
- if (op.data) fieldsToTrack.push({ modelName: op.modelName, data: op.data });
2981
- results.push({ success: true, id: opId });
2982
- } else if (op.op === "patch") {
2983
- if (!this._engine.recordExists(op.modelName, op.id)) {
2984
- results.push({
2985
- success: false,
2986
- id: op.id,
2987
- error: `Record ${op.modelName}/${op.id} not found`
2988
- });
2989
- continue;
2990
- }
2991
- if (op.data) {
2992
- const existing = this._engine.execSqlSync(
2993
- "SELECT _data FROM records WHERE _id = ? AND _type = ?",
2994
- [op.id, op.modelName]
2995
- );
2996
- if (existing.length > 0) {
2997
- const existingData = JSON.parse(existing[0]._data);
2998
- const mergedData = { ...existingData, ...op.data };
2999
3128
  const violation = this._engine.checkUniqueConstraints(
3000
3129
  op.modelName,
3001
- op.id,
3002
- mergedData
3130
+ opId,
3131
+ dataForUnique
3003
3132
  );
3004
3133
  if (violation) {
3005
- results.push({
3006
- success: false,
3007
- id: op.id,
3008
- error: violation
3134
+ pushFailure(i, {
3135
+ id: opId,
3136
+ error: violation,
3137
+ status: 409,
3138
+ code: "UNIQUE_VIOLATION"
3009
3139
  });
3010
3140
  continue;
3011
3141
  }
3012
3142
  }
3013
- }
3014
- const found = this._engine.patchRecordRaw(
3015
- op.modelName,
3016
- op.id,
3017
- op.data,
3018
- op.stringSets
3019
- );
3020
- if (!found) {
3021
- results.push({
3022
- success: false,
3023
- id: op.id,
3024
- error: `Record ${op.modelName}/${op.id} not found`
3025
- });
3026
- } 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
+ }
3027
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
+ );
3028
3300
  results.push({ success: true, id: op.id });
3029
3301
  }
3030
- } else if (op.op === "delete") {
3031
- this._engine.execSqlSync(
3032
- "DELETE FROM records WHERE _id = ? AND _type = ?",
3033
- [op.id, op.modelName]
3034
- );
3035
- this._engine.execSqlSync(
3036
- "DELETE FROM stringset_index WHERE _record_id = ? AND _type = ?",
3037
- [op.id, op.modelName]
3038
- );
3039
- results.push({ success: true, id: op.id });
3040
- } else if (op.op === "increment") {
3041
- const newValues = this._engine.incrementFieldsRaw(
3042
- op.modelName,
3043
- op.id,
3044
- op.fields
3045
- );
3046
- if (!newValues) {
3047
- results.push({
3048
- success: false,
3049
- id: op.id,
3050
- error: `Record ${op.modelName}/${op.id} not found`
3051
- });
3052
- } else {
3053
- results.push({ success: true, id: op.id, values: newValues });
3054
- }
3055
- } else if (op.op === "addToSet") {
3056
- if (!this._engine.recordExists(op.modelName, op.id)) {
3057
- results.push({
3058
- success: false,
3059
- id: op.id,
3060
- error: `Record ${op.modelName}/${op.id} not found`
3061
- });
3062
- continue;
3063
- }
3064
- this._engine.addToStringSetsRaw(
3065
- op.modelName,
3066
- op.id,
3067
- op.stringSets
3068
- );
3069
- results.push({ success: true, id: op.id });
3070
- } else if (op.op === "removeFromSet") {
3071
- if (!this._engine.recordExists(op.modelName, op.id)) {
3072
- results.push({
3073
- success: false,
3074
- id: op.id,
3075
- error: `Record ${op.modelName}/${op.id} not found`
3076
- });
3077
- continue;
3078
- }
3079
- this._engine.removeFromStringSetsRaw(
3080
- op.modelName,
3081
- op.id,
3082
- op.stringSets
3083
- );
3084
- results.push({ success: true, id: op.id });
3085
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 });
3086
3318
  }
3087
- });
3319
+ throw err;
3320
+ }
3088
3321
  for (const entry of fieldsToTrack) {
3089
3322
  this._engine.trackModelFields(entry.modelName, entry.data);
3090
3323
  }
@@ -3454,6 +3687,18 @@ function createDatabaseDO(config = {}) {
3454
3687
  }
3455
3688
  return facetResult;
3456
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
+ }
3457
3702
  const nestedResult = {};
3458
3703
  const aliasLookup = /* @__PURE__ */ new Map();
3459
3704
  for (const entry of aliasMap) {
@@ -3781,16 +4026,7 @@ function createDatabaseDO(config = {}) {
3781
4026
  }
3782
4027
  /** @internal */
3783
4028
  _parseRow(row) {
3784
- const { _id, _type, _data, ...rest } = row;
3785
- let parsed = {};
3786
- if (_data) {
3787
- try {
3788
- parsed = JSON.parse(_data);
3789
- } catch (e) {
3790
- console.warn("Failed to parse _data:", e);
3791
- }
3792
- }
3793
- return { id: _id, type: _type, ...parsed, ...rest };
4029
+ return parseRecordRow(row);
3794
4030
  }
3795
4031
  // ─── Include (Related Data Loading) ──────────────────────────────
3796
4032
  /** @internal — Validate an IncludeSpec, returning an error message or null */
@@ -3920,8 +4156,9 @@ function createDatabaseDO(config = {}) {
3920
4156
  spec.filter,
3921
4157
  { projection: spec.projection }
3922
4158
  );
4159
+ const relatedProjected = spec.projection ? related.map((r) => this._applyProjection(r, spec.projection)) : related;
3923
4160
  const lookupMap = /* @__PURE__ */ new Map();
3924
- for (const r of related) {
4161
+ for (const r of relatedProjected) {
3925
4162
  lookupMap.set(r.id, r);
3926
4163
  }
3927
4164
  for (const rec of records) {
@@ -3954,8 +4191,9 @@ function createDatabaseDO(config = {}) {
3954
4191
  spec.filter,
3955
4192
  { projection: spec.projection }
3956
4193
  );
4194
+ const targetsProjected = spec.projection ? targets.map((t) => this._applyProjection(t, spec.projection)) : targets;
3957
4195
  const targetLookup = /* @__PURE__ */ new Map();
3958
- for (const t of targets) {
4196
+ for (const t of targetsProjected) {
3959
4197
  targetLookup.set(t.id, t);
3960
4198
  }
3961
4199
  for (const rec of records) {
@@ -4099,6 +4337,14 @@ function createDatabaseDO(config = {}) {
4099
4337
  return allResults;
4100
4338
  }
4101
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.
4102
4348
  _applyProjection(record, projection) {
4103
4349
  const fields = Object.entries(projection);
4104
4350
  if (fields.length === 0) return record;
@@ -4117,7 +4363,7 @@ function createDatabaseDO(config = {}) {
4117
4363
  } else {
4118
4364
  const result = { ...record };
4119
4365
  for (const [f, v] of fields) {
4120
- if (v === 0) delete result[f];
4366
+ if (v === 0 && f !== "id" && f !== "type") delete result[f];
4121
4367
  }
4122
4368
  return result;
4123
4369
  }
@@ -4395,6 +4641,7 @@ var import_smol_toml = require("smol-toml");
4395
4641
  // src/models/schema.ts
4396
4642
  init_BaseModel();
4397
4643
  init_ModelRegistry();
4644
+ init_uniqueIndex();
4398
4645
  function defineModelSchema(input) {
4399
4646
  const { name, fields } = input;
4400
4647
  const options = {
@@ -4437,7 +4684,7 @@ function resolveUniqueConstraints(modelName, fields, customConstraints) {
4437
4684
  for (const [fieldName, options] of fields.entries()) {
4438
4685
  if (options.unique) {
4439
4686
  resolved.push({
4440
- name: `${modelName}_${fieldName}_unique`,
4687
+ name: fieldUniqueConstraintName(modelName, fieldName),
4441
4688
  fields: [fieldName]
4442
4689
  });
4443
4690
  }