@happyvertical/smrt-core 0.51.10 → 0.51.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/agents/change-feed.md +82 -0
  2. package/agents/generators.md +3 -1
  3. package/agents/schema-paths.md +19 -6
  4. package/dist/change-feed-sensitivity.d.ts +155 -0
  5. package/dist/change-feed-sensitivity.d.ts.map +1 -0
  6. package/dist/change-feed-sensitivity.js +139 -0
  7. package/dist/change-feed-sensitivity.js.map +1 -0
  8. package/dist/change-feed.d.ts +37 -10
  9. package/dist/change-feed.d.ts.map +1 -1
  10. package/dist/change-feed.js +128 -12
  11. package/dist/change-feed.js.map +1 -1
  12. package/dist/change-signals.d.ts.map +1 -1
  13. package/dist/change-signals.js +3 -0
  14. package/dist/change-signals.js.map +1 -1
  15. package/dist/class.d.ts +1 -1
  16. package/dist/class.d.ts.map +1 -1
  17. package/dist/class.js +4 -2
  18. package/dist/class.js.map +1 -1
  19. package/dist/decorators/index.d.ts +19 -0
  20. package/dist/decorators/index.d.ts.map +1 -1
  21. package/dist/decorators/index.js.map +1 -1
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +3 -2
  25. package/dist/manifest/static-manifest.js +1 -1
  26. package/dist/manifest/static-manifest.js.map +1 -1
  27. package/dist/manifest/store.js +1 -1
  28. package/dist/manifest.json +1 -1
  29. package/dist/migrations/differ.d.ts +64 -5
  30. package/dist/migrations/differ.d.ts.map +1 -1
  31. package/dist/migrations/differ.js +270 -37
  32. package/dist/migrations/differ.js.map +1 -1
  33. package/dist/migrations/index.d.ts +1 -1
  34. package/dist/migrations/index.d.ts.map +1 -1
  35. package/dist/migrations/index.js +2 -2
  36. package/dist/migrations/sqlite-rebuild.d.ts +41 -0
  37. package/dist/migrations/sqlite-rebuild.d.ts.map +1 -1
  38. package/dist/migrations/sqlite-rebuild.js +68 -13
  39. package/dist/migrations/sqlite-rebuild.js.map +1 -1
  40. package/dist/migrations.js +2 -2
  41. package/dist/registry/class-registration.d.ts +1 -1
  42. package/dist/registry/class-registration.d.ts.map +1 -1
  43. package/dist/registry/class-registration.js +50 -4
  44. package/dist/registry/class-registration.js.map +1 -1
  45. package/dist/registry/db-cache-key.d.ts +47 -0
  46. package/dist/registry/db-cache-key.d.ts.map +1 -0
  47. package/dist/registry/db-cache-key.js +201 -0
  48. package/dist/registry/db-cache-key.js.map +1 -0
  49. package/dist/registry/schema-builder.d.ts.map +1 -1
  50. package/dist/registry/schema-builder.js +6 -1
  51. package/dist/registry/schema-builder.js.map +1 -1
  52. package/dist/registry/types.d.ts +43 -0
  53. package/dist/registry/types.d.ts.map +1 -1
  54. package/dist/registry.d.ts +17 -1
  55. package/dist/registry.d.ts.map +1 -1
  56. package/dist/registry.js +20 -8
  57. package/dist/registry.js.map +1 -1
  58. package/dist/scanner/types.d.ts +2 -0
  59. package/dist/scanner/types.d.ts.map +1 -1
  60. package/dist/scanner/types.js.map +1 -1
  61. package/dist/schema/backfill.d.ts +16 -0
  62. package/dist/schema/backfill.d.ts.map +1 -0
  63. package/dist/schema/backfill.js +26 -0
  64. package/dist/schema/backfill.js.map +1 -0
  65. package/dist/schema/generator.d.ts.map +1 -1
  66. package/dist/schema/generator.js +3 -0
  67. package/dist/schema/generator.js.map +1 -1
  68. package/dist/schema/types.d.ts +20 -1
  69. package/dist/schema/types.d.ts.map +1 -1
  70. package/dist/smrt-knowledge.json +9 -9
  71. package/dist/vite-plugin/dev-plane-route.js +1 -1
  72. package/dist/vite-plugin/dev-plane-route.js.map +1 -1
  73. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  74. package/dist/vite-plugin/sveltekit-generator.js +54 -7
  75. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  76. package/package.json +10 -10
@@ -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. See {@link CHANGE_FEED_EXCLUDED_TABLES}.
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.table?.trim();
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 changes = getQueryRows(await db.query(sql, ...params)).map(rowToEntry);
963
+ const entries = getQueryRows(await db.query(sql, ...params)).map(rowToEntry);
913
964
  return {
914
- changes,
915
- cursor: changes.length === limit ? changes[changes.length - 1].seq : servedHorizon
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
- pruned += await deleteCounted(db, `seq <= ${p(1)}`, [pruneThrough], dryRun);
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) pruned += await deleteCounted(db, `seq <= ${p(1)} AND seq > ${p(2)}`, [ageThrough, prunedThrough], dryRun);
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({