@lunora/sql-store 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.
@@ -1,6 +1,17 @@
1
- import { aggregateTableName, rankTableName, ftsTableName, hasTrigger, runRowValidators, assertFlatPredicate, mergeWhere, sortColumnName, resolveRankPartition, encodePartitionKey, decodeCursor, RANK_TIEBREAK, CountRlsUnsupportedError, normalizeIdStructurally, assertValidClientId, aggregateSqlFunction, compileWhereSql, normalizeOrderKeys, buildSeekWhere, resolveRelationPredicates, resolveWith, encodeCursor, NotFoundError, applyOnDelete, ConflictError, normalizeCountArgument, selectIndexForCount, encodeAggregateKey, selectIndexForAggregate, readAggregateValue, renderSql, runTriggers, matchesRankStaticWhere, stringifySearchText, selectIndexForGroupBy, matchesStaticWhere, coerceAggregateNumber, foldAggregateTally, NotUniqueError, throwingScheduler, tokenizeSearch, buildFtsMatch, scoreDocument } from '@lunora/do';
1
+ import { aggregateTableName, rankTableName, ftsTableName, hasTrigger, runRowValidators, assertFlatPredicate, mergeWhere, sortColumnName, resolveRankPartition, encodePartitionKey, decodeCursor, RANK_TIEBREAK, CountRlsUnsupportedError, normalizeIdStructurally, assertValidClientId, aggregateSqlFunction, softDeleteScope, compileWhereSql, normalizeOrderKeys, buildSeekWhere, resolveRelationPredicates, resolveWith, applySelect, encodeCursor, NotFoundError, applyOnDelete, ConflictError, normalizeCountArgument, selectIndexForCount, encodeAggregateKey, selectIndexForAggregate, readAggregateValue, renderSql, runTriggers, matchesRankStaticWhere, stringifySearchText, selectIndexForGroupBy, fanOutScalarCounts, matchesStaticWhere, coerceAggregateNumber, foldAggregateTally, NotUniqueError, throwingScheduler, tokenizeSearch, buildFtsMatch, scoreDocument } from '@lunora/do';
2
+ import { LunoraError } from '@lunora/errors';
2
3
  import { sql } from 'drizzle-orm';
3
- import { sqliteDecode, effectiveColumnKind, sqliteEncode } from './decodeBigint-Dedu92k4.mjs';
4
+ import { sqliteDecode, sqliteEncode, effectiveColumnKind } from './decodeBigint-Dedu92k4.mjs';
5
+
6
+ const evictOldestEntry = (map, capacity) => {
7
+ if (map.size < capacity) {
8
+ return;
9
+ }
10
+ const oldest = map.keys().next().value;
11
+ if (oldest !== void 0) {
12
+ map.delete(oldest);
13
+ }
14
+ };
4
15
 
5
16
  const physicalColumn = (field) => field === "_id" || field === "id" ? "id" : field;
6
17
  const columnRefSql = (field) => sql`${sql.identifier(physicalColumn(field))}`;
@@ -79,14 +90,23 @@ const tableColumns = (definition) => {
79
90
  }
80
91
  return columns;
81
92
  };
93
+ const columnKindCache = /* @__PURE__ */ new WeakMap();
94
+ const columnKinds = (definition) => {
95
+ let kinds = columnKindCache.get(definition);
96
+ if (kinds === void 0) {
97
+ kinds = Object.entries(definition.shape).map(([field, validator]) => [field, effectiveColumnKind(validator)]);
98
+ columnKindCache.set(definition, kinds);
99
+ }
100
+ return kinds;
101
+ };
82
102
  const decodeGlobalRow = (definition, row) => {
83
103
  const decoded = {};
84
- for (const [field, validator] of Object.entries(definition.shape)) {
104
+ for (const [field, kind] of columnKinds(definition)) {
85
105
  const raw = row[field];
86
106
  if (raw === void 0) {
87
107
  continue;
88
108
  }
89
- decoded[field] = sqliteDecode(raw, effectiveColumnKind(validator));
109
+ decoded[field] = sqliteDecode(raw, kind);
90
110
  }
91
111
  decoded["_id"] = row["id"];
92
112
  decoded["_creationTime"] = row["_creationTime"];
@@ -148,11 +168,8 @@ const createTableNameCache = () => {
148
168
  set: (id, table) => {
149
169
  if (map.has(id)) {
150
170
  map.delete(id);
151
- } else if (map.size >= TABLE_NAME_CACHE_CAPACITY) {
152
- const oldest = map.keys().next().value;
153
- if (oldest !== void 0) {
154
- map.delete(oldest);
155
- }
171
+ } else {
172
+ evictOldestEntry(map, TABLE_NAME_CACHE_CAPACITY);
156
173
  }
157
174
  map.set(id, table);
158
175
  }
@@ -212,6 +229,9 @@ const searchViaFts = async (exec, dialect, definition, tableName, search, limit)
212
229
  for (const filter of search.filters) {
213
230
  conditions.push(sql`m.${columnRefSql(filter.field)} = ${serializeColumnValue(filter.value)}`);
214
231
  }
232
+ if (definition.softDeleteMode) {
233
+ conditions.push(sql`m.${columnRefSql(definition.softDeleteMode.field)} IS NULL`);
234
+ }
215
235
  let query = sql`SELECT m.* FROM ${sql.identifier(ftName)} f JOIN ${sql.identifier(tableName)} m ON m.${sql.identifier("id")} = f.${sql.identifier("__id__")} WHERE ${sql.join(conditions, sql` AND `)} ORDER BY f.rank, m.${sql.identifier("_creationTime")} DESC`;
216
236
  if (typeof limit === "number") {
217
237
  query = sql`${query} LIMIT ${sql.raw(String(Math.max(0, Math.floor(limit))))}`;
@@ -225,6 +245,9 @@ const searchViaScan = async (exec, dialect, definition, tableName, search, limit
225
245
  return [];
226
246
  }
227
247
  const conditions = search.filters.map((filter) => sql`${columnRefSql(filter.field)} = ${serializeColumnValue(filter.value)}`);
248
+ if (definition.softDeleteMode) {
249
+ conditions.push(sql`${columnRefSql(definition.softDeleteMode.field)} IS NULL`);
250
+ }
228
251
  let query = sql`SELECT * FROM ${sql.identifier(tableName)}`;
229
252
  if (conditions.length > 0) {
230
253
  query = sql`${query} WHERE ${sql.join(conditions, sql` AND `)}`;
@@ -245,14 +268,17 @@ const createSearchBuilder = (search, tableName) => {
245
268
  const builder = {
246
269
  eq: (field, value) => {
247
270
  if (!search.definition.filterFields?.includes(field)) {
248
- throw new Error(`field "${field}" is not a filter field of search index "${search.indexName}" on table "${tableName}"`);
271
+ throw new LunoraError("INTERNAL", `field "${field}" is not a filter field of search index "${search.indexName}" on table "${tableName}"`);
249
272
  }
250
273
  search.filters.push({ field, value });
251
274
  return builder;
252
275
  },
253
276
  search: (field, query) => {
254
277
  if (field !== search.definition.field) {
255
- throw new Error(`search index "${search.indexName}" on table "${tableName}" indexes "${search.definition.field}", not "${field}"`);
278
+ throw new LunoraError(
279
+ "INTERNAL",
280
+ `search index "${search.indexName}" on table "${tableName}" indexes "${search.definition.field}", not "${field}"`
281
+ );
256
282
  }
257
283
  const stage = search;
258
284
  stage.field = field;
@@ -263,6 +289,10 @@ const createSearchBuilder = (search, tableName) => {
263
289
  };
264
290
  return builder;
265
291
  };
292
+ const rankIndexFieldsUnchanged = (index, previous, next) => {
293
+ const fields = [...index.partitionBy ?? [], ...index.sortBy.map((key) => key.field), ...index.where ? Object.keys(index.where) : []];
294
+ return fields.every((field) => previous[field] === next[field]);
295
+ };
266
296
  const andBranch = (conditions) => conditions.length === 1 ? conditions[0] : sql`(${sql.join(conditions, sql` AND `)})`;
267
297
  const buildRankBeforeBranches = (engine, index, sortColumns, own, rowId) => {
268
298
  const branches = [];
@@ -300,7 +330,7 @@ const buildRankCursorSeek = (engine, columns, decoded) => {
300
330
  };
301
331
  const rankPageColumns = (index, sortColumns) => {
302
332
  if (index.sortBy.length === 0) {
303
- throw new Error(`rankIndex "${index.name}" requires at least one "sortBy" column for stable pagination`);
333
+ throw new LunoraError("INTERNAL", `rankIndex "${index.name}" requires at least one "sortBy" column for stable pagination`);
304
334
  }
305
335
  const columns = [{ column: "__partition__", direction: "asc" }];
306
336
  for (const [i, sortKey] of index.sortBy.entries()) {
@@ -562,7 +592,12 @@ const createSqlCtxDb = (options) => {
562
592
  fieldRef: columnRefSql,
563
593
  serialize: serializeColumnValue,
564
594
  // MySQL has no `||` string concat; the rest use the portable form compileWhereSql defaults to.
565
- ...dialect.name === "mysql" ? { likeContains: (reference, term) => sql`${reference} LIKE CONCAT('%', ${term}, '%')` } : {}
595
+ // The term is wildcard-escaped by compileContains, so pair it with a backslash `ESCAPE` for literal
596
+ // matching. MySQL treats backslash as a string-literal escape, so the SQL text must contain a DOUBLED
597
+ // backslash (`ESCAPE '\\'`) to denote one literal backslash; drizzle's `sql` tag uses the COOKED
598
+ // template string, so `'\\\\'` here renders `'\\'` in the SQL (a single `'\'` would escape the closing
599
+ // quote and raise a syntax error). SQLite/Postgres take backslash literally and use the portable default.
600
+ ...dialect.name === "mysql" ? { likeContains: (reference, term) => sql`${reference} LIKE CONCAT('%', ${term}, '%') ESCAPE '\\\\'` } : {}
566
601
  };
567
602
  const nullSafeEquals = (reference, value) => nullSafeEqualsSql(dialect.name, reference, value);
568
603
  const upsertSql = (config) => {
@@ -914,11 +949,18 @@ const createSqlCtxDb = (options) => {
914
949
  }
915
950
  };
916
951
  const syncRanks = async (tableName, id, previous, next) => {
917
- const indexes = schema.tables[tableName]?.rankIndexes;
952
+ const definition = schema.tables[tableName];
953
+ const indexes = definition?.rankIndexes;
918
954
  if (!indexes || indexes.length === 0) {
919
955
  return;
920
956
  }
957
+ const softField = definition?.softDeleteMode?.field;
958
+ const nextFieldValue = softField === void 0 || next === void 0 ? void 0 : next[softField];
959
+ const nextSoftDeleted = nextFieldValue !== null && nextFieldValue !== void 0;
921
960
  for (const index of indexes) {
961
+ if (previous !== void 0 && next !== void 0 && rankIndexFieldsUnchanged(index, previous, next)) {
962
+ continue;
963
+ }
922
964
  const cacheKey = `${tableName}::rank::${index.name}`;
923
965
  const cached = rankBackfilled.get(cacheKey);
924
966
  const exists = cached ?? await rankTableExists(tableName, index.name);
@@ -929,7 +971,7 @@ const createSqlCtxDb = (options) => {
929
971
  if (previous) {
930
972
  await queryRun(exec, dialect, sql`DELETE FROM ${sql.identifier(rankTable)} WHERE ${sql.identifier("__id__")} = ${id}`);
931
973
  }
932
- if (next) {
974
+ if (next && !nextSoftDeleted) {
933
975
  if (index.where && !matchesRankStaticWhere(next, index.where)) {
934
976
  continue;
935
977
  }
@@ -1067,6 +1109,9 @@ const createSqlCtxDb = (options) => {
1067
1109
  const row = rowsIndexed2[0];
1068
1110
  return [{ key: { ...planned.partial }, value: readAggregateValue(agg.op, { count: row.count, value: aggregateScalar(row.value) }) }];
1069
1111
  }
1112
+ if (partialKeys.length > 0) {
1113
+ return void 0;
1114
+ }
1070
1115
  const rowsIndexed = await queryAll(
1071
1116
  exec,
1072
1117
  dialect,
@@ -1085,7 +1130,8 @@ const createSqlCtxDb = (options) => {
1085
1130
  return kind === "shardBy" || kind === "root";
1086
1131
  };
1087
1132
  const crossBackendUnsupported = (childTable) => {
1088
- throw new Error(
1133
+ throw new LunoraError(
1134
+ "INTERNAL",
1089
1135
  `cross-backend relation: a global table cannot load the shard-local relation '${childTable}' (it spans every shard) — wire a cross-shard reader to support it`
1090
1136
  );
1091
1137
  };
@@ -1107,7 +1153,7 @@ const createSqlCtxDb = (options) => {
1107
1153
  async aggregate(tableName, aggOptions) {
1108
1154
  const definition = schema.tables[tableName];
1109
1155
  if (!definition) {
1110
- throw new Error(`unknown table: ${tableName}`);
1156
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1111
1157
  }
1112
1158
  await ensureMigrated();
1113
1159
  aggregateSqlFunction(aggOptions.op);
@@ -1120,12 +1166,13 @@ const createSqlCtxDb = (options) => {
1120
1166
  });
1121
1167
  }
1122
1168
  if (!aggOptions.field) {
1123
- throw new Error(`aggregate(${tableName}, { op: "${aggOptions.op}" }): "field" is required for non-count reducers`);
1169
+ throw new LunoraError("INTERNAL", `aggregate(${tableName}, { op: "${aggOptions.op}" }): "field" is required for non-count reducers`);
1124
1170
  }
1125
- const effective = mergeWhere(aggOptions.baseWhere, aggOptions.where);
1171
+ const aggScope = softDeleteScope(definition.softDeleteMode, void 0);
1172
+ const effective = mergeWhere(mergeWhere(aggOptions.baseWhere, aggOptions.where), aggScope);
1126
1173
  const resolved = await resolveAggregateRelations(effective, tableName, aggOptions.relationBaseWhere);
1127
1174
  const hasRelation = resolved !== effective;
1128
- if (definition.aggregateIndexes && !aggOptions.baseWhere && !hasRelation) {
1175
+ if (definition.aggregateIndexes && !aggOptions.baseWhere && !hasRelation && !aggScope) {
1129
1176
  const planned = selectIndexForAggregate(definition.aggregateIndexes, aggOptions.op, aggOptions.field, aggOptions.where);
1130
1177
  if (planned) {
1131
1178
  const counterReady = await ensureBackfilled(tableName, planned.index);
@@ -1152,17 +1199,18 @@ const createSqlCtxDb = (options) => {
1152
1199
  async count(tableName, whereOrOptions) {
1153
1200
  const definition = schema.tables[tableName];
1154
1201
  if (!definition) {
1155
- throw new Error(`unknown table: ${tableName}`);
1202
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1156
1203
  }
1157
1204
  await ensureMigrated();
1158
1205
  const countOptions = normalizeCountArgument(whereOrOptions);
1159
1206
  if (countOptions.restrictsCounts) {
1160
1207
  throw new CountRlsUnsupportedError(tableName);
1161
1208
  }
1162
- const effective = mergeWhere(countOptions.baseWhere, countOptions.where);
1209
+ const countScope = softDeleteScope(definition.softDeleteMode, void 0);
1210
+ const effective = mergeWhere(mergeWhere(countOptions.baseWhere, countOptions.where), countScope);
1163
1211
  const resolved = await resolveAggregateRelations(effective, tableName, countOptions.relationBaseWhere);
1164
1212
  const hasRelation = resolved !== effective;
1165
- if (definition.aggregateIndexes && !countOptions.baseWhere && !hasRelation) {
1213
+ if (definition.aggregateIndexes && !countOptions.baseWhere && !hasRelation && !countScope) {
1166
1214
  const planned = selectIndexForCount(definition.aggregateIndexes, countOptions.where);
1167
1215
  if (planned) {
1168
1216
  const counterReady = await ensureBackfilled(tableName, planned.index);
@@ -1183,7 +1231,7 @@ const createSqlCtxDb = (options) => {
1183
1231
  const rows = await queryAll(exec, dialect, whereCondition ? sql`${query} WHERE ${whereCondition}` : query);
1184
1232
  return Number(rows[0]?.["count"] ?? 0);
1185
1233
  },
1186
- async delete(id, expectedTable) {
1234
+ async delete(id, expectedTable, deleteOptions) {
1187
1235
  const tableName = await resolveTableName(id, expectedTable);
1188
1236
  if (!tableName) {
1189
1237
  return;
@@ -1194,6 +1242,11 @@ const createSqlCtxDb = (options) => {
1194
1242
  }
1195
1243
  const snapshot = await rawRow(tableName, id);
1196
1244
  const existing = decodeRow(definition, snapshot);
1245
+ const hard = deleteOptions?.hard === true;
1246
+ const softField = !hard && definition.softDeleteMode ? definition.softDeleteMode.field : void 0;
1247
+ if (softField && (!existing || existing[softField] !== null && existing[softField] !== void 0)) {
1248
+ return;
1249
+ }
1197
1250
  if (hasMatchingTrigger(tableName, "before", "delete")) {
1198
1251
  await fireTriggers("before", "delete", { id, op: "delete", previous: existing ?? void 0, table: tableName });
1199
1252
  }
@@ -1202,14 +1255,15 @@ const createSqlCtxDb = (options) => {
1202
1255
  deletedReference: (references) => existing?.[references],
1203
1256
  findHolders: async (holderTable, field, value) => {
1204
1257
  if (schema.tables[holderTable]?.shardMode?.kind === "shardBy") {
1205
- throw new Error(
1258
+ throw new LunoraError(
1259
+ "INTERNAL",
1206
1260
  `cross-backend cascade from global '${tableName}' into shardBy '${holderTable}' is not supported — would require Query Coordinator fan-out across shards`
1207
1261
  );
1208
1262
  }
1209
- const holders = await writer.findMany(holderTable, { where: { [field]: value } });
1263
+ const holders = await writer.findMany(holderTable, { includeDeleted: hard, where: { [field]: value } });
1210
1264
  return holders.page;
1211
1265
  },
1212
- onCascade: (_holderTable, holderId) => writer.delete(holderId),
1266
+ onCascade: (_holderTable, holderId) => writer.delete(holderId, void 0, deleteOptions),
1213
1267
  onRestrict: (message) => {
1214
1268
  throw new ConflictError(message, "restrict");
1215
1269
  },
@@ -1220,6 +1274,23 @@ const createSqlCtxDb = (options) => {
1220
1274
  });
1221
1275
  await ensureBackfilledForTable(tableName);
1222
1276
  await ensureRankBackfilledForTable(tableName);
1277
+ if (softField && existing) {
1278
+ const merged = { ...existing, [softField]: clock() };
1279
+ const assignments = sql.join(
1280
+ // eslint-disable-next-line unicorn/no-null -- SQL bind value: an absent column binds `null`, matching the patch path.
1281
+ Object.keys(definition.shape).map((field) => sql`${sql.identifier(field)} = ${serializeColumnValue(merged[field] ?? null)}`),
1282
+ sql`, `
1283
+ );
1284
+ await runGuardedWrite(tableName, "UPDATE", assignments, snapshot);
1285
+ await syncAggregates(tableName, existing, merged);
1286
+ await syncRanks(tableName, id, existing, void 0);
1287
+ await syncSearch(tableName, id, merged);
1288
+ await recordCdc(tableName, id, "update", merged);
1289
+ if (hasMatchingTrigger(tableName, "after", "delete")) {
1290
+ await fireTriggers("after", "delete", { id, op: "delete", previous: existing, table: tableName });
1291
+ }
1292
+ return;
1293
+ }
1223
1294
  await runGuardedWrite(tableName, "DELETE", void 0, snapshot);
1224
1295
  tableNameCache.delete(id);
1225
1296
  await syncAggregates(tableName, existing ?? void 0, void 0);
@@ -1244,19 +1315,43 @@ const createSqlCtxDb = (options) => {
1244
1315
  async findMany(tableName, args = {}) {
1245
1316
  const definition = schema.tables[tableName];
1246
1317
  if (!definition) {
1247
- throw new Error(`unknown table: ${tableName}`);
1318
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1248
1319
  }
1249
1320
  await ensureMigrated();
1250
1321
  const orderKeys = normalizeOrderKeys(args.orderBy);
1251
1322
  const seek = args.cursor ? buildSeekWhere(orderKeys, decodeCursor(args.cursor)) : void 0;
1252
1323
  const relationFetcher = relationPredicateFetcher;
1253
- const relationCounter = (childTable, where) => {
1254
- if (!isShardLocalTarget(childTable)) {
1255
- return writer.count(childTable, where);
1324
+ const relationGroupedCounter = async (childTable, whereField, values, policyWhere) => {
1325
+ if (isShardLocalTarget(childTable)) {
1326
+ if (!crossShardCounter) {
1327
+ return crossBackendUnsupported(childTable);
1328
+ }
1329
+ return fanOutScalarCounts(crossShardCounter, childTable, whereField, values, policyWhere);
1330
+ }
1331
+ const childDefinition = schema.tables[childTable];
1332
+ if (!childDefinition) {
1333
+ throw new LunoraError("INTERNAL", `unknown table: ${childTable}`);
1334
+ }
1335
+ const softScope = softDeleteScope(childDefinition.softDeleteMode, void 0);
1336
+ const inFilter = { [whereField]: { in: values } };
1337
+ const combined = mergeWhere(mergeWhere(inFilter, policyWhere), softScope);
1338
+ const resolvedCombined = await resolveAggregateRelations(combined, childTable, void 0);
1339
+ const whereCondition2 = compileWhereSql(resolvedCombined, whereSqlStrategy);
1340
+ const fieldRef = columnRefSql(whereField);
1341
+ let groupQuery = sql`SELECT ${fieldRef} AS __fk__, COUNT(*) AS count FROM ${sql.identifier(childTable)}`;
1342
+ if (whereCondition2) {
1343
+ groupQuery = sql`${groupQuery} WHERE ${whereCondition2}`;
1256
1344
  }
1257
- return crossShardCounter ? crossShardCounter(childTable, where) : crossBackendUnsupported(childTable);
1345
+ groupQuery = sql`${groupQuery} GROUP BY ${fieldRef}`;
1346
+ const groupRows = await queryAll(exec, dialect, groupQuery);
1347
+ const result = /* @__PURE__ */ new Map();
1348
+ for (const row of groupRows) {
1349
+ result.set(row["__fk__"], Number(row["count"] ?? 0));
1350
+ }
1351
+ return result;
1258
1352
  };
1259
1353
  let predicate = mergeWhere(args.baseWhere, args.where);
1354
+ predicate = mergeWhere(predicate, softDeleteScope(definition.softDeleteMode, args.includeDeleted));
1260
1355
  predicate = await resolveRelationPredicates(predicate, {
1261
1356
  fetcher: relationFetcher,
1262
1357
  maxRelationKeys,
@@ -1282,21 +1377,30 @@ const createSqlCtxDb = (options) => {
1282
1377
  const documents = decodeRows(definition, rows);
1283
1378
  if (limit === void 0) {
1284
1379
  if (args.with) {
1285
- await resolveWith({ counter: relationCounter, fetcher: relationFetcher, parents: documents, schema, tableName, with: args.with });
1380
+ await resolveWith({
1381
+ fetcher: relationFetcher,
1382
+ groupedCounter: relationGroupedCounter,
1383
+ parents: documents,
1384
+ schema,
1385
+ tableName,
1386
+ with: args.with
1387
+ });
1286
1388
  }
1287
- return { continueCursor: null, isDone: true, page: documents };
1389
+ return { continueCursor: null, isDone: true, page: applySelect(documents, args.select, args.with) };
1288
1390
  }
1289
1391
  const hasMore = documents.length > limit;
1290
1392
  const page = hasMore ? documents.slice(0, limit) : documents;
1291
1393
  const last = page.at(-1);
1292
1394
  if (args.with) {
1293
- await resolveWith({ counter: relationCounter, fetcher: relationFetcher, parents: page, schema, tableName, with: args.with });
1395
+ await resolveWith({ fetcher: relationFetcher, groupedCounter: relationGroupedCounter, parents: page, schema, tableName, with: args.with });
1294
1396
  }
1295
1397
  return {
1398
+ // The cursor is encoded from `last` (the full, unprojected row), so
1399
+ // `applySelect` only trims the returned payload — paging is intact.
1296
1400
  // eslint-disable-next-line unicorn/no-null -- public return shape: `continueCursor` is `string | null`; `null` marks the final page.
1297
1401
  continueCursor: hasMore && last ? encodeCursor(last, orderKeys) : null,
1298
1402
  isDone: !hasMore,
1299
- page
1403
+ page: applySelect(page, args.select, args.with)
1300
1404
  };
1301
1405
  },
1302
1406
  async get(id, expectedTable) {
@@ -1314,18 +1418,19 @@ const createSqlCtxDb = (options) => {
1314
1418
  async groupBy(tableName, groupOptions) {
1315
1419
  const definition = schema.tables[tableName];
1316
1420
  if (!definition) {
1317
- throw new Error(`unknown table: ${tableName}`);
1421
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1318
1422
  }
1319
1423
  await ensureMigrated();
1320
1424
  const agg = groupOptions.agg ?? { op: "count" };
1321
1425
  aggregateSqlFunction(agg.op);
1322
1426
  if (agg.op !== "count" && !agg.field) {
1323
- throw new Error(`groupBy(${tableName}, { agg: { op: "${agg.op}" } }): "field" is required for non-count reducers`);
1427
+ throw new LunoraError("INTERNAL", `groupBy(${tableName}, { agg: { op: "${agg.op}" } }): "field" is required for non-count reducers`);
1324
1428
  }
1325
- const effective = mergeWhere(groupOptions.baseWhere, groupOptions.where);
1429
+ const groupScope = softDeleteScope(definition.softDeleteMode, void 0);
1430
+ const effective = mergeWhere(mergeWhere(groupOptions.baseWhere, groupOptions.where), groupScope);
1326
1431
  const resolved = await resolveAggregateRelations(effective, tableName, groupOptions.relationBaseWhere);
1327
1432
  const hasRelation = resolved !== effective;
1328
- if (definition.aggregateIndexes && !groupOptions.baseWhere && !hasRelation) {
1433
+ if (definition.aggregateIndexes && !groupOptions.baseWhere && !hasRelation && !groupScope) {
1329
1434
  const indexed = await tryIndexedGroupBy(tableName, definition.aggregateIndexes, agg, groupOptions);
1330
1435
  if (indexed !== void 0) {
1331
1436
  return indexed;
@@ -1337,7 +1442,7 @@ const createSqlCtxDb = (options) => {
1337
1442
  select.push(sql`COUNT(*) AS value`);
1338
1443
  } else {
1339
1444
  if (!agg.field) {
1340
- throw new Error(`groupBy(${tableName}, { agg: { op: "${agg.op}" } }): "field" is required for non-count reducers`);
1445
+ throw new LunoraError("INTERNAL", `groupBy(${tableName}, { agg: { op: "${agg.op}" } }): "field" is required for non-count reducers`);
1341
1446
  }
1342
1447
  select.push(sql`${sql.raw(aggregateSqlFunction(agg.op))}(${columnRefSql(agg.field)}) AS value`);
1343
1448
  }
@@ -1367,7 +1472,7 @@ const createSqlCtxDb = (options) => {
1367
1472
  async insert(tableName, document, insertOptions) {
1368
1473
  const definition = schema.tables[tableName];
1369
1474
  if (!definition) {
1370
- throw new Error(`unknown table: ${tableName}`);
1475
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1371
1476
  }
1372
1477
  await ensureMigrated();
1373
1478
  const withDefaults = applyInsertDefaults(definition, document, auth);
@@ -1383,7 +1488,7 @@ const createSqlCtxDb = (options) => {
1383
1488
  id = generateId();
1384
1489
  usedExplicitId = false;
1385
1490
  }
1386
- const creationTime = typeof withDefaults["_creationTime"] === "number" ? withDefaults["_creationTime"] : clock();
1491
+ const creationTime = insertOptions?.allowExplicitId && typeof withDefaults["_creationTime"] === "number" ? withDefaults["_creationTime"] : clock();
1387
1492
  const documentWithMeta = { ...withDefaults, _creationTime: creationTime, _id: id };
1388
1493
  if (hasMatchingTrigger(tableName, "before", "insert")) {
1389
1494
  await fireTriggers("before", "insert", { doc: { ...documentWithMeta }, id, op: "insert", table: tableName });
@@ -1418,16 +1523,16 @@ const createSqlCtxDb = (options) => {
1418
1523
  async patch(id, patch, expectedTable) {
1419
1524
  const tableName = await resolveTableName(id, expectedTable);
1420
1525
  if (!tableName) {
1421
- throw new Error(`document not found: ${id}`);
1526
+ throw new LunoraError("INTERNAL", `document not found: ${id}`);
1422
1527
  }
1423
1528
  const definition = schema.tables[tableName];
1424
1529
  if (!definition) {
1425
- throw new Error(`document not found: ${id}`);
1530
+ throw new LunoraError("INTERNAL", `document not found: ${id}`);
1426
1531
  }
1427
1532
  const snapshot = await rawRow(tableName, id);
1428
1533
  const existing = decodeRow(definition, snapshot);
1429
1534
  if (!existing) {
1430
- throw new Error(`document not found: ${id}`);
1535
+ throw new LunoraError("INTERNAL", `document not found: ${id}`);
1431
1536
  }
1432
1537
  const merged = { ...existing, ...patch, _id: id };
1433
1538
  applyOnUpdate(definition, patch, merged, auth);
@@ -1452,10 +1557,30 @@ const createSqlCtxDb = (options) => {
1452
1557
  await fireTriggers("after", "update", { doc: merged, id, op: "update", previous: existing, table: tableName });
1453
1558
  }
1454
1559
  },
1560
+ async restore(id, expectedTable) {
1561
+ const tableName = await resolveTableName(id, expectedTable);
1562
+ if (!tableName) {
1563
+ throw new LunoraError("INTERNAL", `document not found: ${id}`);
1564
+ }
1565
+ const definition = schema.tables[tableName];
1566
+ const field = definition?.softDeleteMode?.field;
1567
+ if (!definition || !field) {
1568
+ throw new LunoraError("INTERNAL", `ctx.db.restore: table "${tableName}" is not a .softDelete() table`);
1569
+ }
1570
+ const snapshot = await rawRow(tableName, id);
1571
+ const wasDeleted = snapshot?.[field] !== null && snapshot?.[field] !== void 0;
1572
+ await writer.patch(id, { [field]: null }, expectedTable);
1573
+ if (wasDeleted) {
1574
+ const row = decodeRow(definition, snapshot);
1575
+ if (row !== null) {
1576
+ await syncRanks(tableName, id, void 0, { ...row, [field]: null });
1577
+ }
1578
+ }
1579
+ },
1455
1580
  query(tableName) {
1456
1581
  const definition = schema.tables[tableName];
1457
1582
  if (!definition) {
1458
- throw new Error(`unknown table: ${tableName}`);
1583
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1459
1584
  }
1460
1585
  const LEGACY_READER_ERROR = "the legacy query()/withIndex() reader is not available on the D1 (global) backend; use findMany";
1461
1586
  const runSearch = async (stage, limit) => {
@@ -1466,16 +1591,16 @@ const createSqlCtxDb = (options) => {
1466
1591
  const reader = {
1467
1592
  async collect() {
1468
1593
  if (!stage) {
1469
- throw new Error(LEGACY_READER_ERROR);
1594
+ throw new LunoraError("INTERNAL", LEGACY_READER_ERROR);
1470
1595
  }
1471
1596
  return runSearch(stage, void 0);
1472
1597
  },
1473
1598
  filter() {
1474
- throw new Error(LEGACY_READER_ERROR);
1599
+ throw new LunoraError("INTERNAL", LEGACY_READER_ERROR);
1475
1600
  },
1476
1601
  async first() {
1477
1602
  if (!stage) {
1478
- throw new Error(LEGACY_READER_ERROR);
1603
+ throw new LunoraError("INTERNAL", LEGACY_READER_ERROR);
1479
1604
  }
1480
1605
  const rows = await runSearch(stage, 1);
1481
1606
  return rows[0] ?? null;
@@ -1486,19 +1611,19 @@ const createSqlCtxDb = (options) => {
1486
1611
  // eslint-disable-next-line @typescript-eslint/require-await -- TableReaderLike.paginate returns a Promise; search queries don't support pagination on either backend
1487
1612
  async paginate() {
1488
1613
  if (stage) {
1489
- throw new Error("pagination is not supported on search queries; use .take(n) or .collect()");
1614
+ throw new LunoraError("INTERNAL", "pagination is not supported on search queries; use .take(n) or .collect()");
1490
1615
  }
1491
- throw new Error(LEGACY_READER_ERROR);
1616
+ throw new LunoraError("INTERNAL", LEGACY_READER_ERROR);
1492
1617
  },
1493
1618
  async take(limit) {
1494
1619
  if (!stage) {
1495
- throw new Error(LEGACY_READER_ERROR);
1620
+ throw new LunoraError("INTERNAL", LEGACY_READER_ERROR);
1496
1621
  }
1497
1622
  return runSearch(stage, limit);
1498
1623
  },
1499
1624
  async unique() {
1500
1625
  if (!stage) {
1501
- throw new Error(LEGACY_READER_ERROR);
1626
+ throw new LunoraError("INTERNAL", LEGACY_READER_ERROR);
1502
1627
  }
1503
1628
  const rows = await runSearch(stage, 2);
1504
1629
  if (rows.length > 1) {
@@ -1506,13 +1631,19 @@ const createSqlCtxDb = (options) => {
1506
1631
  }
1507
1632
  return rows[0] ?? null;
1508
1633
  },
1634
+ withGeoIndex() {
1635
+ throw new LunoraError(
1636
+ "INTERNAL",
1637
+ `geo indexes are not supported on \`.global()\` tables (table "${tableName}") — geospatial queries run only on sharded tables`
1638
+ );
1639
+ },
1509
1640
  withIndex() {
1510
- throw new Error(LEGACY_READER_ERROR);
1641
+ throw new LunoraError("INTERNAL", LEGACY_READER_ERROR);
1511
1642
  },
1512
1643
  withSearchIndex(indexName, search) {
1513
1644
  const searchDefinition = (definition.searchIndexes ?? []).find((index) => index.name === indexName);
1514
1645
  if (!searchDefinition) {
1515
- throw new Error(`unknown search index "${indexName}" on table "${tableName}"`);
1646
+ throw new LunoraError("INTERNAL", `unknown search index "${indexName}" on table "${tableName}"`);
1516
1647
  }
1517
1648
  const searchStage = {
1518
1649
  definition: searchDefinition,
@@ -1524,7 +1655,7 @@ const createSqlCtxDb = (options) => {
1524
1655
  };
1525
1656
  search(createSearchBuilder(searchStage, tableName));
1526
1657
  if (!searchStage.hasQuery) {
1527
- throw new Error(`search index "${indexName}" on table "${tableName}" requires a .search(field, query) call`);
1658
+ throw new LunoraError("INTERNAL", `search index "${indexName}" on table "${tableName}" requires a .search(field, query) call`);
1528
1659
  }
1529
1660
  return buildReader(searchStage);
1530
1661
  }
@@ -1536,11 +1667,11 @@ const createSqlCtxDb = (options) => {
1536
1667
  async rank(tableName, indexName, rankOptions) {
1537
1668
  const definition = schema.tables[tableName];
1538
1669
  if (!definition) {
1539
- throw new Error(`unknown table: ${tableName}`);
1670
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1540
1671
  }
1541
1672
  const index = definition.rankIndexes?.find((i) => i.name === indexName);
1542
1673
  if (!index) {
1543
- throw new Error(`unknown rankIndex "${indexName}" on table "${tableName}"`);
1674
+ throw new LunoraError("INTERNAL", `unknown rankIndex "${indexName}" on table "${tableName}"`);
1544
1675
  }
1545
1676
  if (rankOptions.restrictsCounts) {
1546
1677
  throw new CountRlsUnsupportedError(tableName);
@@ -1593,11 +1724,11 @@ const createSqlCtxDb = (options) => {
1593
1724
  assertFlatPredicate(mergeWhere(rankPageOptions.baseWhere, rankPageOptions.where), schema, tableName, "rankPage");
1594
1725
  const definition = schema.tables[tableName];
1595
1726
  if (!definition) {
1596
- throw new Error(`unknown table: ${tableName}`);
1727
+ throw new LunoraError("INTERNAL", `unknown table: ${tableName}`);
1597
1728
  }
1598
1729
  const index = definition.rankIndexes?.find((i) => i.name === indexName);
1599
1730
  if (!index) {
1600
- throw new Error(`unknown rankIndex "${indexName}" on table "${tableName}"`);
1731
+ throw new LunoraError("INTERNAL", `unknown rankIndex "${indexName}" on table "${tableName}"`);
1601
1732
  }
1602
1733
  await ensureMigrated();
1603
1734
  const counterReady = await ensureRankBackfilled(tableName, index);
@@ -1650,22 +1781,22 @@ const createSqlCtxDb = (options) => {
1650
1781
  }
1651
1782
  return { continueCursor, isDone: !hasMore, page: documents };
1652
1783
  },
1653
- async replace(id, document, expectedTable) {
1784
+ async replace(id, document, expectedTable, replaceOptions) {
1654
1785
  const tableName = await resolveTableName(id, expectedTable);
1655
1786
  if (!tableName) {
1656
- throw new Error(`document not found: ${id}`);
1787
+ throw new LunoraError("INTERNAL", `document not found: ${id}`);
1657
1788
  }
1658
1789
  const definition = schema.tables[tableName];
1659
1790
  if (!definition) {
1660
- throw new Error(`document not found: ${id}`);
1791
+ throw new LunoraError("INTERNAL", `document not found: ${id}`);
1661
1792
  }
1662
1793
  const snapshot = await rawRow(tableName, id);
1663
1794
  if (snapshot === void 0) {
1664
- throw new Error(`document not found: ${id}`);
1795
+ throw new LunoraError("INTERNAL", `document not found: ${id}`);
1665
1796
  }
1666
1797
  const needsPrevious = hasTrigger(schema, tableName, "update") || (definition.aggregateIndexes ?? []).length > 0 || (definition.rankIndexes ?? []).length > 0;
1667
1798
  const previous = needsPrevious ? decodeRow(definition, snapshot) ?? void 0 : void 0;
1668
- const creationTime = typeof document["_creationTime"] === "number" ? document["_creationTime"] : clock();
1799
+ const creationTime = replaceOptions?.allowExplicitId && typeof document["_creationTime"] === "number" ? document["_creationTime"] : clock();
1669
1800
  const replaced = { ...document, _creationTime: creationTime, _id: id };
1670
1801
  applyOnUpdate(definition, document, replaced, auth);
1671
1802
  runRowValidators(definition, replaced);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/sql-store",
3
- "version": "1.0.0-alpha.4",
3
+ "version": "1.0.0-alpha.41",
4
4
  "description": "Internal dialect-parameterized SQL store core for Lunora .global() backends (D1, PlanetScale)",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -25,7 +25,7 @@
25
25
  "directory": "packages/sql-store"
26
26
  },
27
27
  "files": [
28
- "dist",
28
+ "./dist",
29
29
  "README.md",
30
30
  "LICENSE.md",
31
31
  "__assets__"
@@ -50,7 +50,8 @@
50
50
  "access": "public"
51
51
  },
52
52
  "dependencies": {
53
- "@lunora/do": "1.0.0-alpha.4",
53
+ "@lunora/do": "1.0.0-alpha.44",
54
+ "@lunora/errors": "1.0.0-alpha.8",
54
55
  "drizzle-orm": "^0.45.2"
55
56
  },
56
57
  "engines": {