@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.
- package/LICENSE.md +6 -0
- package/README.md +80 -1
- package/__assets__/package-og.svg +1 -1
- package/dist/dialect.d.mts +48 -38
- package/dist/dialect.d.ts +48 -38
- package/dist/index.d.mts +106 -100
- package/dist/index.d.ts +106 -100
- package/dist/index.mjs +1 -1
- package/dist/packem_shared/{createSqlCtxDb-WI84CQ7K.mjs → createSqlCtxDb-BPEyi1Yl.mjs} +197 -66
- package/package.json +4 -3
|
@@ -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,
|
|
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,
|
|
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,
|
|
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
|
|
152
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1169
|
+
throw new LunoraError("INTERNAL", `aggregate(${tableName}, { op: "${aggOptions.op}" }): "field" is required for non-count reducers`);
|
|
1124
1170
|
}
|
|
1125
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1254
|
-
if (
|
|
1255
|
-
|
|
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
|
-
|
|
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({
|
|
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({
|
|
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
|
|
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
|
|
1427
|
+
throw new LunoraError("INTERNAL", `groupBy(${tableName}, { agg: { op: "${agg.op}" } }): "field" is required for non-count reducers`);
|
|
1324
1428
|
}
|
|
1325
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1599
|
+
throw new LunoraError("INTERNAL", LEGACY_READER_ERROR);
|
|
1475
1600
|
},
|
|
1476
1601
|
async first() {
|
|
1477
1602
|
if (!stage) {
|
|
1478
|
-
throw new
|
|
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
|
|
1614
|
+
throw new LunoraError("INTERNAL", "pagination is not supported on search queries; use .take(n) or .collect()");
|
|
1490
1615
|
}
|
|
1491
|
-
throw new
|
|
1616
|
+
throw new LunoraError("INTERNAL", LEGACY_READER_ERROR);
|
|
1492
1617
|
},
|
|
1493
1618
|
async take(limit) {
|
|
1494
1619
|
if (!stage) {
|
|
1495
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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": {
|