@lunora/do 1.0.0-alpha.4 → 1.0.0-alpha.41

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.
Files changed (52) hide show
  1. package/LICENSE.md +6 -0
  2. package/__assets__/package-og.svg +1 -1
  3. package/dist/index.d.mts +5236 -3449
  4. package/dist/index.d.ts +5236 -3449
  5. package/dist/index.mjs +34 -25
  6. package/dist/packem_shared/{ADMIN_FUNCTIONS-Dzdqq5J2.mjs → ADMIN_FUNCTIONS-CnZkbXH_.mjs} +69 -8
  7. package/dist/packem_shared/{AGGREGATE_SQL_FUNCTION-CFk6adSu.mjs → AGGREGATE_SQL_FUNCTION-CQsu2Xga.mjs} +5 -3
  8. package/dist/packem_shared/{CDC_LOG_TABLE-Ctdmxmrv.mjs → CDC_LOG_TABLE-uwOJxJJZ.mjs} +8 -4
  9. package/dist/packem_shared/{ConflictError-C0STs6bU.mjs → ConflictError-CLoq37xH.mjs} +4 -5
  10. package/dist/packem_shared/{CountRlsUnsupportedError-28ZvvwKS.mjs → CountRlsUnsupportedError-BGxj0pgS.mjs} +4 -4
  11. package/dist/packem_shared/{DATA_MIGRATION_STATE_TABLE-PTtTiQ7U.mjs → DATA_MIGRATION_STATE_TABLE-CYwBpyTr.mjs} +11 -3
  12. package/dist/packem_shared/{DEFAULT_MAX_RELATION_KEYS-DyVYReuT.mjs → DEFAULT_MAX_RELATION_KEYS-BEan1CRD.mjs} +58 -7
  13. package/dist/packem_shared/GEO_DEFAULT_PRECISION-BWnsNmpP.mjs +114 -0
  14. package/dist/packem_shared/{MAX_SQL_ROWS-dDcFE1YZ.mjs → MAX_SQL_ROWS-iFAA8FbD.mjs} +35 -3
  15. package/dist/packem_shared/NotFoundError-C70b9hLw.mjs +9 -0
  16. package/dist/packem_shared/{NotUniqueError-B6tzuDmM.mjs → NotUniqueError-DbtlWwcG.mjs} +400 -114
  17. package/dist/packem_shared/{rank-CrkEIpF4.mjs → RANK_TIEBREAK-CXhdcA1o.mjs} +2 -13
  18. package/dist/packem_shared/{RLS_UNWRAP_SYMBOL-u3UlnCH5.mjs → RLS_UNWRAP_SYMBOL-DTvHvRzY.mjs} +22 -9
  19. package/dist/packem_shared/ROOT_DO_SIZE_WARN_BYTES-B4Cpm7Qv.mjs +7653 -0
  20. package/dist/packem_shared/{ReactiveCache-ByVzgH3d.mjs → ReactiveCache-DnSvbjil.mjs} +4 -30
  21. package/dist/packem_shared/{SESSION_DO_TTL_DEFAULT-ilPZsVwu.mjs → SESSION_DO_TTL_DEFAULT-BnSKgVO4.mjs} +16 -27
  22. package/dist/packem_shared/{SHARD_REGISTRY_DO_NAME-BsAbi5Mn.mjs → SHARD_REGISTRY_DO_NAME-D99roc-r.mjs} +12 -14
  23. package/dist/packem_shared/{applyOnDelete-CMif2RKw.mjs → applyOnDelete-BXSq3S70.mjs} +23 -12
  24. package/dist/packem_shared/{buildSeekWhere-lVsNXSLy.mjs → applySelect-WQY8m62C.mjs} +21 -2
  25. package/dist/packem_shared/{armRestore-BJk53Ro8.mjs → armRestore-4Px61hHS.mjs} +4 -10
  26. package/dist/packem_shared/{backfillAggregateIndexes-BbVPvciS.mjs → backfillAggregateIndexes-BGTwynwh.mjs} +4 -3
  27. package/dist/packem_shared/{compileWhereSql-CXrhFA3G.mjs → compileWhereSql-DE6yfRcQ.mjs} +5 -3
  28. package/dist/packem_shared/constant-time-equal-BVRWZgES.mjs +12 -0
  29. package/dist/packem_shared/context-telemetry-CDpyil58.mjs +223 -0
  30. package/dist/packem_shared/createMetrics-C4I2Yanj.mjs +1 -0
  31. package/dist/packem_shared/{createSystemReader-8CzSZP9V.mjs → createSystemReader-D12eNH13.mjs} +4 -2
  32. package/dist/packem_shared/ctx-db-idempotency-BdcNpvY4.mjs +108 -0
  33. package/dist/packem_shared/ctx-db-shapes-DTeFiHYS.mjs +53 -0
  34. package/dist/packem_shared/diffExternalSource-CovHfdyo.mjs +44 -0
  35. package/dist/packem_shared/{do-sql-BCHCWtrD.mjs → do-sql-CGAgiQUz.mjs} +2 -1
  36. package/dist/packem_shared/{exportShardRows-DZEhUeyI.mjs → exportShardRows-Dy3oFZ26.mjs} +4 -3
  37. package/dist/packem_shared/isSoftDeleted-CFJmhjFP.mjs +217 -0
  38. package/dist/packem_shared/json-response-BdbtpOhm.mjs +3 -0
  39. package/dist/packem_shared/materializeExternalRows-BtEGs1Fv.mjs +45 -0
  40. package/dist/packem_shared/{runShardMigrations-PabobOjF.mjs → runShardMigrations-BVax6rYu.mjs} +21 -4
  41. package/dist/packem_shared/selectExpiredIds-C5W29Upb.mjs +18 -0
  42. package/dist/packem_shared/serialize-sql-BlRUoiQe.mjs +14 -0
  43. package/dist/packem_shared/{serveRelationFanout-CFBKWJ8Q.mjs → serveRelationFanout-k7HTh3CC.mjs} +4 -7
  44. package/dist/packem_shared/stableStringify-mC40mZts.mjs +40 -0
  45. package/dist/packem_shared/stableWireKey-DKuXO7T5.mjs +6 -0
  46. package/dist/packem_shared/subscriptionListDeltas-CT76bYny.mjs +126 -0
  47. package/dist/packem_shared/wire-codec-CzQc1pvf.mjs +225 -0
  48. package/package.json +5 -3
  49. package/dist/packem_shared/NotFoundError-CMuMZt81.mjs +0 -10
  50. package/dist/packem_shared/RANK_TIEBREAK-C6blLR5K.mjs +0 -1
  51. package/dist/packem_shared/ROOT_DO_SIZE_WARN_BYTES-3lZ2yigq.mjs +0 -4003
  52. package/dist/packem_shared/ctx-db-idempotency-DkC9rP91.mjs +0 -35
@@ -1,27 +1,31 @@
1
+ import { LunoraError } from '@lunora/errors';
1
2
  import { sql } from 'drizzle-orm';
2
- import { matchesStaticWhere, aggregateSqlFunction, normalizeCountArgument, throwingScheduler } from './AGGREGATE_SQL_FUNCTION-CFk6adSu.mjs';
3
+ import { matchesStaticWhere, aggregateSqlFunction, normalizeCountArgument, throwingScheduler } from './AGGREGATE_SQL_FUNCTION-CQsu2Xga.mjs';
3
4
  import { encodeAggregateKey, foldAggregateTally, aggregateTableName, coerceAggregateNumber, readAggregateValue } from './aggregateTableName-CxNqY1Sl.mjs';
4
- import { mergeWhere, CountRlsUnsupportedError, selectIndexForGroupBy, selectIndexForCount, selectIndexForAggregate } from './CountRlsUnsupportedError-28ZvvwKS.mjs';
5
- import { appendCdcChange } from './CDC_LOG_TABLE-Ctdmxmrv.mjs';
6
- export { CDC_LOG_TABLE, applyCdcChanges, bumpCdcEpoch, minCdcSeq, readCdcChanges, readCdcCursor, readCdcEpoch, trimCdcChanges } from './CDC_LOG_TABLE-Ctdmxmrv.mjs';
5
+ import { mergeWhere, CountRlsUnsupportedError, selectIndexForGroupBy, selectIndexForCount, selectIndexForAggregate } from './CountRlsUnsupportedError-BGxj0pgS.mjs';
6
+ import { appendCdcChange } from './CDC_LOG_TABLE-uwOJxJJZ.mjs';
7
+ export { CDC_LOG_TABLE, applyCdcChanges, bumpCdcEpoch, minCdcSeq, readCdcChanges, readCdcCursor, readCdcEpoch, trimCdcChanges } from './CDC_LOG_TABLE-uwOJxJJZ.mjs';
7
8
  import { r as runDrizzle } from './do-exec-5eQy5cEi.mjs';
8
- import { i as isFtsAvailable, D as DOC_COLUMN$1, r as rowToDocument, A as AGG_KEY, a as AGG_VALUE, b as AGG_COUNT, d as aggUpsertSql, j as jsonPathSql, q as quoteIdentifier, t as tableColumns, e as qualifiedJsonPathSql } from './do-sql-BCHCWtrD.mjs';
9
+ import { i as isFtsAvailable, D as DOC_COLUMN$1, r as rowToDocument, A as AGG_KEY, a as AGG_VALUE, b as AGG_COUNT, g as geoTableName, d as aggUpsertSql, j as jsonPathSql, q as quoteIdentifier, t as tableColumns, e as qualifiedJsonPathSql } from './do-sql-CGAgiQUz.mjs';
9
10
  import { param } from './renderSql-D6eUcn2N.mjs';
10
- import { s as sortColumnName, m as matchesRankStaticWhere, e as encodePartitionKey, b as serializeSqlValue, r as rankTableName, a as resolveRankPartition, R as RANK_TIEBREAK } from './rank-CrkEIpF4.mjs';
11
+ import { encodeGeohash, GEO_DEFAULT_PRECISION, coveringGeohashes, boundingBoxGeohashes, pointInBoundingBox, haversineMeters } from './GEO_DEFAULT_PRECISION-BWnsNmpP.mjs';
12
+ import { sortColumnName, matchesRankStaticWhere, encodePartitionKey, rankTableName, resolveRankPartition, RANK_TIEBREAK } from './RANK_TIEBREAK-CXhdcA1o.mjs';
11
13
  import { stringifySearchText, ftsTableName, tokenizeSearch, buildFtsMatch, scoreDocument } from './buildFtsMatch-BLEMawrp.mjs';
14
+ import { s as serializeSqlValue } from './serialize-sql-BlRUoiQe.mjs';
12
15
  import { SCAN_DEP } from './SCAN_DEP-DLJF8dsj.mjs';
13
- import { decodeCursor, normalizeOrderKeys, buildSeekWhere, encodeCursor, buildSeekBeforeWhere } from './buildSeekWhere-lVsNXSLy.mjs';
14
- import NotFoundError from './NotFoundError-CMuMZt81.mjs';
15
- import { assertFlatPredicate, resolveRelationPredicates } from './DEFAULT_MAX_RELATION_KEYS-DyVYReuT.mjs';
16
- import { runRowValidators, resolveWith, applyOnDelete } from './applyOnDelete-CMif2RKw.mjs';
17
- import { guardWriter } from './RLS_UNWRAP_SYMBOL-u3UlnCH5.mjs';
18
- import { createSystemReader } from './createSystemReader-8CzSZP9V.mjs';
19
- import { ConflictError } from './ConflictError-C0STs6bU.mjs';
16
+ import { decodeCursor, normalizeOrderKeys, buildSeekWhere, applySelect, encodeCursor, softDeleteScope, buildSeekBeforeWhere } from './applySelect-WQY8m62C.mjs';
17
+ import NotFoundError from './NotFoundError-C70b9hLw.mjs';
18
+ import { assertFlatPredicate, resolveRelationPredicates } from './DEFAULT_MAX_RELATION_KEYS-BEan1CRD.mjs';
19
+ import { runRowValidators, resolveWith, applyOnDelete, fanOutScalarCounts } from './applyOnDelete-BXSq3S70.mjs';
20
+ import { guardWriter } from './RLS_UNWRAP_SYMBOL-DTvHvRzY.mjs';
21
+ import { createSystemReader } from './createSystemReader-D12eNH13.mjs';
22
+ import { ConflictError } from './ConflictError-CLoq37xH.mjs';
20
23
  import { runTriggers } from './hasTrigger-5N6_Fx0A.mjs';
21
- import { compileWhereSql } from './compileWhereSql-CXrhFA3G.mjs';
22
- export { backfillAggregateIndexes, backfillRankIndexes } from './backfillAggregateIndexes-BbVPvciS.mjs';
23
- export { I as IDEMPOTENCY_TABLE, r as readIdempotent, t as trimIdempotent, w as writeIdempotent } from './ctx-db-idempotency-DkC9rP91.mjs';
24
- export { runShardMigrations } from './runShardMigrations-PabobOjF.mjs';
24
+ import { compileWhereSql } from './compileWhereSql-DE6yfRcQ.mjs';
25
+ export { backfillAggregateIndexes, backfillRankIndexes } from './backfillAggregateIndexes-BGTwynwh.mjs';
26
+ export { C as CLIENT_WATERMARK_TABLE, G as GLOBAL_SHAPE_SNAPSHOT_TABLE, I as IDEMPOTENCY_TABLE, c as advanceClientWatermark, d as deleteGlobalShapeSnapshot, e as deleteGlobalShapeSnapshotsForConnection, m as migrateClientWatermark, b as migrateGlobalShapeSnapshot, r as readClientWatermark, f as readGlobalShapeSnapshot, g as readIdempotent, t as trimIdempotent, w as writeGlobalShapeSnapshot, h as writeIdempotent } from './ctx-db-idempotency-BdcNpvY4.mjs';
27
+ export { runShardMigrations } from './runShardMigrations-BVax6rYu.mjs';
28
+ export { a as selectShapeMemberIds, s as selectShapeRows } from './ctx-db-shapes-DTeFiHYS.mjs';
25
29
 
26
30
  const rankIndexFieldsUnchanged = (index, previous, next) => {
27
31
  const fields = [...index.partitionBy ?? [], ...index.sortBy.map((key) => key.field), ...index.where ? Object.keys(index.where) : []];
@@ -311,8 +315,28 @@ const createCompanionSync = (deps) => {
311
315
  }
312
316
  }
313
317
  };
318
+ const syncGeo = (tableName, id, document) => {
319
+ const indexes = schema.tables[tableName]?.geoIndexes;
320
+ if (!indexes || indexes.length === 0) {
321
+ return;
322
+ }
323
+ for (const index of indexes) {
324
+ const geoTable = geoTableName(tableName, index.name);
325
+ runDrizzle(sql$1, sql`DELETE FROM ${sql.identifier(geoTable)} WHERE ${sql.identifier("__id__")} = ${id}`);
326
+ const point = document?.[index.field];
327
+ if (point !== null && typeof point === "object" && typeof point.lat === "number" && typeof point.lng === "number") {
328
+ const { lat, lng } = point;
329
+ const hash = encodeGeohash({ lat, lng }, index.precision ?? GEO_DEFAULT_PRECISION);
330
+ runDrizzle(
331
+ sql$1,
332
+ sql`INSERT INTO ${sql.identifier(geoTable)} (${sql.identifier("__id__")}, ${sql.identifier("__geohash__")}, ${sql.identifier("__lat__")}, ${sql.identifier("__lng__")}) VALUES (${id}, ${hash}, ${lat}, ${lng})`
333
+ );
334
+ }
335
+ }
336
+ };
314
337
  const syncCompanionsForInsert = (tableName, id, document) => {
315
338
  syncSearch(tableName, id, document);
339
+ syncGeo(tableName, id, document);
316
340
  syncAggregates(tableName, void 0, document);
317
341
  syncRanks(tableName, id, void 0, document);
318
342
  invalidateCache(tableName, id);
@@ -326,6 +350,7 @@ const createCompanionSync = (deps) => {
326
350
  ensureRankBackfilledForTable,
327
351
  syncAggregates,
328
352
  syncCompanionsForInsert,
353
+ syncGeo,
329
354
  syncRanks,
330
355
  syncSearch
331
356
  };
@@ -420,11 +445,11 @@ const computeRankPage = (deps, tableName, indexName, rankPageOptions) => {
420
445
  const { assertRankPartitionLocal, ensureRankBackfilled, onRead, schema } = deps;
421
446
  const definition = schema.tables[tableName];
422
447
  if (!definition) {
423
- throw new Error(`unknown table: ${tableName}`);
448
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
424
449
  }
425
450
  const index = definition.rankIndexes?.find((i) => i.name === indexName);
426
451
  if (!index) {
427
- throw new Error(`unknown rankIndex "${indexName}" on table "${tableName}"`);
452
+ throw new LunoraError("INTERNAL", `unknown rankIndex "${indexName}" on table "${tableName}"`);
428
453
  }
429
454
  assertRankPartitionLocal(tableName, definition, index);
430
455
  onRead(tableName, SCAN_DEP);
@@ -472,7 +497,7 @@ const computeRankPage = (deps, tableName, indexName, rankPageOptions) => {
472
497
  const CLIENT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
473
498
  const assertValidClientId = (clientId) => {
474
499
  if (!CLIENT_ID_PATTERN.test(clientId)) {
475
- throw new Error(`invalid clientId ${JSON.stringify(clientId)}: a client-supplied row id must be a UUID`);
500
+ throw new LunoraError("INTERNAL", `invalid clientId ${JSON.stringify(clientId)}: a client-supplied row id must be a UUID`);
476
501
  }
477
502
  };
478
503
  const MAX_TRIGGER_DEPTH = 50;
@@ -480,11 +505,11 @@ const DEFAULT_BATCH_LIMIT = 500;
480
505
  const assertBatchLimit = (count, limit, op) => {
481
506
  const cap = limit ?? DEFAULT_BATCH_LIMIT;
482
507
  if (count > cap) {
483
- throw Object.assign(new Error(`${op}: batch of ${String(count)} exceeds the limit of ${String(cap)} (raise options.limit or chunk the call)`), {
484
- code: "BATCH_LIMIT_EXCEEDED",
485
- name: "LunoraError",
486
- status: 400
487
- });
508
+ throw new LunoraError(
509
+ "BATCH_LIMIT_EXCEEDED",
510
+ `${op}: batch of ${String(count)} exceeds the limit of ${String(cap)} (raise options.limit or chunk the call)`,
511
+ { status: 400 }
512
+ );
488
513
  }
489
514
  };
490
515
  const createRangeBuilder = (stage) => {
@@ -516,14 +541,17 @@ const createSearchBuilder = (search, tableName) => {
516
541
  const builder = {
517
542
  eq: (field, value) => {
518
543
  if (!search.definition.filterFields?.includes(field)) {
519
- throw new Error(`field "${field}" is not a filter field of search index "${search.indexName}" on table "${tableName}"`);
544
+ throw new LunoraError("INTERNAL", `field "${field}" is not a filter field of search index "${search.indexName}" on table "${tableName}"`);
520
545
  }
521
546
  search.filters.push({ field, value });
522
547
  return builder;
523
548
  },
524
549
  search: (field, query) => {
525
550
  if (field !== search.definition.field) {
526
- throw new Error(`search index "${search.indexName}" on table "${tableName}" indexes "${search.definition.field}", not "${field}"`);
551
+ throw new LunoraError(
552
+ "INTERNAL",
553
+ `search index "${search.indexName}" on table "${tableName}" indexes "${search.definition.field}", not "${field}"`
554
+ );
527
555
  }
528
556
  const stage = search;
529
557
  stage.field = field;
@@ -534,7 +562,7 @@ const createSearchBuilder = (search, tableName) => {
534
562
  };
535
563
  return builder;
536
564
  };
537
- const searchViaFts = (sql$1, tableName, search, limit) => {
565
+ const searchViaFts = (sql$1, tableName, search, limit, scopeCondition) => {
538
566
  const tokens = tokenizeSearch(search.query);
539
567
  if (tokens.length === 0) {
540
568
  return [];
@@ -544,6 +572,9 @@ const searchViaFts = (sql$1, tableName, search, limit) => {
544
572
  for (const filter of search.filters) {
545
573
  whereClauses.push(sql`${jsonPathSql(filter.field)} = ${serializeSqlValue(filter.value)}`);
546
574
  }
575
+ if (scopeCondition) {
576
+ whereClauses.push(scopeCondition);
577
+ }
547
578
  let query = sql`SELECT m.id, m._creationTime, m.${sql.identifier(DOC_COLUMN$1)} FROM ${sql.identifier(ftName)} f JOIN ${sql.identifier(tableName)} m ON m.id = f.${sql.identifier("__id__")} WHERE ${sql.join(whereClauses, sql` AND `)} ORDER BY f.rank, m._creationTime DESC`;
548
579
  if (typeof limit === "number") {
549
580
  query = sql`${query} LIMIT ${sql.raw(String(Math.max(0, Math.floor(limit))))}`;
@@ -558,7 +589,7 @@ const searchViaFts = (sql$1, tableName, search, limit) => {
558
589
  }
559
590
  return docs;
560
591
  };
561
- const searchViaScan = (sql$1, tableName, search, limit) => {
592
+ const searchViaScan = (sql$1, tableName, search, limit, scopeCondition) => {
562
593
  const tokens = tokenizeSearch(search.query);
563
594
  if (tokens.length === 0) {
564
595
  return [];
@@ -567,6 +598,9 @@ const searchViaScan = (sql$1, tableName, search, limit) => {
567
598
  for (const filter of search.filters) {
568
599
  whereClauses.push(sql`${jsonPathSql(filter.field)} = ${serializeSqlValue(filter.value)}`);
569
600
  }
601
+ if (scopeCondition) {
602
+ whereClauses.push(scopeCondition);
603
+ }
570
604
  let query = sql`SELECT id, _creationTime, ${sql.identifier(DOC_COLUMN$1)} FROM ${sql.identifier(tableName)}`;
571
605
  if (whereClauses.length > 0) {
572
606
  query = sql`${query} WHERE ${sql.join(whereClauses, sql` AND `)}`;
@@ -587,6 +621,123 @@ const searchViaScan = (sql$1, tableName, search, limit) => {
587
621
  const docs = scored.map((entry) => entry.doc);
588
622
  return typeof limit === "number" ? docs.slice(0, Math.max(0, Math.floor(limit))) : docs;
589
623
  };
624
+ const createGeoBuilder = (geo, tableName) => {
625
+ const staged = geo;
626
+ const builder = {
627
+ near: (point, radiusMeters) => {
628
+ if (staged.within) {
629
+ throw new LunoraError("INTERNAL", `geo index "${staged.indexName}" on table "${tableName}": call .near() or .within(), not both`);
630
+ }
631
+ staged.near = { point: { lat: point.lat, lng: point.lng }, radiusMeters };
632
+ return builder;
633
+ },
634
+ within: (box) => {
635
+ if (staged.near) {
636
+ throw new LunoraError("INTERNAL", `geo index "${staged.indexName}" on table "${tableName}": call .near() or .within(), not both`);
637
+ }
638
+ staged.within = { ne: { lat: box.ne.lat, lng: box.ne.lng }, sw: { lat: box.sw.lat, lng: box.sw.lng } };
639
+ return builder;
640
+ }
641
+ };
642
+ return builder;
643
+ };
644
+ const readGeoPoint = (document, field) => {
645
+ const value = document[field];
646
+ if (value === null || typeof value !== "object") {
647
+ return void 0;
648
+ }
649
+ const { lat, lng } = value;
650
+ return typeof lat === "number" && typeof lng === "number" ? { lat, lng } : void 0;
651
+ };
652
+ const scoreGeoRow = (record, geo) => {
653
+ const point = readGeoPoint(record, geo.definition.field);
654
+ if (!point) {
655
+ return void 0;
656
+ }
657
+ const creationTime = typeof record["_creationTime"] === "number" ? record["_creationTime"] : 0;
658
+ if (geo.near) {
659
+ const distance = haversineMeters(geo.near.point, point);
660
+ return distance <= geo.near.radiusMeters ? { creationTime, distance } : void 0;
661
+ }
662
+ return pointInBoundingBox(point, geo.within) ? { creationTime, distance: 0 } : void 0;
663
+ };
664
+ const runGeoFetch = (sql$1, tableName, geo, limit, scopeCondition) => {
665
+ if (!geo.near && !geo.within) {
666
+ throw new LunoraError("INTERNAL", `geo index "${geo.indexName}" on table "${tableName}": call .near(point, radius) or .within(box)`);
667
+ }
668
+ const prefixes = geo.near ? coveringGeohashes(geo.near.point, geo.near.radiusMeters) : boundingBoxGeohashes(geo.within);
669
+ const geoTable = geoTableName(tableName, geo.indexName);
670
+ const prefixClauses = prefixes.map(
671
+ (prefix) => sql`(g.${sql.identifier("__geohash__")} >= ${prefix} AND g.${sql.identifier("__geohash__")} < ${`${prefix}{`})`
672
+ );
673
+ const whereClauses = [sql`(${sql.join(prefixClauses, sql` OR `)})`];
674
+ if (scopeCondition) {
675
+ whereClauses.push(scopeCondition);
676
+ }
677
+ const query = sql`SELECT m.id, m._creationTime, m.${sql.identifier(DOC_COLUMN$1)} FROM ${sql.identifier(geoTable)} g JOIN ${sql.identifier(tableName)} m ON m.id = g.${sql.identifier("__id__")} WHERE ${sql.join(whereClauses, sql` AND `)}`;
678
+ const rows = runDrizzle(sql$1, query).toArray();
679
+ const scored = [];
680
+ for (const row of rows) {
681
+ const record = rowToDocument(row);
682
+ const score = record ? scoreGeoRow(record, geo) : void 0;
683
+ if (record && score) {
684
+ scored.push({ creationTime: score.creationTime, distance: score.distance, doc: record });
685
+ }
686
+ }
687
+ scored.sort((a, b) => a.distance - b.distance || b.creationTime - a.creationTime);
688
+ const docs = scored.map((entry) => entry.doc);
689
+ return typeof limit === "number" ? docs.slice(0, Math.max(0, Math.floor(limit))) : docs;
690
+ };
691
+ const runGeoTerminal = (sql, tableName, stage, scopeCondition, limit) => {
692
+ const { geo } = stage;
693
+ if (!geo) {
694
+ throw new LunoraError("INTERNAL", "runGeoTerminal called without a staged geo query");
695
+ }
696
+ const filtered = stage.inMemoryFilters.length > 0;
697
+ const docs = runGeoFetch(sql, tableName, geo, filtered ? void 0 : limit, scopeCondition);
698
+ if (!filtered) {
699
+ return docs;
700
+ }
701
+ const result = [];
702
+ for (const record of docs) {
703
+ if (stage.inMemoryFilters.every((predicate) => predicate(record))) {
704
+ result.push(record);
705
+ if (typeof limit === "number" && result.length >= limit) {
706
+ break;
707
+ }
708
+ }
709
+ }
710
+ return result;
711
+ };
712
+ const runPlainFetch = (sql$1, tableName, stage, scopeCondition, orderClause, limit) => {
713
+ const whereClauses = [];
714
+ for (const condition of stage.sqlConditions) {
715
+ whereClauses.push(sql`${jsonPathSql(condition.field)} ${sql.raw(condition.comparator)} ${serializeSqlValue(condition.value)}`);
716
+ }
717
+ if (scopeCondition) {
718
+ whereClauses.push(scopeCondition);
719
+ }
720
+ let query = sql`SELECT id, _creationTime, ${sql.identifier(DOC_COLUMN$1)} FROM ${sql.identifier(tableName)}`;
721
+ if (whereClauses.length > 0) {
722
+ query = sql`${query} WHERE ${sql.join(whereClauses, sql` AND `)}`;
723
+ }
724
+ query = sql`${query} ORDER BY ${orderClause}`;
725
+ if (typeof limit === "number" && stage.inMemoryFilters.length === 0) {
726
+ query = sql`${query} LIMIT ${sql.raw(String(Math.max(0, Math.floor(limit))))}`;
727
+ }
728
+ const rows = runDrizzle(sql$1, query).toArray();
729
+ const docs = [];
730
+ for (const row of rows) {
731
+ const record = rowToDocument(row);
732
+ if (record && stage.inMemoryFilters.every((predicate) => predicate(record))) {
733
+ docs.push(record);
734
+ if (typeof limit === "number" && docs.length >= limit) {
735
+ break;
736
+ }
737
+ }
738
+ }
739
+ return docs;
740
+ };
590
741
  const doWhereSqlStrategy = { fieldRef: jsonPathSql, serialize: serializeSqlValue };
591
742
  const makeRelationExistsSqlStrategy = (onRead) => {
592
743
  let aliasCounter = 0;
@@ -660,11 +811,12 @@ const scanDocs = (rows, filters, cap) => {
660
811
  }
661
812
  return docs;
662
813
  };
663
- const paginateStage = (sql$1, tableName, stage, options) => {
814
+ const paginateStage = (sql$1, tableName, stage, options, scopeCondition) => {
664
815
  const numberItems = Math.max(0, Math.floor(options.numItems));
665
816
  const orderKeys = paginateOrderKeys(stage);
666
817
  const bounded = typeof options.endCursor === "string";
667
- const whereCondition = compileWhereSql(paginateWhere(stage, orderKeys, options.cursor, options.endCursor), doWhereSqlStrategy);
818
+ const pageWhere = compileWhereSql(paginateWhere(stage, orderKeys, options.cursor, options.endCursor), doWhereSqlStrategy);
819
+ const whereCondition = scopeCondition && pageWhere ? sql`${pageWhere} AND ${scopeCondition}` : scopeCondition ?? pageWhere;
668
820
  let query = sql`SELECT id, _creationTime, ${sql.identifier(DOC_COLUMN$1)} FROM ${sql.identifier(tableName)}`;
669
821
  if (whereCondition) {
670
822
  query = sql`${query} WHERE ${whereCondition}`;
@@ -697,19 +849,16 @@ const paginateStage = (sql$1, tableName, stage, options) => {
697
849
  page
698
850
  };
699
851
  };
700
- class NotUniqueError extends Error {
701
- code = "NOT_UNIQUE";
702
- status = 400;
852
+ class NotUniqueError extends LunoraError {
703
853
  constructor(message = "unique() found more than one matching document") {
704
- super(message);
705
- this.name = "NotUniqueError";
854
+ super("NOT_UNIQUE", message, { name: "NotUniqueError" });
706
855
  }
707
856
  }
708
857
  const ID_WHITESPACE_PATTERN = /\s/u;
709
858
  const NUL_CHARACTER = String.fromCodePoint(0);
710
859
  const normalizeIdStructurally = (schema, tableName, id) => {
711
860
  if (!schema.tables[tableName]) {
712
- throw new Error(`unknown table: ${tableName}`);
861
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
713
862
  }
714
863
  if (typeof id !== "string" || id.length === 0 || ID_WHITESPACE_PATTERN.test(id) || id.includes(NUL_CHARACTER)) {
715
864
  return null;
@@ -719,8 +868,10 @@ const normalizeIdStructurally = (schema, tableName, id) => {
719
868
  const buildReader = (sql$1, schema, tableName, onIndexUse = () => void 0) => {
720
869
  const tableDefinition = schema.tables[tableName];
721
870
  if (!tableDefinition) {
722
- throw new Error(`unknown table: ${tableName}`);
871
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
723
872
  }
873
+ const scopeWhere = softDeleteScope(tableDefinition.softDeleteMode, void 0);
874
+ const scopeCondition = scopeWhere ? compileWhereSql(scopeWhere, doWhereSqlStrategy) : void 0;
724
875
  const stage = {
725
876
  indexFields: [],
726
877
  indexName: void 0,
@@ -731,11 +882,11 @@ const buildReader = (sql$1, schema, tableName, onIndexUse = () => void 0) => {
731
882
  const runSearchFetch = (limit) => {
732
883
  const { search } = stage;
733
884
  if (!search) {
734
- throw new Error("runSearchFetch called without a staged search");
885
+ throw new LunoraError("INTERNAL", "runSearchFetch called without a staged search");
735
886
  }
736
887
  const filtered = stage.inMemoryFilters.length > 0;
737
888
  const engineLimit = filtered ? void 0 : limit;
738
- const docs = isFtsAvailable(sql$1) ? searchViaFts(sql$1, tableName, search, engineLimit) : searchViaScan(sql$1, tableName, search, engineLimit);
889
+ const docs = isFtsAvailable(sql$1) ? searchViaFts(sql$1, tableName, search, engineLimit, scopeCondition) : searchViaScan(sql$1, tableName, search, engineLimit, scopeCondition);
739
890
  if (!filtered) {
740
891
  return docs;
741
892
  }
@@ -762,33 +913,10 @@ const buildReader = (sql$1, schema, tableName, onIndexUse = () => void 0) => {
762
913
  if (stage.search) {
763
914
  return runSearchFetch(limit);
764
915
  }
765
- const whereClauses = [];
766
- for (const condition of stage.sqlConditions) {
767
- whereClauses.push(sql`${jsonPathSql(condition.field)} ${sql.raw(condition.comparator)} ${serializeSqlValue(condition.value)}`);
768
- }
769
- let query = sql`SELECT id, _creationTime, ${sql.identifier(DOC_COLUMN$1)} FROM ${sql.identifier(tableName)}`;
770
- if (whereClauses.length > 0) {
771
- query = sql`${query} WHERE ${sql.join(whereClauses, sql` AND `)}`;
772
- }
773
- query = sql`${query} ORDER BY ${buildOrderClause()}`;
774
- if (typeof limit === "number" && stage.inMemoryFilters.length === 0) {
775
- query = sql`${query} LIMIT ${sql.raw(String(Math.max(0, Math.floor(limit))))}`;
916
+ if (stage.geo) {
917
+ return runGeoTerminal(sql$1, tableName, stage, scopeCondition, limit);
776
918
  }
777
- const rows = runDrizzle(sql$1, query).toArray();
778
- const docs = [];
779
- for (const row of rows) {
780
- const record = rowToDocument(row);
781
- if (!record) {
782
- continue;
783
- }
784
- if (stage.inMemoryFilters.every((predicate) => predicate(record))) {
785
- docs.push(record);
786
- if (typeof limit === "number" && docs.length >= limit) {
787
- break;
788
- }
789
- }
790
- }
791
- return docs;
919
+ return runPlainFetch(sql$1, tableName, stage, scopeCondition, buildOrderClause(), limit);
792
920
  };
793
921
  const reader = {
794
922
  // eslint-disable-next-line @typescript-eslint/require-await -- TableReaderLike returns Promises (the D1 twin awaits real I/O); the DO impl is synchronous over local SQLite
@@ -811,9 +939,12 @@ const buildReader = (sql$1, schema, tableName, onIndexUse = () => void 0) => {
811
939
  // eslint-disable-next-line @typescript-eslint/require-await -- TableReaderLike returns Promises (the D1 twin awaits real I/O); the DO impl is synchronous over local SQLite
812
940
  async paginate(options) {
813
941
  if (stage.search) {
814
- throw new Error("pagination is not supported on search queries; use .take(n) or .collect()");
942
+ throw new LunoraError("INTERNAL", "pagination is not supported on search queries; use .take(n) or .collect()");
815
943
  }
816
- return paginateStage(sql$1, tableName, stage, options);
944
+ if (stage.geo) {
945
+ throw new LunoraError("INTERNAL", "pagination is not supported on geo queries; use .take(n) or .collect()");
946
+ }
947
+ return paginateStage(sql$1, tableName, stage, options, scopeCondition);
817
948
  },
818
949
  // eslint-disable-next-line @typescript-eslint/require-await -- TableReaderLike returns Promises (the D1 twin awaits real I/O); the DO impl is synchronous over local SQLite
819
950
  async take(limit) {
@@ -827,10 +958,24 @@ const buildReader = (sql$1, schema, tableName, onIndexUse = () => void 0) => {
827
958
  }
828
959
  return rows[0] ?? null;
829
960
  },
961
+ withGeoIndex(indexName, build) {
962
+ const definition = (tableDefinition.geoIndexes ?? []).find((index) => index.name === indexName);
963
+ if (!definition) {
964
+ throw new LunoraError("INTERNAL", `unknown geo index "${indexName}" on table "${tableName}"`);
965
+ }
966
+ onIndexUse(tableName, indexName, "geo");
967
+ const geoStage = { definition, indexName };
968
+ stage.geo = geoStage;
969
+ build(createGeoBuilder(geoStage, tableName));
970
+ if (!geoStage.near && !geoStage.within) {
971
+ throw new LunoraError("INTERNAL", `geo index "${indexName}" on table "${tableName}" requires a .near(point, radius) or .within(box) call`);
972
+ }
973
+ return reader;
974
+ },
830
975
  withIndex(indexName, range) {
831
976
  const definition = tableDefinition.indexes.find((index) => index.name === indexName);
832
977
  if (!definition) {
833
- throw new Error(`unknown index "${indexName}" on table "${tableName}"`);
978
+ throw new LunoraError("INTERNAL", `unknown index "${indexName}" on table "${tableName}"`);
834
979
  }
835
980
  onIndexUse(tableName, indexName, "index");
836
981
  stage.indexName = indexName;
@@ -843,7 +988,7 @@ const buildReader = (sql$1, schema, tableName, onIndexUse = () => void 0) => {
843
988
  withSearchIndex(indexName, search) {
844
989
  const definition = (tableDefinition.searchIndexes ?? []).find((index) => index.name === indexName);
845
990
  if (!definition) {
846
- throw new Error(`unknown search index "${indexName}" on table "${tableName}"`);
991
+ throw new LunoraError("INTERNAL", `unknown search index "${indexName}" on table "${tableName}"`);
847
992
  }
848
993
  onIndexUse(tableName, indexName, "search");
849
994
  const searchStage = {
@@ -857,7 +1002,7 @@ const buildReader = (sql$1, schema, tableName, onIndexUse = () => void 0) => {
857
1002
  stage.search = searchStage;
858
1003
  search(createSearchBuilder(searchStage, tableName));
859
1004
  if (!searchStage.hasQuery) {
860
- throw new Error(`search index "${indexName}" on table "${tableName}" requires a .search(field, query) call`);
1005
+ throw new LunoraError("INTERNAL", `search index "${indexName}" on table "${tableName}" requires a .search(field, query) call`);
861
1006
  }
862
1007
  return reader;
863
1008
  }
@@ -899,7 +1044,10 @@ const applyOnUpdate = (definition, provided, target, auth) => {
899
1044
  const assertNoExplicitUndefined = (op, document) => {
900
1045
  for (const field of Object.keys(document)) {
901
1046
  if (document[field] === void 0) {
902
- throw new Error(`Cannot ${op} field '${field}' to undefined — use null to clear a nullable field, or omit the key to leave it unchanged.`);
1047
+ throw new LunoraError(
1048
+ "INTERNAL",
1049
+ `Cannot ${op} field '${field}' to undefined — use null to clear a nullable field, or omit the key to leave it unchanged.`
1050
+ );
903
1051
  }
904
1052
  }
905
1053
  };
@@ -979,7 +1127,10 @@ const createShardCtxDb = (options) => {
979
1127
  const routeBackend = (table, op) => {
980
1128
  if (isGlobalTable(table)) {
981
1129
  if (!globalDb) {
982
- throw new Error(`cross-backend ${op} for global table '${table}' requires a globalDb writer — pass one to createShardCtxDb({ globalDb })`);
1130
+ throw new LunoraError(
1131
+ "INTERNAL",
1132
+ `cross-backend ${op} for global table '${table}' requires a globalDb writer — pass one to createShardCtxDb({ globalDb })`
1133
+ );
983
1134
  }
984
1135
  return globalDb;
985
1136
  }
@@ -991,13 +1142,12 @@ const createShardCtxDb = (options) => {
991
1142
  return void 0;
992
1143
  }
993
1144
  if (!globalDb) {
994
- throw new Error(`${op} on global table '${tableName}' requires a globalDb writer — pass one to createShardCtxDb({ globalDb })`);
1145
+ throw new LunoraError("INTERNAL", `${op} on global table '${tableName}' requires a globalDb writer — pass one to createShardCtxDb({ globalDb })`);
995
1146
  }
996
1147
  return globalDb;
997
1148
  };
998
1149
  const globalFallback = () => globalDb;
999
1150
  const relationFetcher = (relationTable, relationArgs) => routeBackend(relationTable, "relation load").findMany(relationTable, relationArgs);
1000
- const relationCounter = (relationTable, relationWhere) => routeBackend(relationTable, "relation load").count(relationTable, relationWhere);
1001
1151
  const relationPredicateFetcher = (relationTable, relationArgs) => {
1002
1152
  if (isGlobalTable(relationTable)) {
1003
1153
  onRead(relationTable, SCAN_DEP);
@@ -1015,6 +1165,31 @@ const createShardCtxDb = (options) => {
1015
1165
  schema,
1016
1166
  tableName: predicateTable
1017
1167
  });
1168
+ const relationGroupedCounter = async (relationTable, whereField, values, policyWhere) => {
1169
+ const globalWriter = globalWriterFor(relationTable, "relation grouped count");
1170
+ if (globalWriter) {
1171
+ onRead(relationTable, SCAN_DEP);
1172
+ return fanOutScalarCounts((t, w) => globalWriter.count(t, w), relationTable, whereField, values, policyWhere);
1173
+ }
1174
+ const definition = schema.tables[relationTable];
1175
+ if (!definition) {
1176
+ throw new LunoraError("INTERNAL", `unknown table: ${relationTable}`);
1177
+ }
1178
+ onRead(relationTable, SCAN_DEP);
1179
+ const softScope = softDeleteScope(definition.softDeleteMode, void 0);
1180
+ const inFilter = { [whereField]: { in: values } };
1181
+ const combined = mergeWhere(mergeWhere(inFilter, policyWhere), softScope);
1182
+ const resolvedCombined = await resolveAggregateRelations(combined, relationTable, void 0);
1183
+ const whereCondition = compileWhereSql(resolvedCombined, doWhereSqlStrategy);
1184
+ const fieldSql = jsonPathSql(whereField);
1185
+ let query = sql`SELECT ${fieldSql} AS __fk__, COUNT(*) AS count FROM ${sql.identifier(relationTable)}`;
1186
+ if (whereCondition) {
1187
+ query = sql`${query} WHERE ${whereCondition}`;
1188
+ }
1189
+ query = sql`${query} GROUP BY ${fieldSql}`;
1190
+ const rows = runDrizzle(sql$1, query).toArray();
1191
+ return new Map(rows.map((row) => [row["__fk__"], row.count]));
1192
+ };
1018
1193
  let triggerDepth = 0;
1019
1194
  const triggerMatchers = /* @__PURE__ */ new Set();
1020
1195
  for (const [tableName, definition] of Object.entries(schema.tables)) {
@@ -1045,6 +1220,7 @@ const createShardCtxDb = (options) => {
1045
1220
  ensureRankBackfilledForTable,
1046
1221
  syncAggregates,
1047
1222
  syncCompanionsForInsert,
1223
+ syncGeo,
1048
1224
  syncRanks,
1049
1225
  syncSearch
1050
1226
  } = createCompanionSync({
@@ -1115,7 +1291,7 @@ const createShardCtxDb = (options) => {
1115
1291
  }
1116
1292
  const definition = schema.tables[tableName];
1117
1293
  if (!definition) {
1118
- throw new Error(`unknown table: ${tableName}`);
1294
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1119
1295
  }
1120
1296
  aggregateSqlFunction(aggOptions.op);
1121
1297
  if (aggOptions.op === "count") {
@@ -1127,13 +1303,14 @@ const createShardCtxDb = (options) => {
1127
1303
  });
1128
1304
  }
1129
1305
  if (!aggOptions.field) {
1130
- throw new Error(`aggregate(${tableName}, { op: "${aggOptions.op}" }): "field" is required for non-count reducers`);
1306
+ throw new LunoraError("INTERNAL", `aggregate(${tableName}, { op: "${aggOptions.op}" }): "field" is required for non-count reducers`);
1131
1307
  }
1132
1308
  onRead(tableName, SCAN_DEP);
1133
- const effective = mergeWhere(aggOptions.baseWhere, aggOptions.where);
1309
+ const aggScope = softDeleteScope(definition.softDeleteMode, void 0);
1310
+ const effective = mergeWhere(mergeWhere(aggOptions.baseWhere, aggOptions.where), aggScope);
1134
1311
  const resolved = await resolveAggregateRelations(effective, tableName, aggOptions.relationBaseWhere);
1135
1312
  const hasRelation = resolved !== effective;
1136
- if (definition.aggregateIndexes && !aggOptions.baseWhere && !hasRelation) {
1313
+ if (definition.aggregateIndexes && !aggOptions.baseWhere && !hasRelation && !aggScope) {
1137
1314
  const planned = selectIndexForAggregate(definition.aggregateIndexes, aggOptions.op, aggOptions.field, aggOptions.where);
1138
1315
  if (planned) {
1139
1316
  ensureBackfilled(tableName, planned.index);
@@ -1168,17 +1345,18 @@ const createShardCtxDb = (options) => {
1168
1345
  }
1169
1346
  const definition = schema.tables[tableName];
1170
1347
  if (!definition) {
1171
- throw new Error(`unknown table: ${tableName}`);
1348
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1172
1349
  }
1173
1350
  const countOptions = normalizeCountArgument(whereOrOptions);
1174
1351
  if (countOptions.restrictsCounts) {
1175
1352
  throw new CountRlsUnsupportedError(tableName);
1176
1353
  }
1177
1354
  onRead(tableName, SCAN_DEP);
1178
- const effective = mergeWhere(countOptions.baseWhere, countOptions.where);
1355
+ const countScope = softDeleteScope(definition.softDeleteMode, void 0);
1356
+ const effective = mergeWhere(mergeWhere(countOptions.baseWhere, countOptions.where), countScope);
1179
1357
  const resolved = await resolveAggregateRelations(effective, tableName, countOptions.relationBaseWhere);
1180
1358
  const hasRelation = resolved !== effective;
1181
- if (definition.aggregateIndexes && !countOptions.baseWhere && !hasRelation) {
1359
+ if (definition.aggregateIndexes && !countOptions.baseWhere && !hasRelation && !countScope) {
1182
1360
  const planned = selectIndexForCount(definition.aggregateIndexes, countOptions.where);
1183
1361
  if (planned) {
1184
1362
  ensureBackfilled(tableName, planned.index);
@@ -1199,16 +1377,22 @@ const createShardCtxDb = (options) => {
1199
1377
  const row = runDrizzle(sql$1, query).one();
1200
1378
  return row.count;
1201
1379
  },
1202
- async delete(id, expectedTable) {
1380
+ async delete(id, expectedTable, deleteOptions) {
1203
1381
  const located = locateRowById(id, expectedTable);
1204
1382
  if (!located) {
1205
1383
  const global = expectedTable === void 0 ? globalFallback() : void 0;
1206
1384
  if (global) {
1207
- await global.delete(id);
1385
+ await global.delete(id, void 0, deleteOptions);
1208
1386
  }
1209
1387
  return;
1210
1388
  }
1211
1389
  const { docJson: existingJson, row: existing, tableName } = located;
1390
+ const definition = schema.tables[tableName];
1391
+ const hard = deleteOptions?.hard === true;
1392
+ const softField = !hard && definition?.softDeleteMode ? definition.softDeleteMode.field : void 0;
1393
+ if (softField && existing[softField] !== null && existing[softField] !== void 0) {
1394
+ return;
1395
+ }
1212
1396
  if (hasMatchingTrigger(tableName, "before", "delete")) {
1213
1397
  await fireTriggers("before", "delete", { id, op: "delete", previous: existing, table: tableName });
1214
1398
  }
@@ -1216,10 +1400,10 @@ const createShardCtxDb = (options) => {
1216
1400
  deletedId: id,
1217
1401
  deletedReference: (references) => existing[references],
1218
1402
  findHolders: async (holderTable, field, value) => {
1219
- const holders = await routeForHolder(holderTable).findMany(holderTable, { where: { [field]: value } });
1403
+ const holders = await routeForHolder(holderTable).findMany(holderTable, { includeDeleted: hard, where: { [field]: value } });
1220
1404
  return holders.page;
1221
1405
  },
1222
- onCascade: (holderTable, holderId) => routeForHolder(holderTable).delete(holderId),
1406
+ onCascade: (holderTable, holderId) => routeForHolder(holderTable).delete(holderId, void 0, deleteOptions),
1223
1407
  onRestrict: (message) => {
1224
1408
  throw new ConflictError(message, "restrict");
1225
1409
  },
@@ -1230,12 +1414,33 @@ const createShardCtxDb = (options) => {
1230
1414
  });
1231
1415
  ensureBackfilledForTable(tableName);
1232
1416
  ensureRankBackfilledForTable(tableName);
1417
+ if (softField) {
1418
+ const merged = { ...existing, [softField]: clock(), _id: id };
1419
+ runGuardedWrite(
1420
+ sql$1,
1421
+ tableName,
1422
+ sql`UPDATE ${sql.identifier(tableName)} SET ${sql.identifier(DOC_COLUMN$1)} = ${JSON.stringify(merged)} WHERE id = ${id} AND ${sql.identifier(DOC_COLUMN$1)} = ${existingJson}`
1423
+ );
1424
+ syncSearch(tableName, id, merged);
1425
+ syncGeo(tableName, id, void 0);
1426
+ syncAggregates(tableName, existing, merged);
1427
+ syncRanks(tableName, id, existing, void 0);
1428
+ cache?.invalidate(tableName, id);
1429
+ recordCdc(tableName, id, "update", merged);
1430
+ broadcast({ key: id, op: "update", row: merged, table: tableName });
1431
+ if (hasMatchingTrigger(tableName, "after", "delete")) {
1432
+ await fireTriggers("after", "delete", { id, op: "delete", previous: existing, table: tableName });
1433
+ }
1434
+ await onWrite({ id, op: "delete", table: tableName });
1435
+ return;
1436
+ }
1233
1437
  runGuardedWrite(
1234
1438
  sql$1,
1235
1439
  tableName,
1236
1440
  sql`DELETE FROM ${sql.identifier(tableName)} WHERE id = ${id} AND ${sql.identifier(DOC_COLUMN$1)} = ${existingJson}`
1237
1441
  );
1238
1442
  syncSearch(tableName, id, void 0);
1443
+ syncGeo(tableName, id, void 0);
1239
1444
  syncAggregates(tableName, existing, void 0);
1240
1445
  syncRanks(tableName, id, existing, void 0);
1241
1446
  cache?.invalidate(tableName, id);
@@ -1253,6 +1458,25 @@ const createShardCtxDb = (options) => {
1253
1458
  }
1254
1459
  return { deleted: ids.length };
1255
1460
  },
1461
+ async deleteWhere(tableName, where, batchOptions) {
1462
+ const global = globalWriterFor(tableName, "deleteWhere");
1463
+ let ids;
1464
+ if (global) {
1465
+ const rows = await global.findMany(tableName, { where });
1466
+ ids = rows.page.map((row) => String(row["_id"]));
1467
+ } else {
1468
+ if (!schema.tables[tableName]) {
1469
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1470
+ }
1471
+ const page = await writer.findMany(tableName, { where });
1472
+ ids = page.page.map((row) => String(row["_id"]));
1473
+ }
1474
+ assertBatchLimit(ids.length, batchOptions?.limit, "deleteWhere");
1475
+ if (writer.deleteMany === void 0) {
1476
+ throw new LunoraError("INTERNAL", `ctx.db.${tableName}.deleteMany is unavailable: this writer has no batch delete`);
1477
+ }
1478
+ return writer.deleteMany(ids, batchOptions);
1479
+ },
1256
1480
  async findFirst(tableName, args = {}) {
1257
1481
  const result = await writer.findMany(tableName, { ...args, limit: 1 });
1258
1482
  return result.page[0] ?? null;
@@ -1271,8 +1495,9 @@ const createShardCtxDb = (options) => {
1271
1495
  onRead(tableName, SCAN_DEP);
1272
1496
  return global.findMany(tableName, args);
1273
1497
  }
1274
- if (!schema.tables[tableName]) {
1275
- throw new Error(`unknown table: ${tableName}`);
1498
+ const findManyDefinition = schema.tables[tableName];
1499
+ if (!findManyDefinition) {
1500
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1276
1501
  }
1277
1502
  const isFullScan = !args.where && !args.baseWhere;
1278
1503
  if (isFullScan) {
@@ -1283,6 +1508,7 @@ const createShardCtxDb = (options) => {
1283
1508
  const orderKeys = normalizeOrderKeys(args.orderBy);
1284
1509
  const seek = args.cursor ? buildSeekWhere(orderKeys, decodeCursor(args.cursor)) : void 0;
1285
1510
  let predicate = mergeWhere(args.baseWhere, args.where);
1511
+ predicate = mergeWhere(predicate, softDeleteScope(findManyDefinition.softDeleteMode, args.includeDeleted));
1286
1512
  predicate = await resolveRelationPredicates(predicate, {
1287
1513
  canPushExists: relationExistsPushDownEnabled ? canPushRelationExists : void 0,
1288
1514
  existsPushMode: relationExistsPushDown === "always" ? "always" : "auto",
@@ -1320,7 +1546,7 @@ const createShardCtxDb = (options) => {
1320
1546
  if (limit === void 0) {
1321
1547
  if (args.with) {
1322
1548
  await resolveWith({
1323
- counter: relationCounter,
1549
+ groupedCounter: relationGroupedCounter,
1324
1550
  fetcher: relationFetcher,
1325
1551
  parents: docs,
1326
1552
  relationBaseWhere: args.relationBaseWhere,
@@ -1329,15 +1555,15 @@ const createShardCtxDb = (options) => {
1329
1555
  with: args.with
1330
1556
  });
1331
1557
  }
1332
- return { continueCursor: null, isDone: true, page: docs };
1558
+ return { continueCursor: null, isDone: true, page: applySelect(docs, args.select, args.with) };
1333
1559
  }
1334
1560
  const hasMore = docs.length > limit;
1335
1561
  const page = hasMore ? docs.slice(0, limit) : docs;
1336
1562
  const last = page.at(-1);
1337
1563
  if (args.with) {
1338
1564
  await resolveWith({
1339
- counter: relationCounter,
1340
1565
  fetcher: relationFetcher,
1566
+ groupedCounter: relationGroupedCounter,
1341
1567
  parents: page,
1342
1568
  relationBaseWhere: args.relationBaseWhere,
1343
1569
  schema,
@@ -1346,10 +1572,12 @@ const createShardCtxDb = (options) => {
1346
1572
  });
1347
1573
  }
1348
1574
  return {
1575
+ // The cursor is encoded from `last` (the full, unprojected row) above,
1576
+ // so `applySelect` only trims the returned payload — paging is intact.
1349
1577
  // eslint-disable-next-line unicorn/no-null -- QueryPage.continueCursor is `null | string`: null is the documented "no further page" cursor on the wire
1350
1578
  continueCursor: hasMore && last ? encodeCursor(last, orderKeys) : null,
1351
1579
  isDone: !hasMore,
1352
- page
1580
+ page: applySelect(page, args.select, args.with)
1353
1581
  };
1354
1582
  },
1355
1583
  async get(id, expectedTable) {
@@ -1382,18 +1610,19 @@ const createShardCtxDb = (options) => {
1382
1610
  }
1383
1611
  const definition = schema.tables[tableName];
1384
1612
  if (!definition) {
1385
- throw new Error(`unknown table: ${tableName}`);
1613
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1386
1614
  }
1387
1615
  onRead(tableName, SCAN_DEP);
1388
1616
  const agg = groupOptions.agg ?? { op: "count" };
1389
1617
  aggregateSqlFunction(agg.op);
1390
1618
  if (agg.op !== "count" && !agg.field) {
1391
- throw new Error(`groupBy(${tableName}, { agg: { op: "${agg.op}" } }): "field" is required for non-count reducers`);
1619
+ throw new LunoraError("INTERNAL", `groupBy(${tableName}, { agg: { op: "${agg.op}" } }): "field" is required for non-count reducers`);
1392
1620
  }
1393
- const effective = mergeWhere(groupOptions.baseWhere, groupOptions.where);
1621
+ const groupScope = softDeleteScope(definition.softDeleteMode, void 0);
1622
+ const effective = mergeWhere(mergeWhere(groupOptions.baseWhere, groupOptions.where), groupScope);
1394
1623
  const resolved = await resolveAggregateRelations(effective, tableName, groupOptions.relationBaseWhere);
1395
1624
  const hasRelation = resolved !== effective;
1396
- if (definition.aggregateIndexes && !groupOptions.baseWhere && !hasRelation) {
1625
+ if (definition.aggregateIndexes && !groupOptions.baseWhere && !hasRelation && !groupScope) {
1397
1626
  const planned = selectIndexForGroupBy(definition.aggregateIndexes, agg.op, agg.field, groupOptions.by, groupOptions.where);
1398
1627
  if (planned) {
1399
1628
  ensureBackfilled(tableName, planned.index);
@@ -1432,7 +1661,7 @@ const createShardCtxDb = (options) => {
1432
1661
  } else {
1433
1662
  const { field } = agg;
1434
1663
  if (field === void 0) {
1435
- throw new Error(`groupBy(${tableName}, { agg: { op: "${agg.op}" } }): "field" is required for non-count reducers`);
1664
+ throw new LunoraError("INTERNAL", `groupBy(${tableName}, { agg: { op: "${agg.op}" } }): "field" is required for non-count reducers`);
1436
1665
  }
1437
1666
  select.push(sql`${sql.raw(aggregateSqlFunction(agg.op))}(${jsonPathSql(field)}) AS value`);
1438
1667
  }
@@ -1465,7 +1694,7 @@ const createShardCtxDb = (options) => {
1465
1694
  }
1466
1695
  const definition = schema.tables[tableName];
1467
1696
  if (!definition) {
1468
- throw new Error(`unknown table: ${tableName}`);
1697
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1469
1698
  }
1470
1699
  const withDefaults = applyInsertDefaults(definition, document, auth);
1471
1700
  runRowValidators(definition, withDefaults);
@@ -1478,7 +1707,7 @@ const createShardCtxDb = (options) => {
1478
1707
  } else {
1479
1708
  id = generateId();
1480
1709
  }
1481
- const creationTime = typeof withDefaults["_creationTime"] === "number" ? withDefaults["_creationTime"] : clock();
1710
+ const creationTime = insertOptions?.allowExplicitId && typeof withDefaults["_creationTime"] === "number" ? withDefaults["_creationTime"] : clock();
1482
1711
  const documentWithMeta = { ...withDefaults, _creationTime: creationTime, _id: id };
1483
1712
  if (hasMatchingTrigger(tableName, "before", "insert")) {
1484
1713
  await fireTriggers("before", "insert", { doc: { ...documentWithMeta }, id, op: "insert", table: tableName });
@@ -1514,14 +1743,14 @@ const createShardCtxDb = (options) => {
1514
1743
  }
1515
1744
  const definition = schema.tables[tableName];
1516
1745
  if (!definition) {
1517
- throw new Error(`unknown table: ${tableName}`);
1746
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1518
1747
  }
1519
1748
  ensureBackfilledForTable(tableName);
1520
1749
  ensureRankBackfilledForTable(tableName);
1521
1750
  const rows = documents.map((document) => {
1522
1751
  const withDefaults = applyInsertDefaults(definition, document, auth);
1523
1752
  const id = batchOptions?.allowExplicitId === true && typeof withDefaults["_id"] === "string" ? withDefaults["_id"] : generateId();
1524
- const creationTime = typeof withDefaults["_creationTime"] === "number" ? withDefaults["_creationTime"] : clock();
1753
+ const creationTime = batchOptions?.allowExplicitId === true && typeof withDefaults["_creationTime"] === "number" ? withDefaults["_creationTime"] : clock();
1525
1754
  return { creationTime, document: { ...withDefaults, _creationTime: creationTime, _id: id }, id };
1526
1755
  });
1527
1756
  const valuesSql = sql.join(
@@ -1537,9 +1766,18 @@ const createShardCtxDb = (options) => {
1537
1766
  },
1538
1767
  async insertMany(tableName, documents, batchOptions) {
1539
1768
  assertBatchLimit(documents.length, batchOptions?.limit, "insertMany");
1769
+ const skipDuplicates = batchOptions?.skipDuplicates === true;
1540
1770
  const ids = [];
1541
1771
  for (const document of documents) {
1542
- ids.push(await writer.insert(tableName, document));
1772
+ try {
1773
+ ids.push(await writer.insert(tableName, document));
1774
+ } catch (error) {
1775
+ if (skipDuplicates && error instanceof ConflictError && error.kind === "unique") {
1776
+ ids.push(null);
1777
+ } else {
1778
+ throw error;
1779
+ }
1780
+ }
1543
1781
  }
1544
1782
  return ids;
1545
1783
  },
@@ -1554,12 +1792,12 @@ const createShardCtxDb = (options) => {
1554
1792
  await global.patch(id, patch);
1555
1793
  return;
1556
1794
  }
1557
- throw new Error(`document not found: ${id}`);
1795
+ throw new LunoraError("INTERNAL", `document not found: ${id}`);
1558
1796
  }
1559
1797
  const { docJson: existingJson, row: existing, tableName } = located;
1560
1798
  const tableDefinition = schema.tables[tableName];
1561
1799
  if (!tableDefinition) {
1562
- throw new Error(`unknown table: ${tableName}`);
1800
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1563
1801
  }
1564
1802
  onRead(tableName, id);
1565
1803
  assertNoExplicitUndefined("patch", patch);
@@ -1577,6 +1815,7 @@ const createShardCtxDb = (options) => {
1577
1815
  sql`UPDATE ${sql.identifier(tableName)} SET ${sql.identifier(DOC_COLUMN$1)} = ${JSON.stringify(merged)} WHERE id = ${id} AND ${sql.identifier(DOC_COLUMN$1)} = ${existingJson}`
1578
1816
  );
1579
1817
  syncSearch(tableName, id, merged);
1818
+ syncGeo(tableName, id, merged);
1580
1819
  syncAggregates(tableName, existing, merged);
1581
1820
  syncRanks(tableName, id, existing, merged);
1582
1821
  cache?.invalidate(tableName, id);
@@ -1592,6 +1831,31 @@ const createShardCtxDb = (options) => {
1592
1831
  for (const entry of patches) {
1593
1832
  await writer.patch(entry.id, entry.patch, expectedTable);
1594
1833
  }
1834
+ return { patched: patches.length };
1835
+ },
1836
+ async patchWhere(tableName, args, batchOptions) {
1837
+ const global = globalWriterFor(tableName, "patchWhere");
1838
+ let patches;
1839
+ if (global) {
1840
+ const rows = await global.findMany(tableName, { where: args.where });
1841
+ patches = rows.page.map((row) => {
1842
+ return { id: String(row["_id"]), patch: args.patch };
1843
+ });
1844
+ } else {
1845
+ if (!schema.tables[tableName]) {
1846
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1847
+ }
1848
+ const page = await writer.findMany(tableName, { where: args.where });
1849
+ patches = page.page.map((row) => {
1850
+ return { id: String(row["_id"]), patch: args.patch };
1851
+ });
1852
+ }
1853
+ assertBatchLimit(patches.length, batchOptions?.limit, "patchWhere");
1854
+ if (writer.patchMany === void 0) {
1855
+ throw new LunoraError("INTERNAL", `ctx.db.${tableName}.patchMany is unavailable: this writer has no batch patch`);
1856
+ }
1857
+ await writer.patchMany(patches, batchOptions);
1858
+ return { patched: patches.length };
1595
1859
  },
1596
1860
  query(tableName) {
1597
1861
  const global = globalWriterFor(tableName, "query");
@@ -1611,11 +1875,11 @@ const createShardCtxDb = (options) => {
1611
1875
  onIndexUse(tableName, indexName, "rank");
1612
1876
  const definition = schema.tables[tableName];
1613
1877
  if (!definition) {
1614
- throw new Error(`unknown table: ${tableName}`);
1878
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1615
1879
  }
1616
1880
  const index = definition.rankIndexes?.find((i) => i.name === indexName);
1617
1881
  if (!index) {
1618
- throw new Error(`unknown rankIndex "${indexName}" on table "${tableName}"`);
1882
+ throw new LunoraError("INTERNAL", `unknown rankIndex "${indexName}" on table "${tableName}"`);
1619
1883
  }
1620
1884
  assertRankPartitionLocal(tableName, definition, index);
1621
1885
  if (rankOptions.restrictsCounts) {
@@ -1656,17 +1920,18 @@ const createShardCtxDb = (options) => {
1656
1920
  // eslint-disable-next-line @typescript-eslint/require-await -- DatabaseWriterLike returns Promises (the D1 twin awaits I/O); the body is synchronous SQLite
1657
1921
  async rankBefore(tableName, indexName, rankBeforeOptions) {
1658
1922
  if (isGlobalTable(tableName)) {
1659
- throw new Error(
1923
+ throw new LunoraError(
1924
+ "INTERNAL",
1660
1925
  `rankBefore is not supported on the global (.global()) table '${tableName}' — cross-shard rank cursors apply only to sharded tables`
1661
1926
  );
1662
1927
  }
1663
1928
  const definition = schema.tables[tableName];
1664
1929
  if (!definition) {
1665
- throw new Error(`unknown table: ${tableName}`);
1930
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1666
1931
  }
1667
1932
  const index = definition.rankIndexes?.find((i) => i.name === indexName);
1668
1933
  if (!index) {
1669
- throw new Error(`unknown rankIndex "${indexName}" on table "${tableName}"`);
1934
+ throw new LunoraError("INTERNAL", `unknown rankIndex "${indexName}" on table "${tableName}"`);
1670
1935
  }
1671
1936
  if (rankBeforeOptions.restrictsCounts) {
1672
1937
  throw new CountRlsUnsupportedError(tableName);
@@ -1696,23 +1961,43 @@ const createShardCtxDb = (options) => {
1696
1961
  const { directions, hasMore, rows } = computeRankPage(rankPageDeps, tableName, indexName, rankPageOptions);
1697
1962
  return { directions, hasMore, rows };
1698
1963
  },
1699
- async replace(id, document, expectedTable) {
1964
+ async restore(id, expectedTable) {
1965
+ const located = locateRowById(id, expectedTable);
1966
+ if (!located) {
1967
+ const global = expectedTable === void 0 ? globalFallback() : void 0;
1968
+ if (global?.restore) {
1969
+ await global.restore(id);
1970
+ return;
1971
+ }
1972
+ throw new LunoraError("INTERNAL", `document not found: ${id}`);
1973
+ }
1974
+ const field = schema.tables[located.tableName]?.softDeleteMode?.field;
1975
+ if (!field) {
1976
+ throw new LunoraError("INTERNAL", `ctx.db.restore: table "${located.tableName}" is not a .softDelete() table`);
1977
+ }
1978
+ const wasDeleted = located.row[field] !== null && located.row[field] !== void 0;
1979
+ await writer.patch(id, { [field]: null }, expectedTable);
1980
+ if (wasDeleted) {
1981
+ syncRanks(located.tableName, id, void 0, located.row);
1982
+ }
1983
+ },
1984
+ async replace(id, document, expectedTable, replaceOptions) {
1700
1985
  const located = locateRowById(id, expectedTable);
1701
1986
  if (!located) {
1702
1987
  const global = expectedTable === void 0 ? globalFallback() : void 0;
1703
1988
  if (global) {
1704
- await global.replace(id, document);
1989
+ await global.replace(id, document, void 0, replaceOptions);
1705
1990
  return;
1706
1991
  }
1707
- throw new Error(`document not found: ${id}`);
1992
+ throw new LunoraError("INTERNAL", `document not found: ${id}`);
1708
1993
  }
1709
1994
  const { docJson: existingJson, row: previous, tableName } = located;
1710
1995
  const tableDefinition = schema.tables[tableName];
1711
1996
  if (!tableDefinition) {
1712
- throw new Error(`unknown table: ${tableName}`);
1997
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1713
1998
  }
1714
1999
  assertNoExplicitUndefined("replace", document);
1715
- const creationTime = typeof document["_creationTime"] === "number" ? document["_creationTime"] : clock();
2000
+ const creationTime = replaceOptions?.allowExplicitId && typeof document["_creationTime"] === "number" ? document["_creationTime"] : clock();
1716
2001
  const replaced = { ...document, _creationTime: creationTime, _id: id };
1717
2002
  applyOnUpdate(tableDefinition, document, replaced, auth);
1718
2003
  runRowValidators(tableDefinition, replaced);
@@ -1727,6 +2012,7 @@ const createShardCtxDb = (options) => {
1727
2012
  sql`UPDATE ${sql.identifier(tableName)} SET _creationTime = ${creationTime}, ${sql.identifier(DOC_COLUMN$1)} = ${JSON.stringify(replaced)} WHERE id = ${id} AND ${sql.identifier(DOC_COLUMN$1)} = ${existingJson}`
1728
2013
  );
1729
2014
  syncSearch(tableName, id, replaced);
2015
+ syncGeo(tableName, id, replaced);
1730
2016
  syncAggregates(tableName, previous, replaced);
1731
2017
  syncRanks(tableName, id, previous, replaced);
1732
2018
  cache?.invalidate(tableName, id);