@happyvertical/smrt-core 0.51.10 → 0.51.11
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/agents/change-feed.md +82 -0
- package/agents/generators.md +3 -1
- package/dist/change-feed-sensitivity.d.ts +155 -0
- package/dist/change-feed-sensitivity.d.ts.map +1 -0
- package/dist/change-feed-sensitivity.js +139 -0
- package/dist/change-feed-sensitivity.js.map +1 -0
- package/dist/change-feed.d.ts +37 -10
- package/dist/change-feed.d.ts.map +1 -1
- package/dist/change-feed.js +128 -12
- package/dist/change-feed.js.map +1 -1
- package/dist/change-signals.d.ts.map +1 -1
- package/dist/change-signals.js +3 -0
- package/dist/change-signals.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/manifest/static-manifest.js +1 -1
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest.json +1 -1
- package/dist/registry/class-registration.d.ts +1 -1
- package/dist/registry/class-registration.d.ts.map +1 -1
- package/dist/registry/class-registration.js +50 -4
- package/dist/registry/class-registration.js.map +1 -1
- package/dist/registry/types.d.ts +43 -0
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/registry.d.ts +14 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +15 -2
- package/dist/registry.js.map +1 -1
- package/dist/schema/types.d.ts +8 -1
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/smrt-knowledge.json +7 -7
- package/dist/vite-plugin/dev-plane-route.js +1 -1
- package/dist/vite-plugin/dev-plane-route.js.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.js +54 -7
- package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
- package/package.json +4 -4
package/dist/change-feed.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getChangeFeedSensitiveTables, isChangeFeedSensitiveTable, isChangeFeedSensitiveWrite } from "./change-feed-sensitivity.js";
|
|
1
2
|
import { resolveDbCacheKey } from "./collection-cache.js";
|
|
2
3
|
import { toSafeInteger } from "./utils/safe-integer.js";
|
|
3
4
|
import { publishChangeSignal } from "./change-signals.js";
|
|
@@ -8,6 +9,7 @@ import { detectEngine } from "./schema/ddl/index.js";
|
|
|
8
9
|
import { CREATE_SMRT_CHANGES_TABLE, ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, ENSURE_POSTGRES_CHANGE_FEED_SCHEMA, FRAMEWORK_OPERATIONAL_TABLES, POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION_IDENTITY, POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION_NAME, POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY, POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME, POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_IDENTITY, POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_NAME, POSTGRES_CHANGE_FEED_HELPER_MARKER, POSTGRES_CHANGE_FEED_PENDING_TABLE, REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, RETIRED_SYSTEM_TABLES } from "./system/schema.js";
|
|
9
10
|
import { SYSTEM_TABLE_NAMES } from "./schema/system-table-shapes.js";
|
|
10
11
|
import { createLogger } from "@happyvertical/logger";
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
11
13
|
//#region src/change-feed.ts
|
|
12
14
|
/**
|
|
13
15
|
* Adapter-agnostic change feed — the framework's change-observation spine
|
|
@@ -157,6 +159,11 @@ var CHANGE_FEED_TABLE = "_smrt_changes";
|
|
|
157
159
|
* the feed's own table so it can never observe itself), the model-backed
|
|
158
160
|
* operational plumbing ({@link FRAMEWORK_OPERATIONAL_TABLES}), and the retired
|
|
159
161
|
* system tables that may still exist on older databases.
|
|
162
|
+
*
|
|
163
|
+
* Credential-bearing *application* tables are excluded separately, because
|
|
164
|
+
* that set grows at registration time rather than being fixed at module load
|
|
165
|
+
* — see {@link isChangeFeedSensitiveTable} and `change-feed-sensitivity.ts`
|
|
166
|
+
* (#2937).
|
|
160
167
|
*/
|
|
161
168
|
var CHANGE_FEED_EXCLUDED_TABLES = /* @__PURE__ */ new Set([
|
|
162
169
|
...SYSTEM_TABLE_NAMES,
|
|
@@ -167,11 +174,17 @@ var CHANGE_FEED_EXCLUDED_TABLES = /* @__PURE__ */ new Set([
|
|
|
167
174
|
/**
|
|
168
175
|
* Whether framework writes to `tableName` are recorded in the change feed.
|
|
169
176
|
*
|
|
177
|
+
* Two reasons a table is not observable: it is framework bookkeeping
|
|
178
|
+
* ({@link CHANGE_FEED_EXCLUDED_TABLES}), or it is credential-bearing
|
|
179
|
+
* ({@link isChangeFeedSensitiveTable}, #2937). The second is evaluated live
|
|
180
|
+
* rather than folded into the frozen set above, because a class declaring
|
|
181
|
+
* `@smrt({ sensitive: true })` registers after this module loads.
|
|
182
|
+
*
|
|
170
183
|
* Exported so tooling and tests can reason about feed coverage without
|
|
171
|
-
* re-deriving the rule.
|
|
184
|
+
* re-deriving the rule.
|
|
172
185
|
*/
|
|
173
186
|
function isChangeFeedObservableTable(tableName) {
|
|
174
|
-
return Boolean(tableName) && !CHANGE_FEED_EXCLUDED_TABLES.has(tableName);
|
|
187
|
+
return Boolean(tableName) && !CHANGE_FEED_EXCLUDED_TABLES.has(tableName) && !isChangeFeedSensitiveTable(tableName);
|
|
175
188
|
}
|
|
176
189
|
/** Interceptor name of the framework's change-feed writer. */
|
|
177
190
|
var CHANGE_FEED_INTERCEPTOR_NAME = "smrt-change-feed";
|
|
@@ -471,6 +484,7 @@ async function appendChange(db, input) {
|
|
|
471
484
|
if (!table) throw new Error("appendChange requires a non-empty table name");
|
|
472
485
|
const operation = input.operation ?? "update";
|
|
473
486
|
if (!VALID_OPERATIONS.has(operation)) throw new Error(`appendChange operation must be one of create/update/delete, got '${String(input.operation)}'`);
|
|
487
|
+
if (isChangeFeedSensitiveTable(table)) return null;
|
|
474
488
|
const engine = getEngine(db);
|
|
475
489
|
const p = placeholders(db);
|
|
476
490
|
const sql = engine === "postgres" ? `SELECT allocated_seq, error_code, error_message FROM ${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME}(${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)})` : `INSERT INTO ${CHANGE_FEED_TABLE} (seq, table_name, row_id, operation, tenant_id, created_at) SELECT COALESCE(MAX(seq), 0) + 1, ${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)} FROM ${CHANGE_FEED_TABLE} RETURNING seq`;
|
|
@@ -536,13 +550,42 @@ async function appendChange(db, input) {
|
|
|
536
550
|
* entry, preserves rollback/failure isolation, and never waits on the feed
|
|
537
551
|
* head. Direct batches allocate contiguous sequences in input order.
|
|
538
552
|
*/
|
|
553
|
+
/**
|
|
554
|
+
* Validate one {@link appendChanges} entry, returning its normalized table and
|
|
555
|
+
* operation. Shared by the sensitive-entry filter and the batch builder so both
|
|
556
|
+
* apply exactly the same rules (#2937).
|
|
557
|
+
*/
|
|
558
|
+
function validateAppendChangesInput(input) {
|
|
559
|
+
const table = input.table?.trim();
|
|
560
|
+
if (!table) throw new Error("appendChanges requires a non-empty table name");
|
|
561
|
+
const operation = input.operation ?? "update";
|
|
562
|
+
if (!VALID_OPERATIONS.has(operation)) throw new Error(`appendChanges operation must be one of create/update/delete, got '${String(input.operation)}'`);
|
|
563
|
+
return {
|
|
564
|
+
table,
|
|
565
|
+
operation
|
|
566
|
+
};
|
|
567
|
+
}
|
|
539
568
|
async function appendChanges(db, inputs) {
|
|
540
569
|
if (inputs.length === 0) return [];
|
|
570
|
+
if (inputs.some((input) => isChangeFeedSensitiveTable(input.table?.trim()))) {
|
|
571
|
+
for (const input of inputs) validateAppendChangesInput(input);
|
|
572
|
+
const kept = [];
|
|
573
|
+
inputs.forEach((input, index) => {
|
|
574
|
+
if (!isChangeFeedSensitiveTable(input.table?.trim())) kept.push({
|
|
575
|
+
input,
|
|
576
|
+
index
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
const result = Array(inputs.length).fill(null);
|
|
580
|
+
if (kept.length === 0) return result;
|
|
581
|
+
const sequences = await appendChanges(db, kept.map(({ input }) => input));
|
|
582
|
+
kept.forEach(({ index }, position) => {
|
|
583
|
+
result[index] = sequences[position] ?? null;
|
|
584
|
+
});
|
|
585
|
+
return result;
|
|
586
|
+
}
|
|
541
587
|
const entries = inputs.map((input) => {
|
|
542
|
-
const table = input
|
|
543
|
-
if (!table) throw new Error("appendChanges requires a non-empty table name");
|
|
544
|
-
const operation = input.operation ?? "update";
|
|
545
|
-
if (!VALID_OPERATIONS.has(operation)) throw new Error(`appendChanges operation must be one of create/update/delete, got '${String(input.operation)}'`);
|
|
588
|
+
const { table, operation } = validateAppendChangesInput(input);
|
|
546
589
|
return {
|
|
547
590
|
table,
|
|
548
591
|
rowId: input.rowId ?? null,
|
|
@@ -897,10 +940,18 @@ async function getChangesSince(db, options) {
|
|
|
897
940
|
params.push(since);
|
|
898
941
|
conditions.push(`seq <= ${next()}`);
|
|
899
942
|
params.push(servedHorizon);
|
|
900
|
-
const tables = options.tables?.filter((table) => table.trim().length > 0);
|
|
943
|
+
const tables = options.tables?.filter((table) => table.trim().length > 0).filter((table) => !isChangeFeedSensitiveTable(table.trim()));
|
|
901
944
|
if (tables && tables.length > 0) {
|
|
902
945
|
conditions.push(`table_name IN (${tables.map(() => next()).join(", ")})`);
|
|
903
946
|
params.push(...tables);
|
|
947
|
+
} else if (options.tables && options.tables.length > 0) return {
|
|
948
|
+
changes: [],
|
|
949
|
+
cursor: servedHorizon
|
|
950
|
+
};
|
|
951
|
+
const sensitiveTables = getChangeFeedSensitiveTables();
|
|
952
|
+
if (sensitiveTables.length > 0) {
|
|
953
|
+
conditions.push(`table_name NOT IN (${sensitiveTables.map(() => next()).join(", ")})`);
|
|
954
|
+
params.push(...sensitiveTables);
|
|
904
955
|
}
|
|
905
956
|
if (options.tenantId === null) conditions.push("tenant_id IS NULL");
|
|
906
957
|
else if (typeof options.tenantId === "string") {
|
|
@@ -909,10 +960,10 @@ async function getChangesSince(db, options) {
|
|
|
909
960
|
}
|
|
910
961
|
const sql = `SELECT seq, table_name, row_id, operation, tenant_id, created_at FROM ${CHANGE_FEED_TABLE} WHERE ${conditions.join(" AND ")} ORDER BY seq ASC LIMIT ${next()}`;
|
|
911
962
|
params.push(limit);
|
|
912
|
-
const
|
|
963
|
+
const entries = getQueryRows(await db.query(sql, ...params)).map(rowToEntry);
|
|
913
964
|
return {
|
|
914
|
-
changes,
|
|
915
|
-
cursor:
|
|
965
|
+
changes: entries.filter((entry) => !isChangeFeedSensitiveTable(entry.table)),
|
|
966
|
+
cursor: entries.length === limit ? entries[entries.length - 1].seq : servedHorizon
|
|
916
967
|
};
|
|
917
968
|
}
|
|
918
969
|
/**
|
|
@@ -975,12 +1026,38 @@ async function getTenantScopedChangesSince(db, options) {
|
|
|
975
1026
|
* high-water mark that survives pruning would remove even that cost; it is a
|
|
976
1027
|
* deliberate follow-up, out of scope for this slice.
|
|
977
1028
|
*
|
|
1029
|
+
* ## Tables the feed does not observe (#2937)
|
|
1030
|
+
*
|
|
1031
|
+
* The whole contract above — "any write to the table appends a new sequence
|
|
1032
|
+
* strictly greater than every previously-observed value" — rests on the table
|
|
1033
|
+
* being recorded at all. For a credential-bearing table it is not, so there is
|
|
1034
|
+
* no version to compute: the value would pin at whatever an older build last
|
|
1035
|
+
* wrote, then fall back to the global horizon, which only moves when some
|
|
1036
|
+
* *other* table writes. A client revalidating with `If-None-Match` would be
|
|
1037
|
+
* answered `304` for a revoked API key, a rotated session or a changed
|
|
1038
|
+
* identity, and stay stale until unrelated traffic happened to bump the
|
|
1039
|
+
* horizon — a silent stale read of security state.
|
|
1040
|
+
*
|
|
1041
|
+
* Such a table therefore gets a deliberately **unrepeatable** value, so every
|
|
1042
|
+
* ETag derived from it is fresh and no *concrete* `If-None-Match` can match.
|
|
1043
|
+
* Those reads cost a full response every time, which is the correct trade for
|
|
1044
|
+
* a credential store, and the caller needs no special case: this is the one
|
|
1045
|
+
* place every ETag path (the runtime `APIGenerator`, the generated SvelteKit
|
|
1046
|
+
* `conditionalVersionedRead`, and route files already emitted by an older
|
|
1047
|
+
* generator) goes through.
|
|
1048
|
+
*
|
|
1049
|
+
* A wildcard `If-None-Match: *` still yields `304`, and deliberately so: the
|
|
1050
|
+
* read paths evaluate it only *after* the payload is built, so it distinguishes
|
|
1051
|
+
* nothing beyond 200-versus-404 — which an unconditional request reveals too —
|
|
1052
|
+
* and it carries no stale representation.
|
|
1053
|
+
*
|
|
978
1054
|
* Idempotently ensures the feed table exists first, so it is safe to call from
|
|
979
1055
|
* a read route on a raw handle that has never been written to.
|
|
980
1056
|
*/
|
|
981
1057
|
async function getTableVersion(db, table) {
|
|
982
1058
|
const name = table?.trim();
|
|
983
1059
|
if (!name) throw new Error("getTableVersion requires a non-empty table name");
|
|
1060
|
+
if (!isChangeFeedObservableTable(name)) return randomUnobservableTableVersion();
|
|
984
1061
|
await ensureChangeFeedTable(db);
|
|
985
1062
|
const p = placeholders(db);
|
|
986
1063
|
const row = getQueryRows(await db.query(getEngine(db) === "postgres" ? `SELECT
|
|
@@ -995,6 +1072,15 @@ async function getTableVersion(db, table) {
|
|
|
995
1072
|
if (tableVersion != null) return toSeqNumber(tableVersion) + staged;
|
|
996
1073
|
return toSeqNumber(row?.horizon) + staged;
|
|
997
1074
|
}
|
|
1075
|
+
/**
|
|
1076
|
+
* A fresh, effectively-unique version for a table the feed does not observe
|
|
1077
|
+
* (#2937). 48 bits keeps it inside `Number.MAX_SAFE_INTEGER` so it round-trips
|
|
1078
|
+
* through the same numeric ETag path as a real sequence.
|
|
1079
|
+
*/
|
|
1080
|
+
function randomUnobservableTableVersion() {
|
|
1081
|
+
const bytes = randomBytes(6);
|
|
1082
|
+
return bytes[0] * 2 ** 40 + bytes[1] * 2 ** 32 + bytes[2] * 2 ** 24 + bytes[3] * 2 ** 16 + bytes[4] * 2 ** 8 + bytes[5];
|
|
1083
|
+
}
|
|
998
1084
|
function toSeqNumber(value) {
|
|
999
1085
|
return toSafeInteger(value ?? 0, "Change-feed sequence");
|
|
1000
1086
|
}
|
|
@@ -1046,18 +1132,46 @@ async function pruneChangeFeed(db, retention) {
|
|
|
1046
1132
|
if (horizon === 0) return { pruned: 0 };
|
|
1047
1133
|
let pruned = 0;
|
|
1048
1134
|
let prunedThrough = 0;
|
|
1135
|
+
const sensitiveTables = getChangeFeedSensitiveTables();
|
|
1136
|
+
if (sensitiveTables.length > 0) pruned += await deleteCounted(db, `seq < ${p(1)} AND table_name IN (${sensitiveTables.map((_, offset) => p(offset + 2)).join(", ")})`, [horizon, ...sensitiveTables], dryRun);
|
|
1137
|
+
/**
|
|
1138
|
+
* Under `dryRun` nothing was actually deleted, so the bounds below would
|
|
1139
|
+
* count the sensitive rows the purge already reported. Append an exclusion to
|
|
1140
|
+
* their predicates in that mode only — after a real delete the rows are gone
|
|
1141
|
+
* and the extra clause would be dead weight. `offset` is the number of
|
|
1142
|
+
* placeholders the caller's own predicate already consumed, so the numbered
|
|
1143
|
+
* dialects stay aligned.
|
|
1144
|
+
*/
|
|
1145
|
+
const excludeSensitive = (offset) => {
|
|
1146
|
+
if (!dryRun || sensitiveTables.length === 0) return {
|
|
1147
|
+
sql: "",
|
|
1148
|
+
params: []
|
|
1149
|
+
};
|
|
1150
|
+
return {
|
|
1151
|
+
sql: ` AND table_name NOT IN (${sensitiveTables.map((_, index) => p(offset + index + 1)).join(", ")})`,
|
|
1152
|
+
params: [...sensitiveTables]
|
|
1153
|
+
};
|
|
1154
|
+
};
|
|
1049
1155
|
if (maxRows != null) {
|
|
1050
1156
|
const pruneThrough = Math.min(horizon - Math.floor(maxRows), horizon - 1);
|
|
1051
1157
|
if (pruneThrough > 0) {
|
|
1052
1158
|
prunedThrough = pruneThrough;
|
|
1053
|
-
|
|
1159
|
+
const exclusion = excludeSensitive(1);
|
|
1160
|
+
pruned += await deleteCounted(db, `seq <= ${p(1)}${exclusion.sql}`, [pruneThrough, ...exclusion.params], dryRun);
|
|
1054
1161
|
}
|
|
1055
1162
|
}
|
|
1056
1163
|
if (maxAgeMs != null) {
|
|
1057
1164
|
const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
|
|
1058
1165
|
const firstRetained = toSeqNumber(getQueryRows(await db.query(`SELECT MIN(seq) AS first_retained FROM ${CHANGE_FEED_TABLE} WHERE created_at >= ${p(1)}`, cutoff))[0]?.first_retained);
|
|
1059
1166
|
const ageThrough = Math.min(firstRetained > 0 ? firstRetained - 1 : horizon - 1, horizon - 1);
|
|
1060
|
-
if (ageThrough > prunedThrough)
|
|
1167
|
+
if (ageThrough > prunedThrough) {
|
|
1168
|
+
const exclusion = excludeSensitive(2);
|
|
1169
|
+
pruned += await deleteCounted(db, `seq <= ${p(1)} AND seq > ${p(2)}${exclusion.sql}`, [
|
|
1170
|
+
ageThrough,
|
|
1171
|
+
prunedThrough,
|
|
1172
|
+
...exclusion.params
|
|
1173
|
+
], dryRun);
|
|
1174
|
+
}
|
|
1061
1175
|
}
|
|
1062
1176
|
return { pruned };
|
|
1063
1177
|
}
|
|
@@ -1140,6 +1254,7 @@ async function appendForInstance(instance, operation) {
|
|
|
1140
1254
|
try {
|
|
1141
1255
|
table = instance.tableName;
|
|
1142
1256
|
if (!isChangeFeedObservableTable(table)) return;
|
|
1257
|
+
if (isChangeFeedSensitiveWrite(table, instance.constructor)) return;
|
|
1143
1258
|
db = instance.db;
|
|
1144
1259
|
} catch {
|
|
1145
1260
|
return;
|
|
@@ -1182,6 +1297,7 @@ async function recordInstanceChanges(entries) {
|
|
|
1182
1297
|
for (const entry of entries) try {
|
|
1183
1298
|
const table = entry.instance.tableName;
|
|
1184
1299
|
if (!isChangeFeedObservableTable(table)) continue;
|
|
1300
|
+
if (isChangeFeedSensitiveWrite(table, entry.instance.constructor)) continue;
|
|
1185
1301
|
const id = entry.instance.id;
|
|
1186
1302
|
const tenantId = entry.instance.tenantId;
|
|
1187
1303
|
prepared.push({
|