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.
@@ -229,21 +229,36 @@ var init_CursorManager = __esm({
229
229
  return fields;
230
230
  }
231
231
  /**
232
- * Build ORDER BY clause from sort specification
232
+ * Build ORDER BY clause from sort specification.
233
+ *
234
+ * `direction` is the pagination direction (1 = forward, -1 = backward).
235
+ * For backward pagination each field's effective ORDER BY direction is
236
+ * reversed so the cursor's flipped inequality (built by
237
+ * `buildPaginationConditions`) returns the page adjacent to the cursor,
238
+ * not the globally smallest/largest window. Without this,
239
+ * `field < cursor ... ORDER BY field ASC LIMIT n` returns the n smallest
240
+ * rows overall instead of the n rows just below the cursor.
241
+ *
242
+ * The returned `directions` array carries the *declared* per-field sort
243
+ * directions (unaffected by `direction`) — `buildPaginationConditions`
244
+ * needs the declared directions to pick each field's comparison operator.
233
245
  */
234
- static buildOrderClause(sort, fieldFormatter = (field) => field) {
246
+ static buildOrderClause(sort, fieldFormatter = (field) => field, direction = 1) {
235
247
  const fields = this.extractSortFields(sort);
236
248
  const clauses = [];
237
249
  const directions = [];
250
+ const sqlDir = (declared) => {
251
+ const effective = direction === -1 ? declared === 1 ? -1 : 1 : declared;
252
+ return effective === 1 ? "ASC" : "DESC";
253
+ };
238
254
  if (sort && Object.keys(sort).length > 0) {
239
255
  for (const [field, dir] of Object.entries(sort)) {
240
- const sqlDirection = dir === 1 ? "ASC" : "DESC";
241
- clauses.push(`${fieldFormatter(field)} ${sqlDirection}`);
256
+ clauses.push(`${fieldFormatter(field)} ${sqlDir(dir)}`);
242
257
  directions.push(dir);
243
258
  }
244
259
  }
245
260
  if (!sort || !sort.hasOwnProperty("id")) {
246
- clauses.push(`${fieldFormatter("id")} ASC`);
261
+ clauses.push(`${fieldFormatter("id")} ${sqlDir(1)}`);
247
262
  directions.push(1);
248
263
  } else if (sort && sort.hasOwnProperty("id")) {
249
264
  directions.push(sort["id"]);
@@ -266,18 +281,27 @@ var init_CursorManager = __esm({
266
281
  /**
267
282
  * Generate next and previous cursors from result set
268
283
  */
269
- static generateResultCursors(results, sortFields, _requestedDirection, hasMore, isFirstPage = false) {
284
+ static generateResultCursors(results, sortFields, requestedDirection, hasMore, isFirstPage = false) {
270
285
  if (results.length === 0) {
271
286
  return {};
272
287
  }
273
288
  const cursors = {};
274
289
  if (hasMore && results.length > 0) {
275
290
  const lastResult = results[results.length - 1];
276
- cursors.nextCursor = this.generateCursor(lastResult, sortFields, 1);
291
+ cursors.nextCursor = this.generateCursor(
292
+ lastResult,
293
+ sortFields,
294
+ requestedDirection
295
+ );
277
296
  }
278
297
  if (results.length > 0 && !isFirstPage) {
279
298
  const firstResult = results[0];
280
- cursors.prevCursor = this.generateCursor(firstResult, sortFields, -1);
299
+ const opposite = requestedDirection === 1 ? -1 : 1;
300
+ cursors.prevCursor = this.generateCursor(
301
+ firstResult,
302
+ sortFields,
303
+ opposite
304
+ );
281
305
  }
282
306
  return cursors;
283
307
  }
@@ -367,8 +391,33 @@ var init_DocumentQueryTranslator = __esm({
367
391
  }
368
392
  });
369
393
 
370
- // src/models/metaSync.ts
394
+ // src/models/writeLifecycle.ts
371
395
  import * as Y3 from "yjs";
396
+ import { ulid as ulid2 } from "ulid";
397
+ function registerFunctionDefaultGenerator(name, fn) {
398
+ FUNCTION_DEFAULT_GENERATORS.set(name, fn);
399
+ }
400
+ var KNOWN_FIELD_TYPE_MAP, KNOWN_FIELD_TYPES, FUNCTION_DEFAULT_GENERATORS, UNKNOWN_FUNCTION_DEFAULT;
401
+ var init_writeLifecycle = __esm({
402
+ "src/models/writeLifecycle.ts"() {
403
+ "use strict";
404
+ KNOWN_FIELD_TYPE_MAP = {
405
+ string: true,
406
+ number: true,
407
+ boolean: true,
408
+ date: true,
409
+ id: true,
410
+ stringset: true
411
+ };
412
+ KNOWN_FIELD_TYPES = new Set(Object.keys(KNOWN_FIELD_TYPE_MAP));
413
+ FUNCTION_DEFAULT_GENERATORS = /* @__PURE__ */ new Map();
414
+ registerFunctionDefaultGenerator("generate_ulid", () => ulid2());
415
+ UNKNOWN_FUNCTION_DEFAULT = "$unknown_function";
416
+ }
417
+ });
418
+
419
+ // src/models/metaSync.ts
420
+ import * as Y4 from "yjs";
372
421
  function inferFieldType(value) {
373
422
  if (isStringSetYMap(value)) return "stringset";
374
423
  switch (typeof value) {
@@ -389,7 +438,10 @@ function encodeDefault(value) {
389
438
  if (value === void 0 || value === null) return void 0;
390
439
  if (typeof value === "function") {
391
440
  const name = KNOWN_FUNCTION_DEFAULTS.get(value);
392
- return name ? `$${name}` : "$unknown_function";
441
+ return name ? `$${name}` : UNKNOWN_FUNCTION_DEFAULT;
442
+ }
443
+ if (typeof value === "string" && value.startsWith("$")) {
444
+ return `$${value}`;
393
445
  }
394
446
  return value;
395
447
  }
@@ -415,7 +467,7 @@ function syncModelMeta(yDoc, modelName, schema) {
415
467
  if (compoundConstraints.length > 0) {
416
468
  let constraints = meta.get("_constraints");
417
469
  if (!constraints) {
418
- constraints = new Y3.Map();
470
+ constraints = new Y4.Map();
419
471
  meta.set("_constraints", constraints);
420
472
  }
421
473
  for (const constraint of compoundConstraints) {
@@ -426,7 +478,7 @@ function syncModelMeta(yDoc, modelName, schema) {
426
478
  if (relationships && Object.keys(relationships).length > 0) {
427
479
  let rels = meta.get("_relationships");
428
480
  if (!rels) {
429
- rels = new Y3.Map();
481
+ rels = new Y4.Map();
430
482
  meta.set("_relationships", rels);
431
483
  }
432
484
  for (const [relName, relConfig] of Object.entries(relationships)) {
@@ -438,7 +490,7 @@ function syncModelMeta(yDoc, modelName, schema) {
438
490
  function syncFieldMeta(metaMap, fieldName, fieldOpts) {
439
491
  let fieldMeta = metaMap.get(fieldName);
440
492
  if (!fieldMeta) {
441
- fieldMeta = new Y3.Map();
493
+ fieldMeta = new Y4.Map();
442
494
  metaMap.set(fieldName, fieldMeta);
443
495
  }
444
496
  setIfChanged(fieldMeta, "type", fieldOpts.type);
@@ -455,7 +507,7 @@ function syncFieldMeta(metaMap, fieldName, fieldOpts) {
455
507
  function syncConstraintMeta(constraintsMap, constraint) {
456
508
  let cMeta = constraintsMap.get(constraint.name);
457
509
  if (!cMeta) {
458
- cMeta = new Y3.Map();
510
+ cMeta = new Y4.Map();
459
511
  constraintsMap.set(constraint.name, cMeta);
460
512
  }
461
513
  setIfChanged(cMeta, "type", "unique");
@@ -465,7 +517,7 @@ function syncConstraintMeta(constraintsMap, constraint) {
465
517
  function syncRelationshipMeta(relsMap, relName, relConfig) {
466
518
  let relMeta = relsMap.get(relName);
467
519
  if (!relMeta) {
468
- relMeta = new Y3.Map();
520
+ relMeta = new Y4.Map();
469
521
  relsMap.set(relName, relMeta);
470
522
  }
471
523
  for (const [key, value] of Object.entries(relConfig)) {
@@ -480,7 +532,7 @@ function syncInferredMeta(yDoc, modelName, recordData) {
480
532
  if (fieldName.startsWith("_")) continue;
481
533
  let fieldMeta = meta.get(fieldName);
482
534
  if (!fieldMeta) {
483
- fieldMeta = new Y3.Map();
535
+ fieldMeta = new Y4.Map();
484
536
  meta.set(fieldName, fieldMeta);
485
537
  }
486
538
  if (!fieldMeta.has("type")) {
@@ -501,16 +553,28 @@ var init_metaSync = __esm({
501
553
  "src/models/metaSync.ts"() {
502
554
  "use strict";
503
555
  init_yjsValues();
556
+ init_writeLifecycle();
504
557
  KNOWN_FUNCTION_DEFAULTS = /* @__PURE__ */ new WeakMap();
505
558
  _syncedCache = /* @__PURE__ */ new WeakMap();
506
559
  }
507
560
  });
508
561
 
562
+ // src/utils/uniqueIndex.ts
563
+ import * as Y5 from "yjs";
564
+ function fieldUniqueConstraintName(modelName, fieldName) {
565
+ return `${modelName}_${fieldName}_unique`;
566
+ }
567
+ var init_uniqueIndex = __esm({
568
+ "src/utils/uniqueIndex.ts"() {
569
+ "use strict";
570
+ }
571
+ });
572
+
509
573
  // src/models/BaseModel.ts
510
- import * as Y4 from "yjs";
511
- import { ulid as ulid2 } from "ulid";
574
+ import * as Y6 from "yjs";
575
+ import { ulid as ulid3 } from "ulid";
512
576
  function generateULID() {
513
- return ulid2();
577
+ return ulid3();
514
578
  }
515
579
  var init_BaseModel = __esm({
516
580
  "src/models/BaseModel.ts"() {
@@ -521,7 +585,9 @@ var init_BaseModel = __esm({
521
585
  init_CursorManager();
522
586
  init_sql();
523
587
  init_metaSync();
588
+ init_writeLifecycle();
524
589
  init_yjsValues();
590
+ init_uniqueIndex();
525
591
  registerFunctionDefault(generateULID, "generate_ulid");
526
592
  }
527
593
  });
@@ -1551,6 +1617,45 @@ var DurableObjectEngine = class extends JsonSchemaEngine {
1551
1617
  init_queryTypes();
1552
1618
  init_CursorManager();
1553
1619
  init_sql();
1620
+
1621
+ // src/query/recordRow.ts
1622
+ var PROJECTED_JSON_ALIAS_PREFIX = "_json_";
1623
+ function projectedJsonAlias(fieldName) {
1624
+ return `${PROJECTED_JSON_ALIAS_PREFIX}${fieldName}`;
1625
+ }
1626
+ function parseRecordRow(row) {
1627
+ const { _id, _type, _data, ...rest } = row;
1628
+ let parsed = {};
1629
+ if (_data) {
1630
+ try {
1631
+ parsed = JSON.parse(_data);
1632
+ } catch (e) {
1633
+ console.warn("Failed to parse _data:", e);
1634
+ }
1635
+ }
1636
+ return { id: _id, type: _type, ...parsed, ...decodeProjectedColumns(rest) };
1637
+ }
1638
+ function decodeProjectedColumns(columns) {
1639
+ const decoded = {};
1640
+ for (const [column, value] of Object.entries(columns)) {
1641
+ if (!column.startsWith(PROJECTED_JSON_ALIAS_PREFIX)) {
1642
+ decoded[column] = value;
1643
+ continue;
1644
+ }
1645
+ decoded[column.slice(PROJECTED_JSON_ALIAS_PREFIX.length)] = parseJsonColumn(value);
1646
+ }
1647
+ return decoded;
1648
+ }
1649
+ function parseJsonColumn(value) {
1650
+ if (typeof value !== "string") return value;
1651
+ try {
1652
+ return JSON.parse(value);
1653
+ } catch {
1654
+ return value;
1655
+ }
1656
+ }
1657
+
1658
+ // src/query/JsonQueryTranslator.ts
1554
1659
  init_patterns();
1555
1660
  var SYSTEM_FIELDS = /* @__PURE__ */ new Set(["id", "type"]);
1556
1661
  var JsonQueryTranslator = class {
@@ -1595,7 +1700,8 @@ var JsonQueryTranslator = class {
1595
1700
  const whereClause = this.translateFilter(filter);
1596
1701
  const orderClause = CursorManager.buildOrderClause(
1597
1702
  options?.sort,
1598
- (field) => this.getFieldSql(field)
1703
+ (field) => this.getFieldSql(field),
1704
+ options?.direction || 1
1599
1705
  );
1600
1706
  const limitClause = this.buildLimitClause(options);
1601
1707
  const paginationClause = this.buildPaginationClause(
@@ -2046,7 +2152,7 @@ var JsonQueryTranslator = class {
2046
2152
  for (const field of includeFields) {
2047
2153
  if (field === "id" || field === "type") continue;
2048
2154
  selectParts.push(
2049
- `json_extract(_data, '$.${field}') AS ${quoteIdentifier(field)}`
2155
+ `_data -> '$.${field}' AS ${quoteIdentifier(projectedJsonAlias(field))}`
2050
2156
  );
2051
2157
  }
2052
2158
  return selectParts.join(", ");
@@ -2233,6 +2339,13 @@ var JsonQueryTranslator = class {
2233
2339
  init_CursorManager();
2234
2340
  init_sql();
2235
2341
  import { ulid } from "ulid";
2342
+ var AtomicBatchAbort = class extends Error {
2343
+ constructor(failedIndex) {
2344
+ super(`atomic batch aborted at operation ${failedIndex}`);
2345
+ this.failedIndex = failedIndex;
2346
+ this.name = "AtomicBatchAbort";
2347
+ }
2348
+ };
2236
2349
  function checkReservedFieldName(fieldName) {
2237
2350
  if (fieldName === "id") return null;
2238
2351
  if (fieldName === "type") {
@@ -2277,6 +2390,35 @@ function applyAutoStamps(data, autoStamps, isNew) {
2277
2390
  }
2278
2391
  }
2279
2392
  }
2393
+ function collectAppliedStamps(data, autoStamps, isNew) {
2394
+ if (!autoStamps || !data) return void 0;
2395
+ const stamps = {};
2396
+ const take = (fields) => {
2397
+ if (!fields) return;
2398
+ for (const field of fields) {
2399
+ const value = data[field];
2400
+ if (typeof value === "number" && Number.isFinite(value)) {
2401
+ stamps[field] = value;
2402
+ }
2403
+ }
2404
+ };
2405
+ if (isNew) take(autoStamps.create);
2406
+ take(autoStamps.update);
2407
+ return Object.keys(stamps).length > 0 ? stamps : void 0;
2408
+ }
2409
+ function collectAppliedValues(data, fieldNames) {
2410
+ if (!data || !fieldNames || fieldNames.length === 0) return void 0;
2411
+ const out = {};
2412
+ for (const name of fieldNames) {
2413
+ const value = data[name];
2414
+ if (value !== void 0) out[name] = value;
2415
+ }
2416
+ return Object.keys(out).length > 0 ? out : void 0;
2417
+ }
2418
+ function mergeAppliedFields(stamps, applied) {
2419
+ if (!stamps && !applied) return void 0;
2420
+ return { ...stamps ?? {}, ...applied ?? {} };
2421
+ }
2280
2422
  function createDatabaseDO(config = {}) {
2281
2423
  const hooks = config.hooks;
2282
2424
  return class DocumentDO {
@@ -2434,6 +2576,16 @@ function createDatabaseDO(config = {}) {
2434
2576
  hasMore,
2435
2577
  isFirstPage
2436
2578
  );
2579
+ if (options?.projection && Object.keys(options.projection).length > 0) {
2580
+ const isExclusion = Object.values(options.projection).some(
2581
+ (v) => v === 0
2582
+ );
2583
+ if (isExclusion) {
2584
+ data = data.map(
2585
+ (rec) => this._applyProjection(rec, options.projection)
2586
+ );
2587
+ }
2588
+ }
2437
2589
  const response = {
2438
2590
  data,
2439
2591
  hasMore,
@@ -2536,6 +2688,7 @@ function createDatabaseDO(config = {}) {
2536
2688
  }
2537
2689
  const isNew = !this._engine.recordExists(modelName, id);
2538
2690
  applyAutoStamps(data, autoStamps, isNew);
2691
+ let hookAppliedNames;
2539
2692
  if (hooks?.beforeSave) {
2540
2693
  const ctx = {
2541
2694
  modelName,
@@ -2551,6 +2704,7 @@ function createDatabaseDO(config = {}) {
2551
2704
  if (!result.allow) {
2552
2705
  return this._errorResponse(result.reason || "Save denied", 403);
2553
2706
  }
2707
+ hookAppliedNames = result.serverAppliedFieldNames;
2554
2708
  }
2555
2709
  const existingForUnique = this._engine.execSqlSync(
2556
2710
  "SELECT _data FROM records WHERE _id = ? AND _type = ?",
@@ -2580,6 +2734,11 @@ function createDatabaseDO(config = {}) {
2580
2734
  }
2581
2735
  this._engine.trackModelFields(modelName, data);
2582
2736
  const response = { success: true, id };
2737
+ const appliedFields = mergeAppliedFields(
2738
+ collectAppliedStamps(data, autoStamps, isNew),
2739
+ collectAppliedValues(data, hookAppliedNames)
2740
+ );
2741
+ if (appliedFields) response.appliedFields = appliedFields;
2583
2742
  return Response.json(response);
2584
2743
  }
2585
2744
  /** @internal — Partial update: merge provided fields into existing record */
@@ -2609,6 +2768,7 @@ function createDatabaseDO(config = {}) {
2609
2768
  );
2610
2769
  }
2611
2770
  applyAutoStamps(data, autoStamps, false);
2771
+ let hookAppliedNames;
2612
2772
  if (hooks?.beforeSave) {
2613
2773
  const ctx = {
2614
2774
  modelName,
@@ -2624,6 +2784,7 @@ function createDatabaseDO(config = {}) {
2624
2784
  if (!result.allow) {
2625
2785
  return this._errorResponse(result.reason || "Save denied", 403);
2626
2786
  }
2787
+ hookAppliedNames = result.serverAppliedFieldNames;
2627
2788
  }
2628
2789
  const existing = this._engine.execSqlSync(
2629
2790
  "SELECT _data FROM records WHERE _id = ? AND _type = ?",
@@ -2647,6 +2808,11 @@ function createDatabaseDO(config = {}) {
2647
2808
  }
2648
2809
  this._engine.trackModelFields(modelName, data);
2649
2810
  const response = { success: true, id };
2811
+ const appliedFields = mergeAppliedFields(
2812
+ collectAppliedStamps(data, autoStamps, false),
2813
+ collectAppliedValues(data, hookAppliedNames)
2814
+ );
2815
+ if (appliedFields) response.appliedFields = appliedFields;
2650
2816
  return Response.json(response);
2651
2817
  }
2652
2818
  /** @internal */
@@ -2688,7 +2854,7 @@ function createDatabaseDO(config = {}) {
2688
2854
  /** @internal — Execute multiple save/patch/delete operations in a single transaction */
2689
2855
  async _handleBatch(request, docId) {
2690
2856
  const body = await request.json();
2691
- const { operations } = body;
2857
+ const { operations, atomic = false } = body;
2692
2858
  if (!operations || !Array.isArray(operations) || operations.length === 0) {
2693
2859
  return this._errorResponse("operations array is required and must not be empty", 400);
2694
2860
  }
@@ -2696,6 +2862,8 @@ function createDatabaseDO(config = {}) {
2696
2862
  const resolvedBatchIds = /* @__PURE__ */ new Map();
2697
2863
  const opErrors = /* @__PURE__ */ new Map();
2698
2864
  const batchCreatedIds = /* @__PURE__ */ new Set();
2865
+ const batchIsNew = /* @__PURE__ */ new Map();
2866
+ const batchAppliedNames = /* @__PURE__ */ new Map();
2699
2867
  for (let i = 0; i < operations.length; i++) {
2700
2868
  const op = operations[i];
2701
2869
  if (!op.modelName || !op.id && !(op.op === "save" && op.upsertOn) || !op.op) {
@@ -2769,6 +2937,7 @@ function createDatabaseDO(config = {}) {
2769
2937
  if (op.op === "save" && isNew && effectiveId) {
2770
2938
  batchCreatedIds.add(`${op.modelName}/${effectiveId}`);
2771
2939
  }
2940
+ batchIsNew.set(i, isNew);
2772
2941
  applyAutoStamps(op.data, op.autoStamps, isNew);
2773
2942
  if (hooks?.beforeSave) {
2774
2943
  const ctx = {
@@ -2784,6 +2953,8 @@ function createDatabaseDO(config = {}) {
2784
2953
  const result = await hooks.beforeSave(ctx);
2785
2954
  if (!result.allow) {
2786
2955
  hookDenials.set(i, result.reason || "Save denied");
2956
+ } else if (result.serverAppliedFieldNames) {
2957
+ batchAppliedNames.set(i, result.serverAppliedFieldNames);
2787
2958
  }
2788
2959
  }
2789
2960
  } else if (op.op === "delete") {
@@ -2832,205 +3003,263 @@ function createDatabaseDO(config = {}) {
2832
3003
  }
2833
3004
  const results = [];
2834
3005
  const fieldsToTrack = [];
2835
- this._engine.transactionSync(() => {
2836
- for (let i = 0; i < operations.length; i++) {
2837
- const op = operations[i];
2838
- if (hookDenials.has(i)) {
2839
- const effectiveId = resolvedBatchIds.get(i) || op.id || "";
2840
- results.push({
2841
- success: false,
2842
- id: effectiveId,
2843
- error: hookDenials.get(i)
2844
- });
2845
- continue;
2846
- }
2847
- if (opErrors.has(i)) {
2848
- results.push({
2849
- success: false,
2850
- id: op.id || "",
2851
- error: opErrors.get(i)
2852
- });
2853
- continue;
2854
- }
2855
- const conditionId = resolvedBatchIds.get(i) || op.id;
2856
- if (op.condition && !this._checkCondition(op.modelName, conditionId, op.condition)) {
2857
- results.push({
2858
- success: false,
2859
- id: conditionId,
2860
- error: `Condition not met for ${op.modelName}/${conditionId}`
2861
- });
2862
- continue;
2863
- }
2864
- if (op.op === "save") {
2865
- let opId = resolvedBatchIds.get(i) || op.id;
2866
- if (op.ifNotExists && this._engine.recordExists(op.modelName, opId)) {
2867
- results.push({
2868
- success: false,
2869
- id: opId,
2870
- error: `Record ${op.modelName}/${opId} already exists`
3006
+ const pushFailure = (index, failure) => {
3007
+ results.push({
3008
+ success: false,
3009
+ id: failure.id,
3010
+ error: failure.error,
3011
+ status: failure.status,
3012
+ code: failure.code
3013
+ });
3014
+ if (atomic) throw new AtomicBatchAbort(index);
3015
+ };
3016
+ try {
3017
+ this._engine.transactionSync(() => {
3018
+ for (let i = 0; i < operations.length; i++) {
3019
+ const op = operations[i];
3020
+ if (hookDenials.has(i)) {
3021
+ const effectiveId = resolvedBatchIds.get(i) || op.id || "";
3022
+ pushFailure(i, {
3023
+ id: effectiveId,
3024
+ error: hookDenials.get(i),
3025
+ status: 403,
3026
+ code: "HOOK_DENIED"
2871
3027
  });
2872
3028
  continue;
2873
3029
  }
2874
- if (op.data) {
2875
- const existingForUnique = this._engine.execSqlSync(
2876
- "SELECT _data FROM records WHERE _id = ? AND _type = ?",
2877
- [opId, op.modelName]
2878
- );
2879
- let dataForUnique = op.data;
2880
- if (existingForUnique.length > 0) {
2881
- const existingData = JSON.parse(existingForUnique[0]._data);
2882
- dataForUnique = { ...existingData, ...op.data };
2883
- }
2884
- const violation = this._engine.checkUniqueConstraints(
2885
- op.modelName,
2886
- opId,
2887
- dataForUnique
2888
- );
2889
- if (violation) {
2890
- results.push({
2891
- success: false,
3030
+ if (opErrors.has(i)) {
3031
+ pushFailure(i, {
3032
+ id: op.id || "",
3033
+ error: opErrors.get(i),
3034
+ status: 400,
3035
+ code: "VALIDATION_ERROR"
3036
+ });
3037
+ continue;
3038
+ }
3039
+ const conditionId = resolvedBatchIds.get(i) || op.id;
3040
+ if (op.condition && !this._checkCondition(op.modelName, conditionId, op.condition)) {
3041
+ pushFailure(i, {
3042
+ id: conditionId,
3043
+ error: `Condition not met for ${op.modelName}/${conditionId}`,
3044
+ status: 409,
3045
+ code: "CONDITION_NOT_MET"
3046
+ });
3047
+ continue;
3048
+ }
3049
+ if (op.op === "save") {
3050
+ let opId = resolvedBatchIds.get(i) || op.id;
3051
+ if (op.ifNotExists && this._engine.recordExists(op.modelName, opId)) {
3052
+ pushFailure(i, {
2892
3053
  id: opId,
2893
- error: violation
3054
+ error: `Record ${op.modelName}/${opId} already exists`,
3055
+ status: 409,
3056
+ code: "ALREADY_EXISTS"
2894
3057
  });
2895
3058
  continue;
2896
3059
  }
2897
- }
2898
- if (op.stringSets && Object.keys(op.stringSets).length > 0) {
2899
- const { id: _id, ...fieldsForJson } = op.data;
2900
- const jsonFields = { ...fieldsForJson, ...op.stringSets };
2901
- const dataJson = JSON.stringify(jsonFields);
2902
- this._engine.execSqlSync(
2903
- "INSERT INTO records (_id, _type, _data) VALUES (?, ?, ?) ON CONFLICT(_type, _id) DO UPDATE SET _data = json_patch(records._data, excluded._data)",
2904
- [opId, op.modelName, dataJson]
2905
- );
2906
- for (const [fieldName, values] of Object.entries(op.stringSets)) {
2907
- this._engine.execSqlSync(
2908
- "DELETE FROM stringset_index WHERE _type = ? AND _record_id = ? AND field = ?",
2909
- [op.modelName, opId, fieldName]
3060
+ if (op.data) {
3061
+ const existingForUnique = this._engine.execSqlSync(
3062
+ "SELECT _data FROM records WHERE _id = ? AND _type = ?",
3063
+ [opId, op.modelName]
2910
3064
  );
2911
- for (const value of values) {
2912
- this._engine.execSqlSync(
2913
- "INSERT OR IGNORE INTO stringset_index (_record_id, _type, field, value) VALUES (?, ?, ?, ?)",
2914
- [opId, op.modelName, fieldName, value]
2915
- );
3065
+ let dataForUnique = op.data;
3066
+ if (existingForUnique.length > 0) {
3067
+ const existingData = JSON.parse(existingForUnique[0]._data);
3068
+ dataForUnique = { ...existingData, ...op.data };
2916
3069
  }
2917
- }
2918
- } else {
2919
- const { id: _id, ...fieldsForJson } = op.data;
2920
- const dataJson = JSON.stringify(fieldsForJson);
2921
- this._engine.execSqlSync(
2922
- "INSERT INTO records (_id, _type, _data) VALUES (?, ?, ?) ON CONFLICT(_type, _id) DO UPDATE SET _data = json_patch(records._data, excluded._data)",
2923
- [opId, op.modelName, dataJson]
2924
- );
2925
- }
2926
- if (op.data) fieldsToTrack.push({ modelName: op.modelName, data: op.data });
2927
- results.push({ success: true, id: opId });
2928
- } else if (op.op === "patch") {
2929
- if (!this._engine.recordExists(op.modelName, op.id)) {
2930
- results.push({
2931
- success: false,
2932
- id: op.id,
2933
- error: `Record ${op.modelName}/${op.id} not found`
2934
- });
2935
- continue;
2936
- }
2937
- if (op.data) {
2938
- const existing = this._engine.execSqlSync(
2939
- "SELECT _data FROM records WHERE _id = ? AND _type = ?",
2940
- [op.id, op.modelName]
2941
- );
2942
- if (existing.length > 0) {
2943
- const existingData = JSON.parse(existing[0]._data);
2944
- const mergedData = { ...existingData, ...op.data };
2945
3070
  const violation = this._engine.checkUniqueConstraints(
2946
3071
  op.modelName,
2947
- op.id,
2948
- mergedData
3072
+ opId,
3073
+ dataForUnique
2949
3074
  );
2950
3075
  if (violation) {
2951
- results.push({
2952
- success: false,
2953
- id: op.id,
2954
- error: violation
3076
+ pushFailure(i, {
3077
+ id: opId,
3078
+ error: violation,
3079
+ status: 409,
3080
+ code: "UNIQUE_VIOLATION"
2955
3081
  });
2956
3082
  continue;
2957
3083
  }
2958
3084
  }
2959
- }
2960
- const found = this._engine.patchRecordRaw(
2961
- op.modelName,
2962
- op.id,
2963
- op.data,
2964
- op.stringSets
2965
- );
2966
- if (!found) {
2967
- results.push({
2968
- success: false,
2969
- id: op.id,
2970
- error: `Record ${op.modelName}/${op.id} not found`
2971
- });
2972
- } else {
3085
+ if (op.stringSets && Object.keys(op.stringSets).length > 0) {
3086
+ const { id: _id, ...fieldsForJson } = op.data;
3087
+ const jsonFields = { ...fieldsForJson, ...op.stringSets };
3088
+ const dataJson = JSON.stringify(jsonFields);
3089
+ this._engine.execSqlSync(
3090
+ "INSERT INTO records (_id, _type, _data) VALUES (?, ?, ?) ON CONFLICT(_type, _id) DO UPDATE SET _data = json_patch(records._data, excluded._data)",
3091
+ [opId, op.modelName, dataJson]
3092
+ );
3093
+ for (const [fieldName, values] of Object.entries(op.stringSets)) {
3094
+ this._engine.execSqlSync(
3095
+ "DELETE FROM stringset_index WHERE _type = ? AND _record_id = ? AND field = ?",
3096
+ [op.modelName, opId, fieldName]
3097
+ );
3098
+ for (const value of values) {
3099
+ this._engine.execSqlSync(
3100
+ "INSERT OR IGNORE INTO stringset_index (_record_id, _type, field, value) VALUES (?, ?, ?, ?)",
3101
+ [opId, op.modelName, fieldName, value]
3102
+ );
3103
+ }
3104
+ }
3105
+ } else {
3106
+ const { id: _id, ...fieldsForJson } = op.data;
3107
+ const dataJson = JSON.stringify(fieldsForJson);
3108
+ this._engine.execSqlSync(
3109
+ "INSERT INTO records (_id, _type, _data) VALUES (?, ?, ?) ON CONFLICT(_type, _id) DO UPDATE SET _data = json_patch(records._data, excluded._data)",
3110
+ [opId, op.modelName, dataJson]
3111
+ );
3112
+ }
2973
3113
  if (op.data) fieldsToTrack.push({ modelName: op.modelName, data: op.data });
3114
+ const saveResult = { success: true, id: opId };
3115
+ const saveApplied = mergeAppliedFields(
3116
+ collectAppliedStamps(
3117
+ op.data,
3118
+ op.autoStamps,
3119
+ batchIsNew.get(i) ?? false
3120
+ ),
3121
+ collectAppliedValues(op.data, batchAppliedNames.get(i))
3122
+ );
3123
+ if (saveApplied) saveResult.appliedFields = saveApplied;
3124
+ results.push(saveResult);
3125
+ } else if (op.op === "patch") {
3126
+ if (!this._engine.recordExists(op.modelName, op.id)) {
3127
+ pushFailure(i, {
3128
+ id: op.id,
3129
+ error: `Record ${op.modelName}/${op.id} not found`,
3130
+ status: 404,
3131
+ code: "NOT_FOUND"
3132
+ });
3133
+ continue;
3134
+ }
3135
+ if (op.data) {
3136
+ const existing = this._engine.execSqlSync(
3137
+ "SELECT _data FROM records WHERE _id = ? AND _type = ?",
3138
+ [op.id, op.modelName]
3139
+ );
3140
+ if (existing.length > 0) {
3141
+ const existingData = JSON.parse(existing[0]._data);
3142
+ const mergedData = { ...existingData, ...op.data };
3143
+ const violation = this._engine.checkUniqueConstraints(
3144
+ op.modelName,
3145
+ op.id,
3146
+ mergedData
3147
+ );
3148
+ if (violation) {
3149
+ pushFailure(i, {
3150
+ id: op.id,
3151
+ error: violation,
3152
+ status: 409,
3153
+ code: "UNIQUE_VIOLATION"
3154
+ });
3155
+ continue;
3156
+ }
3157
+ }
3158
+ }
3159
+ const found = this._engine.patchRecordRaw(
3160
+ op.modelName,
3161
+ op.id,
3162
+ op.data,
3163
+ op.stringSets
3164
+ );
3165
+ if (!found) {
3166
+ pushFailure(i, {
3167
+ id: op.id,
3168
+ error: `Record ${op.modelName}/${op.id} not found`,
3169
+ status: 404,
3170
+ code: "NOT_FOUND"
3171
+ });
3172
+ } else {
3173
+ if (op.data) fieldsToTrack.push({ modelName: op.modelName, data: op.data });
3174
+ const patchResult = {
3175
+ success: true,
3176
+ id: op.id
3177
+ };
3178
+ const patchApplied = mergeAppliedFields(
3179
+ collectAppliedStamps(op.data, op.autoStamps, false),
3180
+ collectAppliedValues(op.data, batchAppliedNames.get(i))
3181
+ );
3182
+ if (patchApplied) patchResult.appliedFields = patchApplied;
3183
+ results.push(patchResult);
3184
+ }
3185
+ } else if (op.op === "delete") {
3186
+ this._engine.execSqlSync(
3187
+ "DELETE FROM records WHERE _id = ? AND _type = ?",
3188
+ [op.id, op.modelName]
3189
+ );
3190
+ this._engine.execSqlSync(
3191
+ "DELETE FROM stringset_index WHERE _record_id = ? AND _type = ?",
3192
+ [op.id, op.modelName]
3193
+ );
3194
+ results.push({ success: true, id: op.id });
3195
+ } else if (op.op === "increment") {
3196
+ const newValues = this._engine.incrementFieldsRaw(
3197
+ op.modelName,
3198
+ op.id,
3199
+ op.fields
3200
+ );
3201
+ if (!newValues) {
3202
+ pushFailure(i, {
3203
+ id: op.id,
3204
+ error: `Record ${op.modelName}/${op.id} not found`,
3205
+ status: 404,
3206
+ code: "NOT_FOUND"
3207
+ });
3208
+ } else {
3209
+ results.push({ success: true, id: op.id, values: newValues });
3210
+ }
3211
+ } else if (op.op === "addToSet") {
3212
+ if (!this._engine.recordExists(op.modelName, op.id)) {
3213
+ pushFailure(i, {
3214
+ id: op.id,
3215
+ error: `Record ${op.modelName}/${op.id} not found`,
3216
+ status: 404,
3217
+ code: "NOT_FOUND"
3218
+ });
3219
+ continue;
3220
+ }
3221
+ this._engine.addToStringSetsRaw(
3222
+ op.modelName,
3223
+ op.id,
3224
+ op.stringSets
3225
+ );
3226
+ results.push({ success: true, id: op.id });
3227
+ } else if (op.op === "removeFromSet") {
3228
+ if (!this._engine.recordExists(op.modelName, op.id)) {
3229
+ pushFailure(i, {
3230
+ id: op.id,
3231
+ error: `Record ${op.modelName}/${op.id} not found`,
3232
+ status: 404,
3233
+ code: "NOT_FOUND"
3234
+ });
3235
+ continue;
3236
+ }
3237
+ this._engine.removeFromStringSetsRaw(
3238
+ op.modelName,
3239
+ op.id,
3240
+ op.stringSets
3241
+ );
2974
3242
  results.push({ success: true, id: op.id });
2975
3243
  }
2976
- } else if (op.op === "delete") {
2977
- this._engine.execSqlSync(
2978
- "DELETE FROM records WHERE _id = ? AND _type = ?",
2979
- [op.id, op.modelName]
2980
- );
2981
- this._engine.execSqlSync(
2982
- "DELETE FROM stringset_index WHERE _record_id = ? AND _type = ?",
2983
- [op.id, op.modelName]
2984
- );
2985
- results.push({ success: true, id: op.id });
2986
- } else if (op.op === "increment") {
2987
- const newValues = this._engine.incrementFieldsRaw(
2988
- op.modelName,
2989
- op.id,
2990
- op.fields
2991
- );
2992
- if (!newValues) {
2993
- results.push({
2994
- success: false,
2995
- id: op.id,
2996
- error: `Record ${op.modelName}/${op.id} not found`
2997
- });
2998
- } else {
2999
- results.push({ success: true, id: op.id, values: newValues });
3000
- }
3001
- } else if (op.op === "addToSet") {
3002
- if (!this._engine.recordExists(op.modelName, op.id)) {
3003
- results.push({
3004
- success: false,
3005
- id: op.id,
3006
- error: `Record ${op.modelName}/${op.id} not found`
3007
- });
3008
- continue;
3009
- }
3010
- this._engine.addToStringSetsRaw(
3011
- op.modelName,
3012
- op.id,
3013
- op.stringSets
3014
- );
3015
- results.push({ success: true, id: op.id });
3016
- } else if (op.op === "removeFromSet") {
3017
- if (!this._engine.recordExists(op.modelName, op.id)) {
3018
- results.push({
3019
- success: false,
3020
- id: op.id,
3021
- error: `Record ${op.modelName}/${op.id} not found`
3022
- });
3023
- continue;
3024
- }
3025
- this._engine.removeFromStringSetsRaw(
3026
- op.modelName,
3027
- op.id,
3028
- op.stringSets
3029
- );
3030
- results.push({ success: true, id: op.id });
3031
3244
  }
3245
+ });
3246
+ } catch (err) {
3247
+ if (err instanceof AtomicBatchAbort) {
3248
+ const failed = results[err.failedIndex];
3249
+ const failedOp = operations[err.failedIndex];
3250
+ console.warn(
3251
+ `[js-bao batch] atomic rollback: operation ${err.failedIndex} (${failedOp?.op}) failed \u2014 ${failed?.error ?? "unknown error"}`
3252
+ );
3253
+ const errorBody = {
3254
+ error: `Operation ${err.failedIndex} (${failedOp?.op}) failed: ${failed?.error ?? "unknown error"}`,
3255
+ failedIndex: err.failedIndex,
3256
+ op: failedOp?.op,
3257
+ code: failed?.code
3258
+ };
3259
+ return Response.json(errorBody, { status: failed?.status ?? 409 });
3032
3260
  }
3033
- });
3261
+ throw err;
3262
+ }
3034
3263
  for (const entry of fieldsToTrack) {
3035
3264
  this._engine.trackModelFields(entry.modelName, entry.data);
3036
3265
  }
@@ -3400,6 +3629,18 @@ function createDatabaseDO(config = {}) {
3400
3629
  }
3401
3630
  return facetResult;
3402
3631
  }
3632
+ if (aggOptions.groupBy.length === 0) {
3633
+ const row = results[0];
3634
+ const flat = {};
3635
+ for (const op of aggOptions.operations) {
3636
+ if (op.type === "count") {
3637
+ flat.count = row.count;
3638
+ } else {
3639
+ flat[`${op.type}_${op.field}`] = row[`${op.type}_${op.field}`];
3640
+ }
3641
+ }
3642
+ return flat;
3643
+ }
3403
3644
  const nestedResult = {};
3404
3645
  const aliasLookup = /* @__PURE__ */ new Map();
3405
3646
  for (const entry of aliasMap) {
@@ -3727,16 +3968,7 @@ function createDatabaseDO(config = {}) {
3727
3968
  }
3728
3969
  /** @internal */
3729
3970
  _parseRow(row) {
3730
- const { _id, _type, _data, ...rest } = row;
3731
- let parsed = {};
3732
- if (_data) {
3733
- try {
3734
- parsed = JSON.parse(_data);
3735
- } catch (e) {
3736
- console.warn("Failed to parse _data:", e);
3737
- }
3738
- }
3739
- return { id: _id, type: _type, ...parsed, ...rest };
3971
+ return parseRecordRow(row);
3740
3972
  }
3741
3973
  // ─── Include (Related Data Loading) ──────────────────────────────
3742
3974
  /** @internal — Validate an IncludeSpec, returning an error message or null */
@@ -3866,8 +4098,9 @@ function createDatabaseDO(config = {}) {
3866
4098
  spec.filter,
3867
4099
  { projection: spec.projection }
3868
4100
  );
4101
+ const relatedProjected = spec.projection ? related.map((r) => this._applyProjection(r, spec.projection)) : related;
3869
4102
  const lookupMap = /* @__PURE__ */ new Map();
3870
- for (const r of related) {
4103
+ for (const r of relatedProjected) {
3871
4104
  lookupMap.set(r.id, r);
3872
4105
  }
3873
4106
  for (const rec of records) {
@@ -3900,8 +4133,9 @@ function createDatabaseDO(config = {}) {
3900
4133
  spec.filter,
3901
4134
  { projection: spec.projection }
3902
4135
  );
4136
+ const targetsProjected = spec.projection ? targets.map((t) => this._applyProjection(t, spec.projection)) : targets;
3903
4137
  const targetLookup = /* @__PURE__ */ new Map();
3904
- for (const t of targets) {
4138
+ for (const t of targetsProjected) {
3905
4139
  targetLookup.set(t.id, t);
3906
4140
  }
3907
4141
  for (const rec of records) {
@@ -4045,6 +4279,14 @@ function createDatabaseDO(config = {}) {
4045
4279
  return allResults;
4046
4280
  }
4047
4281
  /** @internal — Apply projection to a single record */
4282
+ // Apply a projection to a single record. Assumes the projection has
4283
+ // already been validated upstream (JsonQueryTranslator.validateProjection /
4284
+ // buildSelectClause reject mixed inclusion+exclusion): `isInclusion` treats
4285
+ // "any value === 1" as inclusion mode, so an unvalidated mixed projection
4286
+ // would silently ignore the excludes. `id` and `type` are system-identity
4287
+ // fields and are always kept in BOTH modes (a record without its `id` is
4288
+ // unusable downstream). `_meta` is NOT forced — an explicit `{ _meta: 0 }`
4289
+ // is honored.
4048
4290
  _applyProjection(record, projection) {
4049
4291
  const fields = Object.entries(projection);
4050
4292
  if (fields.length === 0) return record;
@@ -4063,7 +4305,7 @@ function createDatabaseDO(config = {}) {
4063
4305
  } else {
4064
4306
  const result = { ...record };
4065
4307
  for (const [f, v] of fields) {
4066
- if (v === 0) delete result[f];
4308
+ if (v === 0 && f !== "id" && f !== "type") delete result[f];
4067
4309
  }
4068
4310
  return result;
4069
4311
  }
@@ -4341,6 +4583,7 @@ import { parse as parseToml } from "smol-toml";
4341
4583
  // src/models/schema.ts
4342
4584
  init_BaseModel();
4343
4585
  init_ModelRegistry();
4586
+ init_uniqueIndex();
4344
4587
  function defineModelSchema(input) {
4345
4588
  const { name, fields } = input;
4346
4589
  const options = {
@@ -4383,7 +4626,7 @@ function resolveUniqueConstraints(modelName, fields, customConstraints) {
4383
4626
  for (const [fieldName, options] of fields.entries()) {
4384
4627
  if (options.unique) {
4385
4628
  resolved.push({
4386
- name: `${modelName}_${fieldName}_unique`,
4629
+ name: fieldUniqueConstraintName(modelName, fieldName),
4387
4630
  fields: [fieldName]
4388
4631
  });
4389
4632
  }
@@ -4635,6 +4878,7 @@ export {
4635
4878
  DurableObjectEngine,
4636
4879
  JsonQueryTranslator,
4637
4880
  JsonSchemaDDL,
4881
+ PROJECTED_JSON_ALIAS_PREFIX,
4638
4882
  buildSafeAlias,
4639
4883
  checkAutoStampsReservedFields,
4640
4884
  checkReservedFieldName,
@@ -4647,6 +4891,8 @@ export {
4647
4891
  handleRequest,
4648
4892
  inferFieldType,
4649
4893
  loadSchemaFromTomlString,
4894
+ parseRecordRow,
4895
+ projectedJsonAlias,
4650
4896
  registerFunctionDefault,
4651
4897
  schemaToToml,
4652
4898
  syncInferredMeta,