@lunora/do 1.0.0-alpha.3 → 1.0.0-alpha.30

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 (51) hide show
  1. package/LICENSE.md +6 -0
  2. package/__assets__/package-og.svg +1 -1
  3. package/dist/index.d.mts +1375 -289
  4. package/dist/index.d.ts +1375 -289
  5. package/dist/index.mjs +35 -30
  6. package/dist/packem_shared/{ADMIN_FUNCTION_PREFIX-Dzdqq5J2.mjs → ADMIN_FUNCTIONS-CAHLZMj8.mjs} +62 -8
  7. package/dist/packem_shared/{matchesStaticWhere-CFk6adSu.mjs → AGGREGATE_SQL_FUNCTION-CQsu2Xga.mjs} +5 -3
  8. package/dist/packem_shared/{applyCdcChanges-Ctdmxmrv.mjs → CDC_LOG_TABLE-DjJEHiM2.mjs} +7 -3
  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/{assertFlatPredicate-DyVYReuT.mjs → DEFAULT_MAX_RELATION_KEYS-BEan1CRD.mjs} +58 -7
  13. package/dist/packem_shared/{assertReadonly-dDcFE1YZ.mjs → MAX_SQL_ROWS-D57CJaT9.mjs} +3 -1
  14. package/dist/packem_shared/NotFoundError-C70b9hLw.mjs +9 -0
  15. package/dist/packem_shared/{assertValidClientId-CBZ1zC96.mjs → NotUniqueError-BigrdT_W.mjs} +241 -90
  16. package/dist/packem_shared/{rank-CrkEIpF4.mjs → RANK_TIEBREAK-CXhdcA1o.mjs} +2 -13
  17. package/dist/packem_shared/{guardWriter-u3UlnCH5.mjs → RLS_UNWRAP_SYMBOL-DTvHvRzY.mjs} +22 -9
  18. package/dist/packem_shared/{ROOT_DO_SIZE_WARN_BYTES-DQkmGiCS.mjs → ROOT_DO_SIZE_WARN_BYTES-BA8QOChj.mjs} +2878 -326
  19. package/dist/packem_shared/{ReactiveCache-ByVzgH3d.mjs → ReactiveCache-BYlSGY0N.mjs} +1 -28
  20. package/dist/packem_shared/{SESSION_DO_TTL_DEFAULT-ilPZsVwu.mjs → SESSION_DO_TTL_DEFAULT-BnSKgVO4.mjs} +16 -27
  21. package/dist/packem_shared/{SHARD_REGISTRY_DO_NAME-BsAbi5Mn.mjs → SHARD_REGISTRY_DO_NAME-D99roc-r.mjs} +12 -14
  22. package/dist/packem_shared/{applyOnDelete-CMif2RKw.mjs → applyOnDelete-BXSq3S70.mjs} +23 -12
  23. package/dist/packem_shared/{buildSeekWhere-lVsNXSLy.mjs → applySelect-WQY8m62C.mjs} +21 -2
  24. package/dist/packem_shared/{armRestore-BJk53Ro8.mjs → armRestore-4Px61hHS.mjs} +4 -10
  25. package/dist/packem_shared/{backfillAggregateIndexes-BF5eL7kW.mjs → backfillAggregateIndexes-DDoT-UUI.mjs} +3 -2
  26. package/dist/packem_shared/{compileWhereSql-CXrhFA3G.mjs → compileWhereSql-DE6yfRcQ.mjs} +5 -3
  27. package/dist/packem_shared/constant-time-equal-BVRWZgES.mjs +12 -0
  28. package/dist/packem_shared/{createSystemReader-8CzSZP9V.mjs → createSystemReader-D12eNH13.mjs} +4 -2
  29. package/dist/packem_shared/ctx-db-idempotency-BdcNpvY4.mjs +108 -0
  30. package/dist/packem_shared/ctx-db-shapes-Cz9dHyh1.mjs +53 -0
  31. package/dist/packem_shared/diffExternalSource-Cx9HUPJj.mjs +44 -0
  32. package/dist/packem_shared/{exportShardRows-DZEhUeyI.mjs → exportShardRows-Dy3oFZ26.mjs} +4 -3
  33. package/dist/packem_shared/isSourceDue-CYkt7Ru8.mjs +41 -0
  34. package/dist/packem_shared/json-response-BdbtpOhm.mjs +3 -0
  35. package/dist/packem_shared/materializeExternalRows-CTqZisSC.mjs +23 -0
  36. package/dist/packem_shared/{runShardMigrations-C3bn5r93.mjs → runShardMigrations-BGx4v2B6.mjs} +6 -4
  37. package/dist/packem_shared/serialize-sql-BlRUoiQe.mjs +14 -0
  38. package/dist/packem_shared/{serveRelationFanout-Clr1a05L.mjs → serveRelationFanout-BgaNg3Hu.mjs} +4 -7
  39. package/dist/packem_shared/stableStringify-MydiuScU.mjs +40 -0
  40. package/dist/packem_shared/subscription-delivery-CWigSEr3.mjs +348 -0
  41. package/dist/packem_shared/subscriptionListDeltas-DRLvFlM1.mjs +1 -0
  42. package/package.json +5 -3
  43. package/dist/packem_shared/NotFoundError-CMuMZt81.mjs +0 -10
  44. package/dist/packem_shared/ctx-db-idempotency-DkC9rP91.mjs +0 -35
  45. package/dist/packem_shared/encodePartitionKey-C6blLR5K.mjs +0 -1
  46. /package/dist/packem_shared/{AUTH_METRICS_BUCKET_MS-CiHHYeJi.mjs → AUTH_METRICS_BUCKETS_TABLE-CiHHYeJi.mjs} +0 -0
  47. /package/dist/packem_shared/{ensureFunctionMetricsTables-UDNVD7FS.mjs → FUNCTION_METRICS_BUCKETS_TABLE-UDNVD7FS.mjs} +0 -0
  48. /package/dist/packem_shared/{clearCapturedMail-CPpgl-dX.mjs → MAIL_RETENTION-CPpgl-dX.mjs} +0 -0
  49. /package/dist/packem_shared/{buildSecurityAudit-CCAvoFlr.mjs → MIN_ADMIN_TOKEN_LENGTH-CCAvoFlr.mjs} +0 -0
  50. /package/dist/packem_shared/{ftsTableName-BLEMawrp.mjs → buildFtsMatch-BLEMawrp.mjs} +0 -0
  51. /package/dist/packem_shared/{runTriggers-5N6_Fx0A.mjs → hasTrigger-5N6_Fx0A.mjs} +0 -0
@@ -1,27 +1,30 @@
1
+ import { LunoraError } from '@lunora/errors';
1
2
  import { sql } from 'drizzle-orm';
2
- import { matchesStaticWhere, aggregateSqlFunction, normalizeCountArgument, throwingScheduler } from './matchesStaticWhere-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 './applyCdcChanges-Ctdmxmrv.mjs';
6
- export { CDC_LOG_TABLE, CDC_META_TABLE, applyCdcChanges, bumpCdcEpoch, migrateCdcLog, migrateCdcMeta, minCdcSeq, readCdcChanges, readCdcCursor, readCdcEpoch, trimCdcChanges } from './applyCdcChanges-Ctdmxmrv.mjs';
5
+ import { mergeWhere, CountRlsUnsupportedError, selectIndexForGroupBy, selectIndexForCount, selectIndexForAggregate } from './CountRlsUnsupportedError-BGxj0pgS.mjs';
6
+ import { appendCdcChange } from './CDC_LOG_TABLE-DjJEHiM2.mjs';
7
+ export { CDC_LOG_TABLE, applyCdcChanges, bumpCdcEpoch, minCdcSeq, readCdcChanges, readCdcCursor, readCdcEpoch, trimCdcChanges } from './CDC_LOG_TABLE-DjJEHiM2.mjs';
7
8
  import { r as runDrizzle } from './do-exec-5eQy5cEi.mjs';
8
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, d as aggUpsertSql, j as jsonPathSql, q as quoteIdentifier, t as tableColumns, e as qualifiedJsonPathSql } from './do-sql-BCHCWtrD.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 { stringifySearchText, ftsTableName, tokenizeSearch, buildFtsMatch, scoreDocument } from './ftsTableName-BLEMawrp.mjs';
11
+ import { sortColumnName, matchesRankStaticWhere, encodePartitionKey, rankTableName, resolveRankPartition, RANK_TIEBREAK } from './RANK_TIEBREAK-CXhdcA1o.mjs';
12
+ import { stringifySearchText, ftsTableName, tokenizeSearch, buildFtsMatch, scoreDocument } from './buildFtsMatch-BLEMawrp.mjs';
13
+ import { s as serializeSqlValue } from './serialize-sql-BlRUoiQe.mjs';
12
14
  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 './assertFlatPredicate-DyVYReuT.mjs';
16
- import { runRowValidators, resolveWith, applyOnDelete } from './applyOnDelete-CMif2RKw.mjs';
17
- import { guardWriter } from './guardWriter-u3UlnCH5.mjs';
18
- import { createSystemReader } from './createSystemReader-8CzSZP9V.mjs';
19
- import { ConflictError } from './ConflictError-C0STs6bU.mjs';
20
- import { runTriggers } from './runTriggers-5N6_Fx0A.mjs';
21
- import { compileWhereSql } from './compileWhereSql-CXrhFA3G.mjs';
22
- export { backfillAggregateIndexes, backfillRankIndexes } from './backfillAggregateIndexes-BF5eL7kW.mjs';
23
- export { I as IDEMPOTENCY_TABLE, m as migrateIdempotency, r as readIdempotent, t as trimIdempotent, w as writeIdempotent } from './ctx-db-idempotency-DkC9rP91.mjs';
24
- export { runShardMigrations } from './runShardMigrations-C3bn5r93.mjs';
15
+ import { decodeCursor, normalizeOrderKeys, buildSeekWhere, applySelect, encodeCursor, softDeleteScope, buildSeekBeforeWhere } from './applySelect-WQY8m62C.mjs';
16
+ import NotFoundError from './NotFoundError-C70b9hLw.mjs';
17
+ import { assertFlatPredicate, resolveRelationPredicates } from './DEFAULT_MAX_RELATION_KEYS-BEan1CRD.mjs';
18
+ import { runRowValidators, resolveWith, applyOnDelete, fanOutScalarCounts } from './applyOnDelete-BXSq3S70.mjs';
19
+ import { guardWriter } from './RLS_UNWRAP_SYMBOL-DTvHvRzY.mjs';
20
+ import { createSystemReader } from './createSystemReader-D12eNH13.mjs';
21
+ import { ConflictError } from './ConflictError-CLoq37xH.mjs';
22
+ import { runTriggers } from './hasTrigger-5N6_Fx0A.mjs';
23
+ import { compileWhereSql } from './compileWhereSql-DE6yfRcQ.mjs';
24
+ export { backfillAggregateIndexes, backfillRankIndexes } from './backfillAggregateIndexes-DDoT-UUI.mjs';
25
+ 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';
26
+ export { runShardMigrations } from './runShardMigrations-BGx4v2B6.mjs';
27
+ export { a as selectShapeMemberIds, s as selectShapeRows } from './ctx-db-shapes-Cz9dHyh1.mjs';
25
28
 
26
29
  const rankIndexFieldsUnchanged = (index, previous, next) => {
27
30
  const fields = [...index.partitionBy ?? [], ...index.sortBy.map((key) => key.field), ...index.where ? Object.keys(index.where) : []];
@@ -420,11 +423,11 @@ const computeRankPage = (deps, tableName, indexName, rankPageOptions) => {
420
423
  const { assertRankPartitionLocal, ensureRankBackfilled, onRead, schema } = deps;
421
424
  const definition = schema.tables[tableName];
422
425
  if (!definition) {
423
- throw new Error(`unknown table: ${tableName}`);
426
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
424
427
  }
425
428
  const index = definition.rankIndexes?.find((i) => i.name === indexName);
426
429
  if (!index) {
427
- throw new Error(`unknown rankIndex "${indexName}" on table "${tableName}"`);
430
+ throw new LunoraError("INTERNAL", `unknown rankIndex "${indexName}" on table "${tableName}"`);
428
431
  }
429
432
  assertRankPartitionLocal(tableName, definition, index);
430
433
  onRead(tableName, SCAN_DEP);
@@ -472,7 +475,7 @@ const computeRankPage = (deps, tableName, indexName, rankPageOptions) => {
472
475
  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
476
  const assertValidClientId = (clientId) => {
474
477
  if (!CLIENT_ID_PATTERN.test(clientId)) {
475
- throw new Error(`invalid clientId ${JSON.stringify(clientId)}: a client-supplied row id must be a UUID`);
478
+ throw new LunoraError("INTERNAL", `invalid clientId ${JSON.stringify(clientId)}: a client-supplied row id must be a UUID`);
476
479
  }
477
480
  };
478
481
  const MAX_TRIGGER_DEPTH = 50;
@@ -480,11 +483,11 @@ const DEFAULT_BATCH_LIMIT = 500;
480
483
  const assertBatchLimit = (count, limit, op) => {
481
484
  const cap = limit ?? DEFAULT_BATCH_LIMIT;
482
485
  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
- });
486
+ throw new LunoraError(
487
+ "BATCH_LIMIT_EXCEEDED",
488
+ `${op}: batch of ${String(count)} exceeds the limit of ${String(cap)} (raise options.limit or chunk the call)`,
489
+ { status: 400 }
490
+ );
488
491
  }
489
492
  };
490
493
  const createRangeBuilder = (stage) => {
@@ -516,14 +519,17 @@ const createSearchBuilder = (search, tableName) => {
516
519
  const builder = {
517
520
  eq: (field, value) => {
518
521
  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}"`);
522
+ throw new LunoraError("INTERNAL", `field "${field}" is not a filter field of search index "${search.indexName}" on table "${tableName}"`);
520
523
  }
521
524
  search.filters.push({ field, value });
522
525
  return builder;
523
526
  },
524
527
  search: (field, query) => {
525
528
  if (field !== search.definition.field) {
526
- throw new Error(`search index "${search.indexName}" on table "${tableName}" indexes "${search.definition.field}", not "${field}"`);
529
+ throw new LunoraError(
530
+ "INTERNAL",
531
+ `search index "${search.indexName}" on table "${tableName}" indexes "${search.definition.field}", not "${field}"`
532
+ );
527
533
  }
528
534
  const stage = search;
529
535
  stage.field = field;
@@ -534,7 +540,7 @@ const createSearchBuilder = (search, tableName) => {
534
540
  };
535
541
  return builder;
536
542
  };
537
- const searchViaFts = (sql$1, tableName, search, limit) => {
543
+ const searchViaFts = (sql$1, tableName, search, limit, scopeCondition) => {
538
544
  const tokens = tokenizeSearch(search.query);
539
545
  if (tokens.length === 0) {
540
546
  return [];
@@ -544,6 +550,9 @@ const searchViaFts = (sql$1, tableName, search, limit) => {
544
550
  for (const filter of search.filters) {
545
551
  whereClauses.push(sql`${jsonPathSql(filter.field)} = ${serializeSqlValue(filter.value)}`);
546
552
  }
553
+ if (scopeCondition) {
554
+ whereClauses.push(scopeCondition);
555
+ }
547
556
  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
557
  if (typeof limit === "number") {
549
558
  query = sql`${query} LIMIT ${sql.raw(String(Math.max(0, Math.floor(limit))))}`;
@@ -558,7 +567,7 @@ const searchViaFts = (sql$1, tableName, search, limit) => {
558
567
  }
559
568
  return docs;
560
569
  };
561
- const searchViaScan = (sql$1, tableName, search, limit) => {
570
+ const searchViaScan = (sql$1, tableName, search, limit, scopeCondition) => {
562
571
  const tokens = tokenizeSearch(search.query);
563
572
  if (tokens.length === 0) {
564
573
  return [];
@@ -567,6 +576,9 @@ const searchViaScan = (sql$1, tableName, search, limit) => {
567
576
  for (const filter of search.filters) {
568
577
  whereClauses.push(sql`${jsonPathSql(filter.field)} = ${serializeSqlValue(filter.value)}`);
569
578
  }
579
+ if (scopeCondition) {
580
+ whereClauses.push(scopeCondition);
581
+ }
570
582
  let query = sql`SELECT id, _creationTime, ${sql.identifier(DOC_COLUMN$1)} FROM ${sql.identifier(tableName)}`;
571
583
  if (whereClauses.length > 0) {
572
584
  query = sql`${query} WHERE ${sql.join(whereClauses, sql` AND `)}`;
@@ -660,11 +672,12 @@ const scanDocs = (rows, filters, cap) => {
660
672
  }
661
673
  return docs;
662
674
  };
663
- const paginateStage = (sql$1, tableName, stage, options) => {
675
+ const paginateStage = (sql$1, tableName, stage, options, scopeCondition) => {
664
676
  const numberItems = Math.max(0, Math.floor(options.numItems));
665
677
  const orderKeys = paginateOrderKeys(stage);
666
678
  const bounded = typeof options.endCursor === "string";
667
- const whereCondition = compileWhereSql(paginateWhere(stage, orderKeys, options.cursor, options.endCursor), doWhereSqlStrategy);
679
+ const pageWhere = compileWhereSql(paginateWhere(stage, orderKeys, options.cursor, options.endCursor), doWhereSqlStrategy);
680
+ const whereCondition = scopeCondition && pageWhere ? sql`${pageWhere} AND ${scopeCondition}` : scopeCondition ?? pageWhere;
668
681
  let query = sql`SELECT id, _creationTime, ${sql.identifier(DOC_COLUMN$1)} FROM ${sql.identifier(tableName)}`;
669
682
  if (whereCondition) {
670
683
  query = sql`${query} WHERE ${whereCondition}`;
@@ -697,19 +710,16 @@ const paginateStage = (sql$1, tableName, stage, options) => {
697
710
  page
698
711
  };
699
712
  };
700
- class NotUniqueError extends Error {
701
- code = "NOT_UNIQUE";
702
- status = 400;
713
+ class NotUniqueError extends LunoraError {
703
714
  constructor(message = "unique() found more than one matching document") {
704
- super(message);
705
- this.name = "NotUniqueError";
715
+ super("NOT_UNIQUE", message, { name: "NotUniqueError" });
706
716
  }
707
717
  }
708
718
  const ID_WHITESPACE_PATTERN = /\s/u;
709
719
  const NUL_CHARACTER = String.fromCodePoint(0);
710
720
  const normalizeIdStructurally = (schema, tableName, id) => {
711
721
  if (!schema.tables[tableName]) {
712
- throw new Error(`unknown table: ${tableName}`);
722
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
713
723
  }
714
724
  if (typeof id !== "string" || id.length === 0 || ID_WHITESPACE_PATTERN.test(id) || id.includes(NUL_CHARACTER)) {
715
725
  return null;
@@ -719,8 +729,10 @@ const normalizeIdStructurally = (schema, tableName, id) => {
719
729
  const buildReader = (sql$1, schema, tableName, onIndexUse = () => void 0) => {
720
730
  const tableDefinition = schema.tables[tableName];
721
731
  if (!tableDefinition) {
722
- throw new Error(`unknown table: ${tableName}`);
732
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
723
733
  }
734
+ const scopeWhere = softDeleteScope(tableDefinition.softDeleteMode, void 0);
735
+ const scopeCondition = scopeWhere ? compileWhereSql(scopeWhere, doWhereSqlStrategy) : void 0;
724
736
  const stage = {
725
737
  indexFields: [],
726
738
  indexName: void 0,
@@ -731,11 +743,11 @@ const buildReader = (sql$1, schema, tableName, onIndexUse = () => void 0) => {
731
743
  const runSearchFetch = (limit) => {
732
744
  const { search } = stage;
733
745
  if (!search) {
734
- throw new Error("runSearchFetch called without a staged search");
746
+ throw new LunoraError("INTERNAL", "runSearchFetch called without a staged search");
735
747
  }
736
748
  const filtered = stage.inMemoryFilters.length > 0;
737
749
  const engineLimit = filtered ? void 0 : limit;
738
- const docs = isFtsAvailable(sql$1) ? searchViaFts(sql$1, tableName, search, engineLimit) : searchViaScan(sql$1, tableName, search, engineLimit);
750
+ const docs = isFtsAvailable(sql$1) ? searchViaFts(sql$1, tableName, search, engineLimit, scopeCondition) : searchViaScan(sql$1, tableName, search, engineLimit, scopeCondition);
739
751
  if (!filtered) {
740
752
  return docs;
741
753
  }
@@ -766,6 +778,9 @@ const buildReader = (sql$1, schema, tableName, onIndexUse = () => void 0) => {
766
778
  for (const condition of stage.sqlConditions) {
767
779
  whereClauses.push(sql`${jsonPathSql(condition.field)} ${sql.raw(condition.comparator)} ${serializeSqlValue(condition.value)}`);
768
780
  }
781
+ if (scopeCondition) {
782
+ whereClauses.push(scopeCondition);
783
+ }
769
784
  let query = sql`SELECT id, _creationTime, ${sql.identifier(DOC_COLUMN$1)} FROM ${sql.identifier(tableName)}`;
770
785
  if (whereClauses.length > 0) {
771
786
  query = sql`${query} WHERE ${sql.join(whereClauses, sql` AND `)}`;
@@ -811,9 +826,9 @@ const buildReader = (sql$1, schema, tableName, onIndexUse = () => void 0) => {
811
826
  // 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
827
  async paginate(options) {
813
828
  if (stage.search) {
814
- throw new Error("pagination is not supported on search queries; use .take(n) or .collect()");
829
+ throw new LunoraError("INTERNAL", "pagination is not supported on search queries; use .take(n) or .collect()");
815
830
  }
816
- return paginateStage(sql$1, tableName, stage, options);
831
+ return paginateStage(sql$1, tableName, stage, options, scopeCondition);
817
832
  },
818
833
  // 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
834
  async take(limit) {
@@ -830,7 +845,7 @@ const buildReader = (sql$1, schema, tableName, onIndexUse = () => void 0) => {
830
845
  withIndex(indexName, range) {
831
846
  const definition = tableDefinition.indexes.find((index) => index.name === indexName);
832
847
  if (!definition) {
833
- throw new Error(`unknown index "${indexName}" on table "${tableName}"`);
848
+ throw new LunoraError("INTERNAL", `unknown index "${indexName}" on table "${tableName}"`);
834
849
  }
835
850
  onIndexUse(tableName, indexName, "index");
836
851
  stage.indexName = indexName;
@@ -843,7 +858,7 @@ const buildReader = (sql$1, schema, tableName, onIndexUse = () => void 0) => {
843
858
  withSearchIndex(indexName, search) {
844
859
  const definition = (tableDefinition.searchIndexes ?? []).find((index) => index.name === indexName);
845
860
  if (!definition) {
846
- throw new Error(`unknown search index "${indexName}" on table "${tableName}"`);
861
+ throw new LunoraError("INTERNAL", `unknown search index "${indexName}" on table "${tableName}"`);
847
862
  }
848
863
  onIndexUse(tableName, indexName, "search");
849
864
  const searchStage = {
@@ -857,7 +872,7 @@ const buildReader = (sql$1, schema, tableName, onIndexUse = () => void 0) => {
857
872
  stage.search = searchStage;
858
873
  search(createSearchBuilder(searchStage, tableName));
859
874
  if (!searchStage.hasQuery) {
860
- throw new Error(`search index "${indexName}" on table "${tableName}" requires a .search(field, query) call`);
875
+ throw new LunoraError("INTERNAL", `search index "${indexName}" on table "${tableName}" requires a .search(field, query) call`);
861
876
  }
862
877
  return reader;
863
878
  }
@@ -899,7 +914,10 @@ const applyOnUpdate = (definition, provided, target, auth) => {
899
914
  const assertNoExplicitUndefined = (op, document) => {
900
915
  for (const field of Object.keys(document)) {
901
916
  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.`);
917
+ throw new LunoraError(
918
+ "INTERNAL",
919
+ `Cannot ${op} field '${field}' to undefined — use null to clear a nullable field, or omit the key to leave it unchanged.`
920
+ );
903
921
  }
904
922
  }
905
923
  };
@@ -979,7 +997,10 @@ const createShardCtxDb = (options) => {
979
997
  const routeBackend = (table, op) => {
980
998
  if (isGlobalTable(table)) {
981
999
  if (!globalDb) {
982
- throw new Error(`cross-backend ${op} for global table '${table}' requires a globalDb writer — pass one to createShardCtxDb({ globalDb })`);
1000
+ throw new LunoraError(
1001
+ "INTERNAL",
1002
+ `cross-backend ${op} for global table '${table}' requires a globalDb writer — pass one to createShardCtxDb({ globalDb })`
1003
+ );
983
1004
  }
984
1005
  return globalDb;
985
1006
  }
@@ -991,13 +1012,12 @@ const createShardCtxDb = (options) => {
991
1012
  return void 0;
992
1013
  }
993
1014
  if (!globalDb) {
994
- throw new Error(`${op} on global table '${tableName}' requires a globalDb writer — pass one to createShardCtxDb({ globalDb })`);
1015
+ throw new LunoraError("INTERNAL", `${op} on global table '${tableName}' requires a globalDb writer — pass one to createShardCtxDb({ globalDb })`);
995
1016
  }
996
1017
  return globalDb;
997
1018
  };
998
1019
  const globalFallback = () => globalDb;
999
1020
  const relationFetcher = (relationTable, relationArgs) => routeBackend(relationTable, "relation load").findMany(relationTable, relationArgs);
1000
- const relationCounter = (relationTable, relationWhere) => routeBackend(relationTable, "relation load").count(relationTable, relationWhere);
1001
1021
  const relationPredicateFetcher = (relationTable, relationArgs) => {
1002
1022
  if (isGlobalTable(relationTable)) {
1003
1023
  onRead(relationTable, SCAN_DEP);
@@ -1015,6 +1035,31 @@ const createShardCtxDb = (options) => {
1015
1035
  schema,
1016
1036
  tableName: predicateTable
1017
1037
  });
1038
+ const relationGroupedCounter = async (relationTable, whereField, values, policyWhere) => {
1039
+ const globalWriter = globalWriterFor(relationTable, "relation grouped count");
1040
+ if (globalWriter) {
1041
+ onRead(relationTable, SCAN_DEP);
1042
+ return fanOutScalarCounts((t, w) => globalWriter.count(t, w), relationTable, whereField, values, policyWhere);
1043
+ }
1044
+ const definition = schema.tables[relationTable];
1045
+ if (!definition) {
1046
+ throw new LunoraError("INTERNAL", `unknown table: ${relationTable}`);
1047
+ }
1048
+ onRead(relationTable, SCAN_DEP);
1049
+ const softScope = softDeleteScope(definition.softDeleteMode, void 0);
1050
+ const inFilter = { [whereField]: { in: values } };
1051
+ const combined = mergeWhere(mergeWhere(inFilter, policyWhere), softScope);
1052
+ const resolvedCombined = await resolveAggregateRelations(combined, relationTable, void 0);
1053
+ const whereCondition = compileWhereSql(resolvedCombined, doWhereSqlStrategy);
1054
+ const fieldSql = jsonPathSql(whereField);
1055
+ let query = sql`SELECT ${fieldSql} AS __fk__, COUNT(*) AS count FROM ${sql.identifier(relationTable)}`;
1056
+ if (whereCondition) {
1057
+ query = sql`${query} WHERE ${whereCondition}`;
1058
+ }
1059
+ query = sql`${query} GROUP BY ${fieldSql}`;
1060
+ const rows = runDrizzle(sql$1, query).toArray();
1061
+ return new Map(rows.map((row) => [row["__fk__"], row.count]));
1062
+ };
1018
1063
  let triggerDepth = 0;
1019
1064
  const triggerMatchers = /* @__PURE__ */ new Set();
1020
1065
  for (const [tableName, definition] of Object.entries(schema.tables)) {
@@ -1115,7 +1160,7 @@ const createShardCtxDb = (options) => {
1115
1160
  }
1116
1161
  const definition = schema.tables[tableName];
1117
1162
  if (!definition) {
1118
- throw new Error(`unknown table: ${tableName}`);
1163
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1119
1164
  }
1120
1165
  aggregateSqlFunction(aggOptions.op);
1121
1166
  if (aggOptions.op === "count") {
@@ -1127,13 +1172,14 @@ const createShardCtxDb = (options) => {
1127
1172
  });
1128
1173
  }
1129
1174
  if (!aggOptions.field) {
1130
- throw new Error(`aggregate(${tableName}, { op: "${aggOptions.op}" }): "field" is required for non-count reducers`);
1175
+ throw new LunoraError("INTERNAL", `aggregate(${tableName}, { op: "${aggOptions.op}" }): "field" is required for non-count reducers`);
1131
1176
  }
1132
1177
  onRead(tableName, SCAN_DEP);
1133
- const effective = mergeWhere(aggOptions.baseWhere, aggOptions.where);
1178
+ const aggScope = softDeleteScope(definition.softDeleteMode, void 0);
1179
+ const effective = mergeWhere(mergeWhere(aggOptions.baseWhere, aggOptions.where), aggScope);
1134
1180
  const resolved = await resolveAggregateRelations(effective, tableName, aggOptions.relationBaseWhere);
1135
1181
  const hasRelation = resolved !== effective;
1136
- if (definition.aggregateIndexes && !aggOptions.baseWhere && !hasRelation) {
1182
+ if (definition.aggregateIndexes && !aggOptions.baseWhere && !hasRelation && !aggScope) {
1137
1183
  const planned = selectIndexForAggregate(definition.aggregateIndexes, aggOptions.op, aggOptions.field, aggOptions.where);
1138
1184
  if (planned) {
1139
1185
  ensureBackfilled(tableName, planned.index);
@@ -1168,17 +1214,18 @@ const createShardCtxDb = (options) => {
1168
1214
  }
1169
1215
  const definition = schema.tables[tableName];
1170
1216
  if (!definition) {
1171
- throw new Error(`unknown table: ${tableName}`);
1217
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1172
1218
  }
1173
1219
  const countOptions = normalizeCountArgument(whereOrOptions);
1174
1220
  if (countOptions.restrictsCounts) {
1175
1221
  throw new CountRlsUnsupportedError(tableName);
1176
1222
  }
1177
1223
  onRead(tableName, SCAN_DEP);
1178
- const effective = mergeWhere(countOptions.baseWhere, countOptions.where);
1224
+ const countScope = softDeleteScope(definition.softDeleteMode, void 0);
1225
+ const effective = mergeWhere(mergeWhere(countOptions.baseWhere, countOptions.where), countScope);
1179
1226
  const resolved = await resolveAggregateRelations(effective, tableName, countOptions.relationBaseWhere);
1180
1227
  const hasRelation = resolved !== effective;
1181
- if (definition.aggregateIndexes && !countOptions.baseWhere && !hasRelation) {
1228
+ if (definition.aggregateIndexes && !countOptions.baseWhere && !hasRelation && !countScope) {
1182
1229
  const planned = selectIndexForCount(definition.aggregateIndexes, countOptions.where);
1183
1230
  if (planned) {
1184
1231
  ensureBackfilled(tableName, planned.index);
@@ -1199,16 +1246,22 @@ const createShardCtxDb = (options) => {
1199
1246
  const row = runDrizzle(sql$1, query).one();
1200
1247
  return row.count;
1201
1248
  },
1202
- async delete(id, expectedTable) {
1249
+ async delete(id, expectedTable, deleteOptions) {
1203
1250
  const located = locateRowById(id, expectedTable);
1204
1251
  if (!located) {
1205
1252
  const global = expectedTable === void 0 ? globalFallback() : void 0;
1206
1253
  if (global) {
1207
- await global.delete(id);
1254
+ await global.delete(id, void 0, deleteOptions);
1208
1255
  }
1209
1256
  return;
1210
1257
  }
1211
1258
  const { docJson: existingJson, row: existing, tableName } = located;
1259
+ const definition = schema.tables[tableName];
1260
+ const hard = deleteOptions?.hard === true;
1261
+ const softField = !hard && definition?.softDeleteMode ? definition.softDeleteMode.field : void 0;
1262
+ if (softField && existing[softField] !== null && existing[softField] !== void 0) {
1263
+ return;
1264
+ }
1212
1265
  if (hasMatchingTrigger(tableName, "before", "delete")) {
1213
1266
  await fireTriggers("before", "delete", { id, op: "delete", previous: existing, table: tableName });
1214
1267
  }
@@ -1216,10 +1269,10 @@ const createShardCtxDb = (options) => {
1216
1269
  deletedId: id,
1217
1270
  deletedReference: (references) => existing[references],
1218
1271
  findHolders: async (holderTable, field, value) => {
1219
- const holders = await routeForHolder(holderTable).findMany(holderTable, { where: { [field]: value } });
1272
+ const holders = await routeForHolder(holderTable).findMany(holderTable, { includeDeleted: hard, where: { [field]: value } });
1220
1273
  return holders.page;
1221
1274
  },
1222
- onCascade: (holderTable, holderId) => routeForHolder(holderTable).delete(holderId),
1275
+ onCascade: (holderTable, holderId) => routeForHolder(holderTable).delete(holderId, void 0, deleteOptions),
1223
1276
  onRestrict: (message) => {
1224
1277
  throw new ConflictError(message, "restrict");
1225
1278
  },
@@ -1230,6 +1283,25 @@ const createShardCtxDb = (options) => {
1230
1283
  });
1231
1284
  ensureBackfilledForTable(tableName);
1232
1285
  ensureRankBackfilledForTable(tableName);
1286
+ if (softField) {
1287
+ const merged = { ...existing, [softField]: clock(), _id: id };
1288
+ runGuardedWrite(
1289
+ sql$1,
1290
+ tableName,
1291
+ sql`UPDATE ${sql.identifier(tableName)} SET ${sql.identifier(DOC_COLUMN$1)} = ${JSON.stringify(merged)} WHERE id = ${id} AND ${sql.identifier(DOC_COLUMN$1)} = ${existingJson}`
1292
+ );
1293
+ syncSearch(tableName, id, merged);
1294
+ syncAggregates(tableName, existing, merged);
1295
+ syncRanks(tableName, id, existing, void 0);
1296
+ cache?.invalidate(tableName, id);
1297
+ recordCdc(tableName, id, "update", merged);
1298
+ broadcast({ key: id, op: "update", row: merged, table: tableName });
1299
+ if (hasMatchingTrigger(tableName, "after", "delete")) {
1300
+ await fireTriggers("after", "delete", { id, op: "delete", previous: existing, table: tableName });
1301
+ }
1302
+ await onWrite({ id, op: "delete", table: tableName });
1303
+ return;
1304
+ }
1233
1305
  runGuardedWrite(
1234
1306
  sql$1,
1235
1307
  tableName,
@@ -1253,6 +1325,25 @@ const createShardCtxDb = (options) => {
1253
1325
  }
1254
1326
  return { deleted: ids.length };
1255
1327
  },
1328
+ async deleteWhere(tableName, where, batchOptions) {
1329
+ const global = globalWriterFor(tableName, "deleteWhere");
1330
+ let ids;
1331
+ if (global) {
1332
+ const rows = await global.findMany(tableName, { where });
1333
+ ids = rows.page.map((row) => String(row["_id"]));
1334
+ } else {
1335
+ if (!schema.tables[tableName]) {
1336
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1337
+ }
1338
+ const page = await writer.findMany(tableName, { where });
1339
+ ids = page.page.map((row) => String(row["_id"]));
1340
+ }
1341
+ assertBatchLimit(ids.length, batchOptions?.limit, "deleteWhere");
1342
+ if (writer.deleteMany === void 0) {
1343
+ throw new LunoraError("INTERNAL", `ctx.db.${tableName}.deleteMany is unavailable: this writer has no batch delete`);
1344
+ }
1345
+ return writer.deleteMany(ids, batchOptions);
1346
+ },
1256
1347
  async findFirst(tableName, args = {}) {
1257
1348
  const result = await writer.findMany(tableName, { ...args, limit: 1 });
1258
1349
  return result.page[0] ?? null;
@@ -1271,8 +1362,9 @@ const createShardCtxDb = (options) => {
1271
1362
  onRead(tableName, SCAN_DEP);
1272
1363
  return global.findMany(tableName, args);
1273
1364
  }
1274
- if (!schema.tables[tableName]) {
1275
- throw new Error(`unknown table: ${tableName}`);
1365
+ const findManyDefinition = schema.tables[tableName];
1366
+ if (!findManyDefinition) {
1367
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1276
1368
  }
1277
1369
  const isFullScan = !args.where && !args.baseWhere;
1278
1370
  if (isFullScan) {
@@ -1283,6 +1375,7 @@ const createShardCtxDb = (options) => {
1283
1375
  const orderKeys = normalizeOrderKeys(args.orderBy);
1284
1376
  const seek = args.cursor ? buildSeekWhere(orderKeys, decodeCursor(args.cursor)) : void 0;
1285
1377
  let predicate = mergeWhere(args.baseWhere, args.where);
1378
+ predicate = mergeWhere(predicate, softDeleteScope(findManyDefinition.softDeleteMode, args.includeDeleted));
1286
1379
  predicate = await resolveRelationPredicates(predicate, {
1287
1380
  canPushExists: relationExistsPushDownEnabled ? canPushRelationExists : void 0,
1288
1381
  existsPushMode: relationExistsPushDown === "always" ? "always" : "auto",
@@ -1320,7 +1413,7 @@ const createShardCtxDb = (options) => {
1320
1413
  if (limit === void 0) {
1321
1414
  if (args.with) {
1322
1415
  await resolveWith({
1323
- counter: relationCounter,
1416
+ groupedCounter: relationGroupedCounter,
1324
1417
  fetcher: relationFetcher,
1325
1418
  parents: docs,
1326
1419
  relationBaseWhere: args.relationBaseWhere,
@@ -1329,15 +1422,15 @@ const createShardCtxDb = (options) => {
1329
1422
  with: args.with
1330
1423
  });
1331
1424
  }
1332
- return { continueCursor: null, isDone: true, page: docs };
1425
+ return { continueCursor: null, isDone: true, page: applySelect(docs, args.select, args.with) };
1333
1426
  }
1334
1427
  const hasMore = docs.length > limit;
1335
1428
  const page = hasMore ? docs.slice(0, limit) : docs;
1336
1429
  const last = page.at(-1);
1337
1430
  if (args.with) {
1338
1431
  await resolveWith({
1339
- counter: relationCounter,
1340
1432
  fetcher: relationFetcher,
1433
+ groupedCounter: relationGroupedCounter,
1341
1434
  parents: page,
1342
1435
  relationBaseWhere: args.relationBaseWhere,
1343
1436
  schema,
@@ -1346,10 +1439,12 @@ const createShardCtxDb = (options) => {
1346
1439
  });
1347
1440
  }
1348
1441
  return {
1442
+ // The cursor is encoded from `last` (the full, unprojected row) above,
1443
+ // so `applySelect` only trims the returned payload — paging is intact.
1349
1444
  // eslint-disable-next-line unicorn/no-null -- QueryPage.continueCursor is `null | string`: null is the documented "no further page" cursor on the wire
1350
1445
  continueCursor: hasMore && last ? encodeCursor(last, orderKeys) : null,
1351
1446
  isDone: !hasMore,
1352
- page
1447
+ page: applySelect(page, args.select, args.with)
1353
1448
  };
1354
1449
  },
1355
1450
  async get(id, expectedTable) {
@@ -1382,18 +1477,19 @@ const createShardCtxDb = (options) => {
1382
1477
  }
1383
1478
  const definition = schema.tables[tableName];
1384
1479
  if (!definition) {
1385
- throw new Error(`unknown table: ${tableName}`);
1480
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1386
1481
  }
1387
1482
  onRead(tableName, SCAN_DEP);
1388
1483
  const agg = groupOptions.agg ?? { op: "count" };
1389
1484
  aggregateSqlFunction(agg.op);
1390
1485
  if (agg.op !== "count" && !agg.field) {
1391
- throw new Error(`groupBy(${tableName}, { agg: { op: "${agg.op}" } }): "field" is required for non-count reducers`);
1486
+ throw new LunoraError("INTERNAL", `groupBy(${tableName}, { agg: { op: "${agg.op}" } }): "field" is required for non-count reducers`);
1392
1487
  }
1393
- const effective = mergeWhere(groupOptions.baseWhere, groupOptions.where);
1488
+ const groupScope = softDeleteScope(definition.softDeleteMode, void 0);
1489
+ const effective = mergeWhere(mergeWhere(groupOptions.baseWhere, groupOptions.where), groupScope);
1394
1490
  const resolved = await resolveAggregateRelations(effective, tableName, groupOptions.relationBaseWhere);
1395
1491
  const hasRelation = resolved !== effective;
1396
- if (definition.aggregateIndexes && !groupOptions.baseWhere && !hasRelation) {
1492
+ if (definition.aggregateIndexes && !groupOptions.baseWhere && !hasRelation && !groupScope) {
1397
1493
  const planned = selectIndexForGroupBy(definition.aggregateIndexes, agg.op, agg.field, groupOptions.by, groupOptions.where);
1398
1494
  if (planned) {
1399
1495
  ensureBackfilled(tableName, planned.index);
@@ -1432,7 +1528,7 @@ const createShardCtxDb = (options) => {
1432
1528
  } else {
1433
1529
  const { field } = agg;
1434
1530
  if (field === void 0) {
1435
- throw new Error(`groupBy(${tableName}, { agg: { op: "${agg.op}" } }): "field" is required for non-count reducers`);
1531
+ throw new LunoraError("INTERNAL", `groupBy(${tableName}, { agg: { op: "${agg.op}" } }): "field" is required for non-count reducers`);
1436
1532
  }
1437
1533
  select.push(sql`${sql.raw(aggregateSqlFunction(agg.op))}(${jsonPathSql(field)}) AS value`);
1438
1534
  }
@@ -1465,7 +1561,7 @@ const createShardCtxDb = (options) => {
1465
1561
  }
1466
1562
  const definition = schema.tables[tableName];
1467
1563
  if (!definition) {
1468
- throw new Error(`unknown table: ${tableName}`);
1564
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1469
1565
  }
1470
1566
  const withDefaults = applyInsertDefaults(definition, document, auth);
1471
1567
  runRowValidators(definition, withDefaults);
@@ -1478,7 +1574,7 @@ const createShardCtxDb = (options) => {
1478
1574
  } else {
1479
1575
  id = generateId();
1480
1576
  }
1481
- const creationTime = typeof withDefaults["_creationTime"] === "number" ? withDefaults["_creationTime"] : clock();
1577
+ const creationTime = insertOptions?.allowExplicitId && typeof withDefaults["_creationTime"] === "number" ? withDefaults["_creationTime"] : clock();
1482
1578
  const documentWithMeta = { ...withDefaults, _creationTime: creationTime, _id: id };
1483
1579
  if (hasMatchingTrigger(tableName, "before", "insert")) {
1484
1580
  await fireTriggers("before", "insert", { doc: { ...documentWithMeta }, id, op: "insert", table: tableName });
@@ -1514,14 +1610,14 @@ const createShardCtxDb = (options) => {
1514
1610
  }
1515
1611
  const definition = schema.tables[tableName];
1516
1612
  if (!definition) {
1517
- throw new Error(`unknown table: ${tableName}`);
1613
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1518
1614
  }
1519
1615
  ensureBackfilledForTable(tableName);
1520
1616
  ensureRankBackfilledForTable(tableName);
1521
1617
  const rows = documents.map((document) => {
1522
1618
  const withDefaults = applyInsertDefaults(definition, document, auth);
1523
1619
  const id = batchOptions?.allowExplicitId === true && typeof withDefaults["_id"] === "string" ? withDefaults["_id"] : generateId();
1524
- const creationTime = typeof withDefaults["_creationTime"] === "number" ? withDefaults["_creationTime"] : clock();
1620
+ const creationTime = batchOptions?.allowExplicitId === true && typeof withDefaults["_creationTime"] === "number" ? withDefaults["_creationTime"] : clock();
1525
1621
  return { creationTime, document: { ...withDefaults, _creationTime: creationTime, _id: id }, id };
1526
1622
  });
1527
1623
  const valuesSql = sql.join(
@@ -1537,9 +1633,18 @@ const createShardCtxDb = (options) => {
1537
1633
  },
1538
1634
  async insertMany(tableName, documents, batchOptions) {
1539
1635
  assertBatchLimit(documents.length, batchOptions?.limit, "insertMany");
1636
+ const skipDuplicates = batchOptions?.skipDuplicates === true;
1540
1637
  const ids = [];
1541
1638
  for (const document of documents) {
1542
- ids.push(await writer.insert(tableName, document));
1639
+ try {
1640
+ ids.push(await writer.insert(tableName, document));
1641
+ } catch (error) {
1642
+ if (skipDuplicates && error instanceof ConflictError && error.kind === "unique") {
1643
+ ids.push(null);
1644
+ } else {
1645
+ throw error;
1646
+ }
1647
+ }
1543
1648
  }
1544
1649
  return ids;
1545
1650
  },
@@ -1554,12 +1659,12 @@ const createShardCtxDb = (options) => {
1554
1659
  await global.patch(id, patch);
1555
1660
  return;
1556
1661
  }
1557
- throw new Error(`document not found: ${id}`);
1662
+ throw new LunoraError("INTERNAL", `document not found: ${id}`);
1558
1663
  }
1559
1664
  const { docJson: existingJson, row: existing, tableName } = located;
1560
1665
  const tableDefinition = schema.tables[tableName];
1561
1666
  if (!tableDefinition) {
1562
- throw new Error(`unknown table: ${tableName}`);
1667
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1563
1668
  }
1564
1669
  onRead(tableName, id);
1565
1670
  assertNoExplicitUndefined("patch", patch);
@@ -1592,6 +1697,31 @@ const createShardCtxDb = (options) => {
1592
1697
  for (const entry of patches) {
1593
1698
  await writer.patch(entry.id, entry.patch, expectedTable);
1594
1699
  }
1700
+ return { patched: patches.length };
1701
+ },
1702
+ async patchWhere(tableName, args, batchOptions) {
1703
+ const global = globalWriterFor(tableName, "patchWhere");
1704
+ let patches;
1705
+ if (global) {
1706
+ const rows = await global.findMany(tableName, { where: args.where });
1707
+ patches = rows.page.map((row) => {
1708
+ return { id: String(row["_id"]), patch: args.patch };
1709
+ });
1710
+ } else {
1711
+ if (!schema.tables[tableName]) {
1712
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1713
+ }
1714
+ const page = await writer.findMany(tableName, { where: args.where });
1715
+ patches = page.page.map((row) => {
1716
+ return { id: String(row["_id"]), patch: args.patch };
1717
+ });
1718
+ }
1719
+ assertBatchLimit(patches.length, batchOptions?.limit, "patchWhere");
1720
+ if (writer.patchMany === void 0) {
1721
+ throw new LunoraError("INTERNAL", `ctx.db.${tableName}.patchMany is unavailable: this writer has no batch patch`);
1722
+ }
1723
+ await writer.patchMany(patches, batchOptions);
1724
+ return { patched: patches.length };
1595
1725
  },
1596
1726
  query(tableName) {
1597
1727
  const global = globalWriterFor(tableName, "query");
@@ -1611,11 +1741,11 @@ const createShardCtxDb = (options) => {
1611
1741
  onIndexUse(tableName, indexName, "rank");
1612
1742
  const definition = schema.tables[tableName];
1613
1743
  if (!definition) {
1614
- throw new Error(`unknown table: ${tableName}`);
1744
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1615
1745
  }
1616
1746
  const index = definition.rankIndexes?.find((i) => i.name === indexName);
1617
1747
  if (!index) {
1618
- throw new Error(`unknown rankIndex "${indexName}" on table "${tableName}"`);
1748
+ throw new LunoraError("INTERNAL", `unknown rankIndex "${indexName}" on table "${tableName}"`);
1619
1749
  }
1620
1750
  assertRankPartitionLocal(tableName, definition, index);
1621
1751
  if (rankOptions.restrictsCounts) {
@@ -1656,17 +1786,18 @@ const createShardCtxDb = (options) => {
1656
1786
  // eslint-disable-next-line @typescript-eslint/require-await -- DatabaseWriterLike returns Promises (the D1 twin awaits I/O); the body is synchronous SQLite
1657
1787
  async rankBefore(tableName, indexName, rankBeforeOptions) {
1658
1788
  if (isGlobalTable(tableName)) {
1659
- throw new Error(
1789
+ throw new LunoraError(
1790
+ "INTERNAL",
1660
1791
  `rankBefore is not supported on the global (.global()) table '${tableName}' — cross-shard rank cursors apply only to sharded tables`
1661
1792
  );
1662
1793
  }
1663
1794
  const definition = schema.tables[tableName];
1664
1795
  if (!definition) {
1665
- throw new Error(`unknown table: ${tableName}`);
1796
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1666
1797
  }
1667
1798
  const index = definition.rankIndexes?.find((i) => i.name === indexName);
1668
1799
  if (!index) {
1669
- throw new Error(`unknown rankIndex "${indexName}" on table "${tableName}"`);
1800
+ throw new LunoraError("INTERNAL", `unknown rankIndex "${indexName}" on table "${tableName}"`);
1670
1801
  }
1671
1802
  if (rankBeforeOptions.restrictsCounts) {
1672
1803
  throw new CountRlsUnsupportedError(tableName);
@@ -1696,23 +1827,43 @@ const createShardCtxDb = (options) => {
1696
1827
  const { directions, hasMore, rows } = computeRankPage(rankPageDeps, tableName, indexName, rankPageOptions);
1697
1828
  return { directions, hasMore, rows };
1698
1829
  },
1699
- async replace(id, document, expectedTable) {
1830
+ async restore(id, expectedTable) {
1831
+ const located = locateRowById(id, expectedTable);
1832
+ if (!located) {
1833
+ const global = expectedTable === void 0 ? globalFallback() : void 0;
1834
+ if (global?.restore) {
1835
+ await global.restore(id);
1836
+ return;
1837
+ }
1838
+ throw new LunoraError("INTERNAL", `document not found: ${id}`);
1839
+ }
1840
+ const field = schema.tables[located.tableName]?.softDeleteMode?.field;
1841
+ if (!field) {
1842
+ throw new LunoraError("INTERNAL", `ctx.db.restore: table "${located.tableName}" is not a .softDelete() table`);
1843
+ }
1844
+ const wasDeleted = located.row[field] !== null && located.row[field] !== void 0;
1845
+ await writer.patch(id, { [field]: null }, expectedTable);
1846
+ if (wasDeleted) {
1847
+ syncRanks(located.tableName, id, void 0, located.row);
1848
+ }
1849
+ },
1850
+ async replace(id, document, expectedTable, replaceOptions) {
1700
1851
  const located = locateRowById(id, expectedTable);
1701
1852
  if (!located) {
1702
1853
  const global = expectedTable === void 0 ? globalFallback() : void 0;
1703
1854
  if (global) {
1704
- await global.replace(id, document);
1855
+ await global.replace(id, document, void 0, replaceOptions);
1705
1856
  return;
1706
1857
  }
1707
- throw new Error(`document not found: ${id}`);
1858
+ throw new LunoraError("INTERNAL", `document not found: ${id}`);
1708
1859
  }
1709
1860
  const { docJson: existingJson, row: previous, tableName } = located;
1710
1861
  const tableDefinition = schema.tables[tableName];
1711
1862
  if (!tableDefinition) {
1712
- throw new Error(`unknown table: ${tableName}`);
1863
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1713
1864
  }
1714
1865
  assertNoExplicitUndefined("replace", document);
1715
- const creationTime = typeof document["_creationTime"] === "number" ? document["_creationTime"] : clock();
1866
+ const creationTime = replaceOptions?.allowExplicitId && typeof document["_creationTime"] === "number" ? document["_creationTime"] : clock();
1716
1867
  const replaced = { ...document, _creationTime: creationTime, _id: id };
1717
1868
  applyOnUpdate(tableDefinition, document, replaced, auth);
1718
1869
  runRowValidators(tableDefinition, replaced);
@@ -1742,4 +1893,4 @@ const createShardCtxDb = (options) => {
1742
1893
  return options.enforceRls === true ? guardWriter(writer, schema, (id, expectedTable) => locateRowById(id, expectedTable)?.tableName) : writer;
1743
1894
  };
1744
1895
 
1745
- export { NotUniqueError, appendCdcChange, assertValidClientId, createShardCtxDb, normalizeIdStructurally };
1896
+ export { NotUniqueError, assertValidClientId, createShardCtxDb, normalizeIdStructurally };