@lunora/sql-store 1.0.0-alpha.4 → 1.0.0-alpha.5

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.
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- export { createSqlCtxDb, decodeGlobalRow, readSqlCdcChanges, runSqlAggregateMigrations, runSqlCdcMigration, runSqlGlobalTableMigrations, runSqlRankMigrations, runSqlSearchMigrations, trimSqlCdcChanges } from './packem_shared/createSqlCtxDb-WI84CQ7K.mjs';
1
+ export { createSqlCtxDb, decodeGlobalRow, readSqlCdcChanges, runSqlAggregateMigrations, runSqlCdcMigration, runSqlGlobalTableMigrations, runSqlRankMigrations, runSqlSearchMigrations, trimSqlCdcChanges } from './packem_shared/createSqlCtxDb-DYqDyPq8.mjs';
2
2
  export { decodeBigint, effectiveColumnKind, sqliteDecode, sqliteEncode, tryJsonParse } from './packem_shared/decodeBigint-Dedu92k4.mjs';
@@ -1,4 +1,4 @@
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, matchesStaticWhere, coerceAggregateNumber, foldAggregateTally, NotUniqueError, throwingScheduler, tokenizeSearch, buildFtsMatch, scoreDocument } from '@lunora/do';
2
2
  import { sql } from 'drizzle-orm';
3
3
  import { sqliteDecode, effectiveColumnKind, sqliteEncode } from './decodeBigint-Dedu92k4.mjs';
4
4
 
@@ -212,6 +212,9 @@ const searchViaFts = async (exec, dialect, definition, tableName, search, limit)
212
212
  for (const filter of search.filters) {
213
213
  conditions.push(sql`m.${columnRefSql(filter.field)} = ${serializeColumnValue(filter.value)}`);
214
214
  }
215
+ if (definition.softDeleteMode) {
216
+ conditions.push(sql`m.${columnRefSql(definition.softDeleteMode.field)} IS NULL`);
217
+ }
215
218
  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
219
  if (typeof limit === "number") {
217
220
  query = sql`${query} LIMIT ${sql.raw(String(Math.max(0, Math.floor(limit))))}`;
@@ -225,6 +228,9 @@ const searchViaScan = async (exec, dialect, definition, tableName, search, limit
225
228
  return [];
226
229
  }
227
230
  const conditions = search.filters.map((filter) => sql`${columnRefSql(filter.field)} = ${serializeColumnValue(filter.value)}`);
231
+ if (definition.softDeleteMode) {
232
+ conditions.push(sql`${columnRefSql(definition.softDeleteMode.field)} IS NULL`);
233
+ }
228
234
  let query = sql`SELECT * FROM ${sql.identifier(tableName)}`;
229
235
  if (conditions.length > 0) {
230
236
  query = sql`${query} WHERE ${sql.join(conditions, sql` AND `)}`;
@@ -1122,10 +1128,11 @@ const createSqlCtxDb = (options) => {
1122
1128
  if (!aggOptions.field) {
1123
1129
  throw new Error(`aggregate(${tableName}, { op: "${aggOptions.op}" }): "field" is required for non-count reducers`);
1124
1130
  }
1125
- const effective = mergeWhere(aggOptions.baseWhere, aggOptions.where);
1131
+ const aggScope = softDeleteScope(definition.softDeleteMode, void 0);
1132
+ const effective = mergeWhere(mergeWhere(aggOptions.baseWhere, aggOptions.where), aggScope);
1126
1133
  const resolved = await resolveAggregateRelations(effective, tableName, aggOptions.relationBaseWhere);
1127
1134
  const hasRelation = resolved !== effective;
1128
- if (definition.aggregateIndexes && !aggOptions.baseWhere && !hasRelation) {
1135
+ if (definition.aggregateIndexes && !aggOptions.baseWhere && !hasRelation && !aggScope) {
1129
1136
  const planned = selectIndexForAggregate(definition.aggregateIndexes, aggOptions.op, aggOptions.field, aggOptions.where);
1130
1137
  if (planned) {
1131
1138
  const counterReady = await ensureBackfilled(tableName, planned.index);
@@ -1159,10 +1166,11 @@ const createSqlCtxDb = (options) => {
1159
1166
  if (countOptions.restrictsCounts) {
1160
1167
  throw new CountRlsUnsupportedError(tableName);
1161
1168
  }
1162
- const effective = mergeWhere(countOptions.baseWhere, countOptions.where);
1169
+ const countScope = softDeleteScope(definition.softDeleteMode, void 0);
1170
+ const effective = mergeWhere(mergeWhere(countOptions.baseWhere, countOptions.where), countScope);
1163
1171
  const resolved = await resolveAggregateRelations(effective, tableName, countOptions.relationBaseWhere);
1164
1172
  const hasRelation = resolved !== effective;
1165
- if (definition.aggregateIndexes && !countOptions.baseWhere && !hasRelation) {
1173
+ if (definition.aggregateIndexes && !countOptions.baseWhere && !hasRelation && !countScope) {
1166
1174
  const planned = selectIndexForCount(definition.aggregateIndexes, countOptions.where);
1167
1175
  if (planned) {
1168
1176
  const counterReady = await ensureBackfilled(tableName, planned.index);
@@ -1183,7 +1191,7 @@ const createSqlCtxDb = (options) => {
1183
1191
  const rows = await queryAll(exec, dialect, whereCondition ? sql`${query} WHERE ${whereCondition}` : query);
1184
1192
  return Number(rows[0]?.["count"] ?? 0);
1185
1193
  },
1186
- async delete(id, expectedTable) {
1194
+ async delete(id, expectedTable, deleteOptions) {
1187
1195
  const tableName = await resolveTableName(id, expectedTable);
1188
1196
  if (!tableName) {
1189
1197
  return;
@@ -1194,6 +1202,11 @@ const createSqlCtxDb = (options) => {
1194
1202
  }
1195
1203
  const snapshot = await rawRow(tableName, id);
1196
1204
  const existing = decodeRow(definition, snapshot);
1205
+ const hard = deleteOptions?.hard === true;
1206
+ const softField = !hard && definition.softDeleteMode ? definition.softDeleteMode.field : void 0;
1207
+ if (softField && (!existing || existing[softField] !== null && existing[softField] !== void 0)) {
1208
+ return;
1209
+ }
1197
1210
  if (hasMatchingTrigger(tableName, "before", "delete")) {
1198
1211
  await fireTriggers("before", "delete", { id, op: "delete", previous: existing ?? void 0, table: tableName });
1199
1212
  }
@@ -1206,10 +1219,10 @@ const createSqlCtxDb = (options) => {
1206
1219
  `cross-backend cascade from global '${tableName}' into shardBy '${holderTable}' is not supported — would require Query Coordinator fan-out across shards`
1207
1220
  );
1208
1221
  }
1209
- const holders = await writer.findMany(holderTable, { where: { [field]: value } });
1222
+ const holders = await writer.findMany(holderTable, { includeDeleted: hard, where: { [field]: value } });
1210
1223
  return holders.page;
1211
1224
  },
1212
- onCascade: (_holderTable, holderId) => writer.delete(holderId),
1225
+ onCascade: (_holderTable, holderId) => writer.delete(holderId, void 0, deleteOptions),
1213
1226
  onRestrict: (message) => {
1214
1227
  throw new ConflictError(message, "restrict");
1215
1228
  },
@@ -1220,6 +1233,23 @@ const createSqlCtxDb = (options) => {
1220
1233
  });
1221
1234
  await ensureBackfilledForTable(tableName);
1222
1235
  await ensureRankBackfilledForTable(tableName);
1236
+ if (softField && existing) {
1237
+ const merged = { ...existing, [softField]: clock() };
1238
+ const assignments = sql.join(
1239
+ // eslint-disable-next-line unicorn/no-null -- SQL bind value: an absent column binds `null`, matching the patch path.
1240
+ Object.keys(definition.shape).map((field) => sql`${sql.identifier(field)} = ${serializeColumnValue(merged[field] ?? null)}`),
1241
+ sql`, `
1242
+ );
1243
+ await runGuardedWrite(tableName, "UPDATE", assignments, snapshot);
1244
+ await syncAggregates(tableName, existing, merged);
1245
+ await syncRanks(tableName, id, existing, void 0);
1246
+ await syncSearch(tableName, id, merged);
1247
+ await recordCdc(tableName, id, "update", merged);
1248
+ if (hasMatchingTrigger(tableName, "after", "delete")) {
1249
+ await fireTriggers("after", "delete", { id, op: "delete", previous: existing, table: tableName });
1250
+ }
1251
+ return;
1252
+ }
1223
1253
  await runGuardedWrite(tableName, "DELETE", void 0, snapshot);
1224
1254
  tableNameCache.delete(id);
1225
1255
  await syncAggregates(tableName, existing ?? void 0, void 0);
@@ -1257,6 +1287,7 @@ const createSqlCtxDb = (options) => {
1257
1287
  return crossShardCounter ? crossShardCounter(childTable, where) : crossBackendUnsupported(childTable);
1258
1288
  };
1259
1289
  let predicate = mergeWhere(args.baseWhere, args.where);
1290
+ predicate = mergeWhere(predicate, softDeleteScope(definition.softDeleteMode, args.includeDeleted));
1260
1291
  predicate = await resolveRelationPredicates(predicate, {
1261
1292
  fetcher: relationFetcher,
1262
1293
  maxRelationKeys,
@@ -1284,7 +1315,7 @@ const createSqlCtxDb = (options) => {
1284
1315
  if (args.with) {
1285
1316
  await resolveWith({ counter: relationCounter, fetcher: relationFetcher, parents: documents, schema, tableName, with: args.with });
1286
1317
  }
1287
- return { continueCursor: null, isDone: true, page: documents };
1318
+ return { continueCursor: null, isDone: true, page: applySelect(documents, args.select, args.with) };
1288
1319
  }
1289
1320
  const hasMore = documents.length > limit;
1290
1321
  const page = hasMore ? documents.slice(0, limit) : documents;
@@ -1293,10 +1324,12 @@ const createSqlCtxDb = (options) => {
1293
1324
  await resolveWith({ counter: relationCounter, fetcher: relationFetcher, parents: page, schema, tableName, with: args.with });
1294
1325
  }
1295
1326
  return {
1327
+ // The cursor is encoded from `last` (the full, unprojected row), so
1328
+ // `applySelect` only trims the returned payload — paging is intact.
1296
1329
  // eslint-disable-next-line unicorn/no-null -- public return shape: `continueCursor` is `string | null`; `null` marks the final page.
1297
1330
  continueCursor: hasMore && last ? encodeCursor(last, orderKeys) : null,
1298
1331
  isDone: !hasMore,
1299
- page
1332
+ page: applySelect(page, args.select, args.with)
1300
1333
  };
1301
1334
  },
1302
1335
  async get(id, expectedTable) {
@@ -1322,10 +1355,11 @@ const createSqlCtxDb = (options) => {
1322
1355
  if (agg.op !== "count" && !agg.field) {
1323
1356
  throw new Error(`groupBy(${tableName}, { agg: { op: "${agg.op}" } }): "field" is required for non-count reducers`);
1324
1357
  }
1325
- const effective = mergeWhere(groupOptions.baseWhere, groupOptions.where);
1358
+ const groupScope = softDeleteScope(definition.softDeleteMode, void 0);
1359
+ const effective = mergeWhere(mergeWhere(groupOptions.baseWhere, groupOptions.where), groupScope);
1326
1360
  const resolved = await resolveAggregateRelations(effective, tableName, groupOptions.relationBaseWhere);
1327
1361
  const hasRelation = resolved !== effective;
1328
- if (definition.aggregateIndexes && !groupOptions.baseWhere && !hasRelation) {
1362
+ if (definition.aggregateIndexes && !groupOptions.baseWhere && !hasRelation && !groupScope) {
1329
1363
  const indexed = await tryIndexedGroupBy(tableName, definition.aggregateIndexes, agg, groupOptions);
1330
1364
  if (indexed !== void 0) {
1331
1365
  return indexed;
@@ -1452,6 +1486,26 @@ const createSqlCtxDb = (options) => {
1452
1486
  await fireTriggers("after", "update", { doc: merged, id, op: "update", previous: existing, table: tableName });
1453
1487
  }
1454
1488
  },
1489
+ async restore(id, expectedTable) {
1490
+ const tableName = await resolveTableName(id, expectedTable);
1491
+ if (!tableName) {
1492
+ throw new Error(`document not found: ${id}`);
1493
+ }
1494
+ const definition = schema.tables[tableName];
1495
+ const field = definition?.softDeleteMode?.field;
1496
+ if (!definition || !field) {
1497
+ throw new Error(`ctx.db.restore: table "${tableName}" is not a .softDelete() table`);
1498
+ }
1499
+ const snapshot = await rawRow(tableName, id);
1500
+ const wasDeleted = snapshot?.[field] !== null && snapshot?.[field] !== void 0;
1501
+ await writer.patch(id, { [field]: null }, expectedTable);
1502
+ if (wasDeleted) {
1503
+ const row = decodeRow(definition, snapshot);
1504
+ if (row !== null) {
1505
+ await syncRanks(tableName, id, void 0, row);
1506
+ }
1507
+ }
1508
+ },
1455
1509
  query(tableName) {
1456
1510
  const definition = schema.tables[tableName];
1457
1511
  if (!definition) {
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.5",
4
4
  "description": "Internal dialect-parameterized SQL store core for Lunora .global() backends (D1, PlanetScale)",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -50,7 +50,7 @@
50
50
  "access": "public"
51
51
  },
52
52
  "dependencies": {
53
- "@lunora/do": "1.0.0-alpha.4",
53
+ "@lunora/do": "1.0.0-alpha.5",
54
54
  "drizzle-orm": "^0.45.2"
55
55
  },
56
56
  "engines": {