@cosmicdrift/kumiko-framework 0.333.0 → 0.335.0

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 (172) hide show
  1. package/dist/api/auth-routes.js +19 -4
  2. package/dist/api/client-ip.js +3 -6
  3. package/dist/api/extra-route.js +3 -2
  4. package/dist/api/pii-leak-guard.js +7 -4
  5. package/dist/api/redis-sse-broker.d.ts +2 -0
  6. package/dist/api/redis-sse-broker.js +6 -7
  7. package/dist/api/routes.js +16 -17
  8. package/dist/api/server.js +14 -7
  9. package/dist/api/sse-broker.d.ts +3 -0
  10. package/dist/api/sse-broker.js +14 -0
  11. package/dist/api/sse-route.d.ts +1 -1
  12. package/dist/api/sse-route.js +39 -6
  13. package/dist/bun-db/connection.js +0 -2
  14. package/dist/bun-db/index.d.ts +1 -1
  15. package/dist/bun-db/index.js +1 -1
  16. package/dist/bun-db/query.d.ts +1 -0
  17. package/dist/bun-db/query.js +15 -4
  18. package/dist/crypto/kek-source.js +40 -36
  19. package/dist/crypto/kms-adapter.js +2 -2
  20. package/dist/db/apply-entity-event.js +8 -2
  21. package/dist/db/blind-index-cleanup.js +11 -1
  22. package/dist/db/eagerload.js +2 -1
  23. package/dist/db/entity-table-meta.js +5 -3
  24. package/dist/db/event-store-executor-context.js +13 -19
  25. package/dist/db/event-store-executor-read.js +39 -36
  26. package/dist/db/event-store-executor-write.js +15 -17
  27. package/dist/db/migrate-generator.js +27 -28
  28. package/dist/db/pg-error.js +9 -5
  29. package/dist/db/queries/backfill-pii.js +15 -5
  30. package/dist/db/queries/shadow-swap.js +5 -4
  31. package/dist/db/table-builder.d.ts +1 -0
  32. package/dist/db/table-builder.js +7 -15
  33. package/dist/db/tenant-db.js +18 -2
  34. package/dist/derivatives/variant-key.js +3 -1
  35. package/dist/engine/boot-validator/access-declarations.js +12 -3
  36. package/dist/engine/boot-validator/config-deps.js +13 -0
  37. package/dist/engine/boot-validator/entity-handler.js +13 -1
  38. package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
  39. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  40. package/dist/engine/boot-validator/index.js +6 -6
  41. package/dist/engine/boot-validator/nav.d.ts +2 -1
  42. package/dist/engine/boot-validator/nav.js +10 -20
  43. package/dist/engine/boot-validator/ownership.d.ts +1 -1
  44. package/dist/engine/boot-validator/ownership.js +2 -1
  45. package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
  46. package/dist/engine/boot-validator/pii-retention.js +44 -4
  47. package/dist/engine/boot-validator/projection-list-screens.js +8 -0
  48. package/dist/engine/boot-validator/query-output-columns.js +41 -5
  49. package/dist/engine/boot-validator/screens.d.ts +2 -2
  50. package/dist/engine/boot-validator/screens.js +138 -36
  51. package/dist/engine/boot-validator/transfer-graph.js +9 -6
  52. package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
  53. package/dist/engine/boot-validator/zod-shape.js +17 -4
  54. package/dist/engine/build-app-schema.js +65 -19
  55. package/dist/engine/build-config-feature-schema.js +106 -22
  56. package/dist/engine/changeset-changes.js +24 -10
  57. package/dist/engine/config-helpers.d.ts +2 -2
  58. package/dist/engine/entity-handlers.js +7 -2
  59. package/dist/engine/feature-ast/extractors/events.js +11 -8
  60. package/dist/engine/feature-ast/extractors/handlers.js +38 -16
  61. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
  62. package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
  63. package/dist/engine/feature-ast/parse.d.ts +1 -0
  64. package/dist/engine/feature-ast/parse.js +18 -3
  65. package/dist/engine/feature-ast/patch.js +80 -61
  66. package/dist/engine/feature-ast/patcher.d.ts +1 -0
  67. package/dist/engine/feature-ast/patcher.js +2 -1
  68. package/dist/engine/feature-ast/render.js +1 -1
  69. package/dist/engine/feature-entity-handlers.js +22 -0
  70. package/dist/engine/field-access.d.ts +2 -0
  71. package/dist/engine/field-access.js +40 -0
  72. package/dist/engine/index.d.ts +5 -4
  73. package/dist/engine/index.js +4 -3
  74. package/dist/engine/ownership.d.ts +1 -0
  75. package/dist/engine/ownership.js +15 -2
  76. package/dist/engine/pattern-library/mixed-schemas.js +15 -29
  77. package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
  78. package/dist/engine/pattern-library/shared-fields.js +17 -0
  79. package/dist/engine/registry-facade.js +3 -0
  80. package/dist/engine/role-assignment.d.ts +7 -1
  81. package/dist/engine/role-assignment.js +27 -7
  82. package/dist/engine/schema-builder.d.ts +2 -0
  83. package/dist/engine/schema-builder.js +20 -3
  84. package/dist/engine/screen-helpers.js +15 -4
  85. package/dist/engine/steps/_event-match.js +22 -3
  86. package/dist/engine/steps/_read-source.js +8 -0
  87. package/dist/engine/types/index.d.ts +1 -1
  88. package/dist/engine/where-rule-lint.js +22 -4
  89. package/dist/env/index.js +21 -1
  90. package/dist/errors/reasons.d.ts +0 -1
  91. package/dist/errors/reasons.js +2 -5
  92. package/dist/event-store/event-store.js +6 -1
  93. package/dist/event-store/provenance-append.js +4 -1
  94. package/dist/files/file-ref-entity.d.ts +6 -0
  95. package/dist/files/file-ref-table.d.ts +6 -0
  96. package/dist/files/file-routes.d.ts +0 -1
  97. package/dist/files/file-routes.js +22 -7
  98. package/dist/files/local-provider.js +13 -10
  99. package/dist/files/provider-resolver.js +20 -1
  100. package/dist/files/types.js +16 -1
  101. package/dist/i18n/request-locale.d.ts +3 -2
  102. package/dist/i18n/request-locale.js +16 -6
  103. package/dist/i18n/required-surface-keys.js +28 -35
  104. package/dist/i18n/settings-hub-keys.js +21 -4
  105. package/dist/jobs/index.d.ts +2 -2
  106. package/dist/jobs/index.js +1 -1
  107. package/dist/jobs/job-runner.d.ts +2 -0
  108. package/dist/jobs/job-runner.js +150 -59
  109. package/dist/migrations/index.d.ts +1 -1
  110. package/dist/migrations/index.js +1 -1
  111. package/dist/migrations/pending-rebuilds.d.ts +4 -0
  112. package/dist/migrations/pending-rebuilds.js +23 -1
  113. package/dist/observability/escape-hatch-report.d.ts +1 -0
  114. package/dist/observability/escape-hatch-report.js +3 -0
  115. package/dist/observability/index.d.ts +1 -0
  116. package/dist/observability/index.js +1 -0
  117. package/dist/pipeline/dispatch-batch.js +8 -3
  118. package/dist/pipeline/dispatch-query.d.ts +1 -1
  119. package/dist/pipeline/dispatch-query.js +2 -2
  120. package/dist/pipeline/dispatch-shared.d.ts +2 -3
  121. package/dist/pipeline/dispatch-shared.js +14 -9
  122. package/dist/pipeline/dispatch-stream.js +2 -5
  123. package/dist/pipeline/dispatch-write.js +27 -7
  124. package/dist/pipeline/dispatcher-utils.js +4 -1
  125. package/dist/pipeline/dispatcher.js +1 -2
  126. package/dist/pipeline/event-dispatcher-admin.js +12 -2
  127. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  128. package/dist/pipeline/event-dispatcher.js +26 -11
  129. package/dist/pipeline/index.d.ts +1 -1
  130. package/dist/pipeline/index.js +1 -1
  131. package/dist/pipeline/member-read-only-transaction.js +1 -14
  132. package/dist/pipeline/msp-rebuild.d.ts +1 -0
  133. package/dist/pipeline/msp-rebuild.js +1 -0
  134. package/dist/pipeline/system-hooks.d.ts +1 -1
  135. package/dist/pipeline/system-hooks.js +9 -7
  136. package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
  137. package/dist/pipeline/tenant-timezone-cache.js +20 -6
  138. package/dist/pipeline/write-result-masking.d.ts +6 -0
  139. package/dist/pipeline/write-result-masking.js +36 -0
  140. package/dist/redis/pubsub-signal.d.ts +2 -0
  141. package/dist/redis/pubsub-signal.js +2 -2
  142. package/dist/schema-cli.js +4 -14
  143. package/dist/search/meilisearch-adapter.js +17 -6
  144. package/dist/search/purge-subject.js +8 -3
  145. package/dist/stack/drain-jobs.js +24 -12
  146. package/dist/stack/table-helpers.js +10 -8
  147. package/dist/testing/closed-connection-error.js +2 -3
  148. package/dist/testing/shared-entities.d.ts +10 -0
  149. package/dist/testing/wait-for.js +1 -0
  150. package/dist/ui-types/index.d.ts +2 -2
  151. package/dist/ui-types/index.js +1 -1
  152. package/dist/upgrade-cli.d.ts +1 -0
  153. package/dist/upgrade-cli.js +36 -15
  154. package/package.json +4 -4
  155. package/src/changes.json +218 -0
  156. package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
  157. package/src/scripts/codemod/migrate-db-raw.ts +16 -8
  158. package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
  159. package/src/scripts/codemod/pii-personal-migration.ts +56 -19
  160. package/src/scripts/codemod/unprocessable-error-details-reason.ts +83 -26
  161. package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
  162. package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
  163. package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
  164. package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
  165. package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
  166. package/dist/scripts/codemod/migrate-db-raw.js +0 -345
  167. package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
  168. package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
  169. package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
  170. package/dist/scripts/codemod/pii-personal-migration.js +0 -615
  171. package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
  172. package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
@@ -10,20 +10,27 @@ const DEFAULT_TTL_MS = 5 * 60 * 1000;
10
10
  const DEFAULT_MAX_ENTRIES = 1000;
11
11
  export function createTenantTimezoneCache(opts = {}) {
12
12
  const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
13
- const maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
13
+ // 0 or negative would never evict (size < max is never true) and grow unbounded.
14
+ const maxEntries = Math.max(1, opts.maxEntries ?? DEFAULT_MAX_ENTRIES);
14
15
  const now = opts.now ?? (() => Date.now());
15
16
  // Map insertion order doubles as LRU order: touch = delete+re-insert.
16
17
  const entries = new Map();
18
+ let generation = 0;
17
19
  function evictOldestIfFull() {
18
20
  // skip: cache has room, nothing to evict
19
21
  if (entries.size < maxEntries)
20
22
  return;
21
23
  const oldestKey = entries.keys().next().value;
22
- // skip: defensive — only reachable if maxEntries is 0 (cache disabled) and entries is empty
24
+ // skip: defensive — maxEntries >= 1 guarantees a non-empty map here
23
25
  if (oldestKey === undefined)
24
26
  return;
25
27
  entries.delete(oldestKey);
26
28
  }
29
+ function set(tenantId, value) {
30
+ entries.delete(tenantId);
31
+ evictOldestIfFull();
32
+ entries.set(tenantId, { value, expiresAt: now() + ttlMs });
33
+ }
27
34
  return {
28
35
  get(tenantId) {
29
36
  const hit = entries.get(tenantId);
@@ -37,15 +44,22 @@ export function createTenantTimezoneCache(opts = {}) {
37
44
  entries.set(tenantId, hit);
38
45
  return { value: hit.value };
39
46
  },
40
- set(tenantId, value) {
41
- entries.delete(tenantId);
42
- evictOldestIfFull();
43
- entries.set(tenantId, { value, expiresAt: now() + ttlMs });
47
+ set,
48
+ generation() {
49
+ return generation;
50
+ },
51
+ setIfFresh(tenantId, value, generationAtRead) {
52
+ // skip: an invalidation ran since the read started, the value may be pre-write
53
+ if (generationAtRead !== generation)
54
+ return;
55
+ set(tenantId, value);
44
56
  },
45
57
  invalidate(tenantId) {
58
+ generation++;
46
59
  entries.delete(tenantId);
47
60
  },
48
61
  clear() {
62
+ generation++;
49
63
  entries.clear();
50
64
  },
51
65
  size() {
@@ -0,0 +1,6 @@
1
+ import type { WriteResult } from "../engine/types/index.js";
2
+ import type { BatchCommand, BatchResult, DispatchContext } from "./dispatch-shared.js";
3
+ type EntityLookup = Pick<DispatchContext["registry"], "getHandlerEntity" | "getEntity">;
4
+ export declare function maskWriteResultForClient(registry: EntityLookup, handlerType: string, result: WriteResult): WriteResult;
5
+ export declare function maskBatchResultForClient(registry: EntityLookup, commands: readonly BatchCommand[], batch: BatchResult): BatchResult;
6
+ export {};
@@ -0,0 +1,36 @@
1
+ import { maskWriteOnlyFields } from "../engine/field-access.js";
2
+ import { isLifecycleResult } from "./dispatcher-utils.js";
3
+ function maskLifecycleOrRow(entity, data) {
4
+ if (typeof data !== "object" || data === null || Array.isArray(data))
5
+ return data;
6
+ if (!isLifecycleResult(data))
7
+ return maskWriteOnlyFields(entity, data); // @cast-boundary engine-payload
8
+ if (data.kind === "save") {
9
+ return {
10
+ ...data,
11
+ data: maskWriteOnlyFields(entity, data.data),
12
+ changes: maskWriteOnlyFields(entity, data.changes),
13
+ previous: maskWriteOnlyFields(entity, data.previous),
14
+ };
15
+ }
16
+ return { ...data, data: maskWriteOnlyFields(entity, data.data) };
17
+ }
18
+ // Copy-on-mask: the original results still feed postSaveBatch hooks, which
19
+ // need the plaintext. Only what leaves the dispatcher is masked.
20
+ export function maskWriteResultForClient(registry, handlerType, result) {
21
+ if (!result.isSuccess)
22
+ return result;
23
+ const entityName = registry.getHandlerEntity(handlerType);
24
+ const entity = entityName ? registry.getEntity(entityName) : undefined;
25
+ if (!entity)
26
+ return result;
27
+ const data = maskLifecycleOrRow(entity, result.data);
28
+ return data === result.data ? result : { ...result, data };
29
+ }
30
+ export function maskBatchResultForClient(registry, commands, batch) {
31
+ const results = batch.results.map((result, index) => {
32
+ const command = commands[index];
33
+ return command ? maskWriteResultForClient(registry, command.type, result) : result;
34
+ });
35
+ return { ...batch, results };
36
+ }
@@ -1,3 +1,4 @@
1
+ import { type RedisClientOptions } from "./client.js";
1
2
  export type PubSubSignal = {
2
3
  publish(channel: string, payload: unknown): void;
3
4
  onMessage(listener: (channel: string, payload: unknown) => void): void;
@@ -7,5 +8,6 @@ export type PubSubSignalOptions = {
7
8
  readonly redisUrl: string;
8
9
  readonly channelPattern: string;
9
10
  readonly label: string;
11
+ readonly clientOptions?: RedisClientOptions;
10
12
  };
11
13
  export declare function createRedisPubSubSignal(opts: PubSubSignalOptions): PubSubSignal;
@@ -9,8 +9,8 @@ function logConnectionError(label, role) {
9
9
  };
10
10
  }
11
11
  export function createRedisPubSubSignal(opts) {
12
- const publisher = createRedisClient(opts.redisUrl);
13
- const subscriber = createRedisClient(opts.redisUrl);
12
+ const publisher = createRedisClient(opts.redisUrl, opts.clientOptions);
13
+ const subscriber = createRedisClient(opts.redisUrl, opts.clientOptions);
14
14
  publisher.on("error", logConnectionError(opts.label, "publisher"));
15
15
  subscriber.on("error", logConnectionError(opts.label, "subscriber"));
16
16
  const listeners = new Set();
@@ -14,7 +14,7 @@ import { assertValidMigrationName, baselineMigrations, createDbConnection, diffR
14
14
  import { validateBoot } from "./engine/boot-validator.js";
15
15
  import { createRegistry } from "./engine/registry.js";
16
16
  import { createEventsTable } from "./event-store/index.js";
17
- import { queueRebuildsFromMarkers, runPendingRebuilds } from "./migrations/index.js";
17
+ import { queueRebuildsFromMarkers, reportPendingRebuildRun, runPendingRebuilds, } from "./migrations/index.js";
18
18
  import { createEventConsumerStateTable, createProjectionStateTable } from "./pipeline/index.js";
19
19
  import { ensureTemporalPolyfill } from "./time/index.js";
20
20
  const SNAPSHOT_FILENAME = ".snapshot.json";
@@ -49,6 +49,7 @@ function nextSequenceNumber(migrationsDir) {
49
49
  }
50
50
  return max + 1;
51
51
  }
52
+ const SCHEMA_SUBCOMMANDS = ["generate", "validate", "apply", "baseline", "status"];
52
53
  /**
53
54
  * Runs a schema-CLI subcommand. `appCwd` is the app workspace root (where
54
55
  * `kumiko/schema.ts` + `kumiko/migrations/` live). Returns a process exit code.
@@ -319,18 +320,7 @@ export async function runSchemaCli(argv, appCwd, out, options = {}) {
319
320
  });
320
321
  const registry = createRegistry(options.features);
321
322
  const rebuildRun = await runPendingRebuilds(db, registry, { thisRunTables });
322
- if (rebuildRun.rebuilt.length > 0) {
323
- out.log(` Rebuild ${rebuildRun.rebuilt.length} Projection(s)…`);
324
- for (const r of rebuildRun.rebuilt) {
325
- out.log(` ↻ ${r.projection} (${r.eventsProcessed} events)`);
326
- }
327
- out.log("");
328
- }
329
- if (rebuildRun.failed.length > 0) {
330
- throw new Error(`Projection rebuild failed for: ${rebuildRun.failed
331
- .map((f) => `${f.projection} (${f.error})`)
332
- .join("; ")}. Table(s) stay queued in kumiko_pending_rebuilds — retried on the next apply.`);
333
- }
323
+ reportPendingRebuildRun(rebuildRun, out.log);
334
324
  }
335
325
  return 0;
336
326
  }
@@ -421,7 +411,7 @@ export async function runSchemaCli(argv, appCwd, out, options = {}) {
421
411
  // move on without ever running a migration.
422
412
  if (sub !== undefined) {
423
413
  out.err(` Unbekanntes Subcommand: "${sub}"`);
424
- out.err(" Erlaubt: generate | validate | apply | baseline | status");
414
+ out.err(` Erlaubt: ${SCHEMA_SUBCOMMANDS.join(" | ")}`);
425
415
  return 1;
426
416
  }
427
417
  out.log("");
@@ -39,6 +39,12 @@ export function createMeilisearchAdapter(options) {
39
39
  // always wins and is never overwritten by the lazy default — its promise
40
40
  // sits in the map exactly like a lazily-triggered one.
41
41
  const configuredTenants = new Map();
42
+ // Exact inverse of meilisearchTenantIndex. A bare startsWith(prefix) would also
43
+ // match a longer sibling prefix (`kumiko_` vs `kumiko_staging_`) sharing the
44
+ // instance; tenant ids never contain `_`, which excludes that sibling.
45
+ function isOwnTenantIndex(uid) {
46
+ return uid.startsWith(prefix) && /^t[0-9A-Za-z-]+$/.test(uid.slice(prefix.length));
47
+ }
42
48
  async function applyConfig(tenantId, config) {
43
49
  const index = client.index(meilisearchTenantIndex(prefix, tenantId));
44
50
  const fields = config.rankingFields ?? config.searchableFields;
@@ -124,18 +130,23 @@ export function createMeilisearchAdapter(options) {
124
130
  for (let offset = 0;; offset += INDEX_LIST_PAGE_SIZE) {
125
131
  const page = await client.getIndexes({ limit: INDEX_LIST_PAGE_SIZE, offset });
126
132
  for (const index of page.results) {
127
- if (index.uid.startsWith(prefix))
133
+ if (isOwnTenantIndex(index.uid))
128
134
  uids.push(index.uid);
129
135
  }
130
136
  if (page.results.length === 0 || offset + page.results.length >= page.total)
131
137
  break;
132
138
  }
133
- for (const uid of uids) {
134
- await awaitSucceededTask(client.deleteIndex(uid), REMOVE_TOLERATED_ERROR_CODES);
139
+ // Cleared even when a delete throws midway: otherwise ensureConfigured treats the
140
+ // already-dropped tenants as configured and the next index() recreates an
141
+ // unconfigured index ("not filterable" on filtered search).
142
+ try {
143
+ for (const uid of uids) {
144
+ await awaitSucceededTask(client.deleteIndex(uid), REMOVE_TOLERATED_ERROR_CODES);
145
+ }
146
+ }
147
+ finally {
148
+ configuredTenants.clear();
135
149
  }
136
- // Otherwise ensureConfigured treats the dropped tenants as configured and the
137
- // next index() recreates an unconfigured index ("not filterable" on filtered search).
138
- configuredTenants.clear();
139
150
  return uids.length;
140
151
  },
141
152
  async removeBatch(tenantId, items) {
@@ -12,7 +12,7 @@ import { isSelfPiiField } from "../crypto/is-self-pii-field.js";
12
12
  import { collectSearchableSubjectFields } from "../crypto/subject-resolver.js";
13
13
  import { resolveTableName } from "../db/entity-table-meta.js";
14
14
  import { executeRawQueryRead } from "../db/queries/raw-sql.js";
15
- import { tableExists } from "../db/schema-inspection.js";
15
+ import { columnNamesOf, tableExists } from "../db/schema-inspection.js";
16
16
  import { toSnakeCase } from "../utils/case.js";
17
17
  function finalizePredicateParts(parts, params) {
18
18
  if (parts.length === 0)
@@ -140,8 +140,8 @@ subject) {
140
140
  const seen = new Set();
141
141
  for (const feature of features.values()) {
142
142
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
143
- const fields = collectSearchableSubjectFields(entity);
144
- if (fields.length === 0)
143
+ const declaredFields = collectSearchableSubjectFields(entity);
144
+ if (declaredFields.length === 0)
145
145
  continue;
146
146
  const tableName = resolveTableName(entityName, entity, undefined);
147
147
  // Same post-eraseKey hazard as nullBlindIndexesForSubject (fw#2550):
@@ -149,6 +149,11 @@ subject) {
149
149
  // (and the audit event after it).
150
150
  if (!(await tableExists(db, tableName)))
151
151
  continue;
152
+ // A migrated table can still lack a later-added searchable column.
153
+ const columns = await columnNamesOf(db, tableName);
154
+ const fields = declaredFields.filter((f) => columns.has(toSnakeCase(f)));
155
+ if (fields.length === 0)
156
+ continue;
152
157
  const predicate = buildSubjectPredicate(entity, fields, likePattern, entityName, subject);
153
158
  const rows = await collectMatchingRowsForEntity(db, tableName, predicate.sql, predicate.params);
154
159
  for (const row of rows) {
@@ -76,24 +76,36 @@ export async function drainJobs(stack, tracker) {
76
76
  .filter((consumer) => (consumer.delivery ?? "shared") === "shared")
77
77
  .map((consumer) => consumer.name);
78
78
  try {
79
- let consecutiveIdlePasses = 0;
80
- while (consecutiveIdlePasses < REQUIRED_IDLE_PASSES) {
81
- await eventDispatcher?.runOnce();
82
- const pending = await jobRunner.countPendingJobs();
83
- const caughtUp = await sharedConsumersCaughtUp(db, sharedConsumerNames);
84
- consecutiveIdlePasses = pending === 0 && caughtUp ? consecutiveIdlePasses + 1 : 0;
85
- if (consecutiveIdlePasses < REQUIRED_IDLE_PASSES) {
86
- await Promise.race([tracker.waitForActivity(), sleep(DRAIN_IDLE_POLL_MS)]);
79
+ try {
80
+ let consecutiveIdlePasses = 0;
81
+ while (consecutiveIdlePasses < REQUIRED_IDLE_PASSES) {
82
+ await eventDispatcher?.runOnce();
83
+ const pending = await jobRunner.countPendingJobs();
84
+ const caughtUp = await sharedConsumersCaughtUp(db, sharedConsumerNames);
85
+ consecutiveIdlePasses = pending === 0 && caughtUp ? consecutiveIdlePasses + 1 : 0;
86
+ if (consecutiveIdlePasses < REQUIRED_IDLE_PASSES) {
87
+ await Promise.race([tracker.waitForActivity(), sleep(DRAIN_IDLE_POLL_MS)]);
88
+ }
87
89
  }
88
90
  }
91
+ catch (err) {
92
+ // A failed job often kills its consumer, so the drain error is only the follow-up symptom.
93
+ if (tracker.failures.size === 0)
94
+ throw err;
95
+ const message = err instanceof Error ? err.message : String(err);
96
+ throw new Error(`${message}; ${describeJobFailures(tracker)}`, { cause: err });
97
+ }
89
98
  if (tracker.failures.size > 0) {
90
- const details = [...tracker.failures.values()]
91
- .map((failure) => `${failure.jobName}: ${failure.error}`)
92
- .join("; ");
93
- throw new Error(`drainJobs: job(s) failed: ${details}`);
99
+ throw new Error(`drainJobs: ${describeJobFailures(tracker)}`);
94
100
  }
95
101
  }
96
102
  finally {
97
103
  tracker.clear();
98
104
  }
99
105
  }
106
+ function describeJobFailures(tracker) {
107
+ const details = [...tracker.failures.values()]
108
+ .map((failure) => `${failure.jobName}: ${failure.error}`)
109
+ .join("; ");
110
+ return `job(s) failed: ${details}`;
111
+ }
@@ -117,12 +117,6 @@ function renderColumnType(col) {
117
117
  * rebuilding it.
118
118
  */
119
119
  export async function resetEventStore(stack, extraTables = []) {
120
- // A pass the dispatcher's own timer/LISTEN already started (from a
121
- // preceding test) may still be writing into the tables truncated below —
122
- // drain it first so the TRUNCATE never races a write it didn't cause.
123
- if (stack.eventDispatcher) {
124
- await stack.eventDispatcher.drain();
125
- }
126
120
  const frameworkTables = [
127
121
  "kumiko_events",
128
122
  "kumiko_event_consumers",
@@ -131,8 +125,16 @@ export async function resetEventStore(stack, extraTables = []) {
131
125
  "kumiko_projections",
132
126
  ];
133
127
  const extraNames = extraTables.map((t) => (typeof t === "string" ? t : tableNameOf(t)));
134
- await truncateTablesRestartIdentity(stack.db, [...frameworkTables, ...extraNames]);
128
+ const truncateAndReregister = async () => {
129
+ await truncateTablesRestartIdentity(stack.db, [...frameworkTables, ...extraNames]);
130
+ await stack.eventDispatcher?.ensureRegistered();
131
+ };
132
+ // The dispatcher's own timer/LISTEN must not start a pass between the
133
+ // TRUNCATE and the consumer re-registration, so they are paused for the whole reset.
135
134
  if (stack.eventDispatcher) {
136
- await stack.eventDispatcher.ensureRegistered();
135
+ await stack.eventDispatcher.withBackgroundPassesPaused(truncateAndReregister);
136
+ }
137
+ else {
138
+ await truncateAndReregister();
137
139
  }
138
140
  }
@@ -2,10 +2,9 @@
2
2
  // the production matcher checks driver-specific codes a fake can't reproduce.
3
3
  import postgres from "postgres";
4
4
  import { isClosedConnectionError } from "../bun-db/query.js";
5
+ import { requireEnv } from "../stack/db.js";
5
6
  export function testDatabaseUrl() {
6
- return (process.env["TEST_DATABASE_URL"] ??
7
- process.env["DATABASE_URL"] ??
8
- "postgresql://kumiko:kumiko@localhost:15432/kumiko_test");
7
+ return requireEnv("TEST_DATABASE_URL");
9
8
  }
10
9
  const MAX_WARMUP_ROUNDS = 10;
11
10
  const READS_PER_ROUND = 5;
@@ -9,6 +9,7 @@ export declare const sharedWidgetEntity: import("@cosmicdrift/kumiko-types/field
9
9
  readonly filterable?: boolean;
10
10
  readonly encrypted?: boolean;
11
11
  readonly sensitive?: boolean;
12
+ readonly writeOnly?: boolean;
12
13
  readonly readAsInstruction?: boolean;
13
14
  readonly format?: "email" | "url" | "phone" | "password";
14
15
  readonly default?: string;
@@ -31,6 +32,7 @@ export declare const sharedWidgetTable: import("../db/table-builder.js").EntityT
31
32
  readonly filterable?: boolean;
32
33
  readonly encrypted?: boolean;
33
34
  readonly sensitive?: boolean;
35
+ readonly writeOnly?: boolean;
34
36
  readonly readAsInstruction?: boolean;
35
37
  readonly format?: "email" | "url" | "phone" | "password";
36
38
  readonly default?: string;
@@ -53,6 +55,7 @@ export declare const sharedUserEntity: import("@cosmicdrift/kumiko-types/fields"
53
55
  readonly filterable?: boolean;
54
56
  readonly encrypted?: boolean;
55
57
  readonly sensitive?: boolean;
58
+ readonly writeOnly?: boolean;
56
59
  readonly readAsInstruction?: boolean;
57
60
  readonly format?: "email" | "url" | "phone" | "password";
58
61
  readonly default?: string;
@@ -73,6 +76,7 @@ export declare const sharedUserEntity: import("@cosmicdrift/kumiko-types/fields"
73
76
  readonly filterable?: boolean;
74
77
  readonly encrypted?: boolean;
75
78
  readonly sensitive?: boolean;
79
+ readonly writeOnly?: boolean;
76
80
  readonly readAsInstruction?: boolean;
77
81
  readonly format?: "email" | "url" | "phone" | "password";
78
82
  readonly default?: string;
@@ -93,6 +97,7 @@ export declare const sharedUserEntity: import("@cosmicdrift/kumiko-types/fields"
93
97
  readonly filterable?: boolean;
94
98
  readonly encrypted?: boolean;
95
99
  readonly sensitive?: boolean;
100
+ readonly writeOnly?: boolean;
96
101
  readonly readAsInstruction?: boolean;
97
102
  readonly format?: "email" | "url" | "phone" | "password";
98
103
  readonly default?: string;
@@ -118,6 +123,7 @@ export declare const sharedUserTable: import("../db/table-builder.js").EntityTab
118
123
  readonly filterable?: boolean;
119
124
  readonly encrypted?: boolean;
120
125
  readonly sensitive?: boolean;
126
+ readonly writeOnly?: boolean;
121
127
  readonly readAsInstruction?: boolean;
122
128
  readonly format?: "email" | "url" | "phone" | "password";
123
129
  readonly default?: string;
@@ -138,6 +144,7 @@ export declare const sharedUserTable: import("../db/table-builder.js").EntityTab
138
144
  readonly filterable?: boolean;
139
145
  readonly encrypted?: boolean;
140
146
  readonly sensitive?: boolean;
147
+ readonly writeOnly?: boolean;
141
148
  readonly readAsInstruction?: boolean;
142
149
  readonly format?: "email" | "url" | "phone" | "password";
143
150
  readonly default?: string;
@@ -158,6 +165,7 @@ export declare const sharedUserTable: import("../db/table-builder.js").EntityTab
158
165
  readonly filterable?: boolean;
159
166
  readonly encrypted?: boolean;
160
167
  readonly sensitive?: boolean;
168
+ readonly writeOnly?: boolean;
161
169
  readonly readAsInstruction?: boolean;
162
170
  readonly format?: "email" | "url" | "phone" | "password";
163
171
  readonly default?: string;
@@ -183,6 +191,7 @@ export declare const sharedItemEntity: import("@cosmicdrift/kumiko-types/fields"
183
191
  readonly filterable?: boolean;
184
192
  readonly encrypted?: boolean;
185
193
  readonly sensitive?: boolean;
194
+ readonly writeOnly?: boolean;
186
195
  readonly readAsInstruction?: boolean;
187
196
  readonly format?: "email" | "url" | "phone" | "password";
188
197
  readonly default?: string;
@@ -224,6 +233,7 @@ export declare const sharedItemTable: import("../db/table-builder.js").EntityTab
224
233
  readonly filterable?: boolean;
225
234
  readonly encrypted?: boolean;
226
235
  readonly sensitive?: boolean;
236
+ readonly writeOnly?: boolean;
227
237
  readonly readAsInstruction?: boolean;
228
238
  readonly format?: "email" | "url" | "phone" | "password";
229
239
  readonly default?: string;
@@ -30,6 +30,7 @@ fn, options) {
30
30
  // skip: condition already true — no further polling
31
31
  return;
32
32
  }
33
+ lastError = undefined;
33
34
  }
34
35
  catch (err) {
35
36
  lastError = err;
@@ -5,7 +5,7 @@ export type { ParsedRefTarget } from "../engine/parse-ref-target.js";
5
5
  export { parseRefTarget } from "../engine/parse-ref-target.js";
6
6
  export type { FieldsOrGroupsSection } from "../engine/screen-helpers.js";
7
7
  export { evalFieldCondition, explicitListScreenId, isExtensionEditSection, isFieldsEditSection, isFormatSpec, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../engine/screen-helpers.js";
8
- export type { BooleanFieldDef, DateFieldDef, EmbeddedDerivedCellDef, EntityDefinition, FieldDefinition, FileFieldDef, FilesFieldDef, ImageFieldDef, ImagesFieldDef, NumberFieldDef, SelectFieldDef, SelectOptionTone, TextFieldDef, } from "../engine/types/fields.js";
8
+ export type { BooleanFieldDef, DateFieldDef, EmbeddedDerivedCellDef, EntityDefinition, FieldDefinition, FileFieldDef, FilesFieldDef, ImageFieldDef, ImagesFieldDef, NumberFieldDef, OptionsQueryPayload, OptionsQueryPayloadValue, SelectFieldDef, SelectOptionTone, TextFieldDef, } from "../engine/types/fields.js";
9
9
  export type { AccessRule, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, RoleAccessPersonalData, RoleAccessRule, } from "../engine/types/handlers.js";
10
10
  export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.js";
11
11
  export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav.js";
@@ -14,7 +14,7 @@ export { metricField } from "../engine/types/screen.js";
14
14
  export type { TargetRef } from "../engine/types/target-ref.js";
15
15
  export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node.js";
16
16
  export type { WorkspaceDefinition } from "../engine/types/workspace.js";
17
- export { PROJECTION_DETAIL_ENTITY, WRITE_FORM_SECTION_ENTITY, } from "../i18n/required-surface-keys.js";
17
+ export { ACTION_FORM_ENTITY, PROJECTION_DETAIL_ENTITY, WRITE_FORM_SECTION_ENTITY, } from "../i18n/required-surface-keys.js";
18
18
  export type { AppSchema, FeatureSchema, QualifiedContentCollection, WorkspaceSchema, } from "./app-schema.js";
19
19
  export { type Formality, formalLocaleTag } from "./formality.js";
20
20
  export type { ListRowMetaColumnType, ListRowMetaReference, ReferenceLookupSource, SystemReferenceLabel, } from "./list-row-meta.js";
@@ -25,7 +25,7 @@ export { parseRefTarget } from "../engine/parse-ref-target.js";
25
25
  export { evalFieldCondition, explicitListScreenId, isExtensionEditSection, isFieldsEditSection, isFormatSpec, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../engine/screen-helpers.js";
26
26
  export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.js";
27
27
  export { metricField } from "../engine/types/screen.js";
28
- export { PROJECTION_DETAIL_ENTITY, WRITE_FORM_SECTION_ENTITY, } from "../i18n/required-surface-keys.js";
28
+ export { ACTION_FORM_ENTITY, PROJECTION_DETAIL_ENTITY, WRITE_FORM_SECTION_ENTITY, } from "../i18n/required-surface-keys.js";
29
29
  export { formalLocaleTag } from "./formality.js";
30
30
  export { LIST_ROW_META_COLUMNS, LIST_ROW_META_REFERENCES, REFERENCE_LOOKUP_SOURCES, SYSTEM_REFERENCE_LABELS, } from "./list-row-meta.js";
31
31
  export { resolveTranslationValue, translationValueOtherText, } from "./plural.js";
@@ -2,6 +2,7 @@ export type UpgradeCliOut = {
2
2
  readonly log: (line: string) => void;
3
3
  readonly err: (line: string) => void;
4
4
  };
5
+ export declare function findFeatureChangelogFiles(featuresDir: string): string[];
5
6
  export declare function findPackageChangelogFiles(cwd: string): string[];
6
7
  export declare function findFeaturesDirs(cwd: string): string[];
7
8
  export declare function findCodemodScriptsRoot(repoRoot: string): string | null;
@@ -56,10 +56,10 @@ function readChangelogFile(filePath) {
56
56
  return [];
57
57
  }
58
58
  }
59
- function collectChangelogs(featuresDir) {
59
+ export function findFeatureChangelogFiles(featuresDir) {
60
60
  if (!existsSync(featuresDir))
61
61
  return [];
62
- const entries = [];
62
+ const files = [];
63
63
  const features = readdirSync(featuresDir, { withFileTypes: true })
64
64
  .filter((d) => d.isDirectory())
65
65
  .map((d) => d.name);
@@ -71,9 +71,13 @@ function collectChangelogs(featuresDir) {
71
71
  const srcLayout = join(featuresDir, name, "src", "changes.json");
72
72
  const flatLayout = join(featuresDir, name, "changes.json");
73
73
  const changelogPath = existsSync(srcLayout) ? srcLayout : flatLayout;
74
- entries.push(...readChangelogFile(changelogPath));
74
+ if (existsSync(changelogPath))
75
+ files.push(changelogPath);
75
76
  }
76
- return entries;
77
+ return files;
78
+ }
79
+ function collectChangelogs(featuresDir) {
80
+ return findFeatureChangelogFiles(featuresDir).flatMap(readChangelogFile);
77
81
  }
78
82
  // bundled-features is excluded: findFeaturesDirs collects it per feature
79
83
  // (src/<feature>/changes.json), every other package has one src/changes.json.
@@ -99,7 +103,8 @@ export function findPackageChangelogFiles(cwd) {
99
103
  const scopeDir = join(dir, "node_modules/@cosmicdrift");
100
104
  if (existsSync(scopeDir)) {
101
105
  const pkgNames = readdirSync(scopeDir, { withFileTypes: true })
102
- .filter((d) => d.isDirectory() && d.name !== "kumiko-bundled-features")
106
+ // Dirent has lstat semantics: workspace-linked packages are symlinks.
107
+ .filter((d) => (d.isDirectory() || d.isSymbolicLink()) && d.name !== "kumiko-bundled-features")
103
108
  .map((d) => d.name);
104
109
  for (const name of pkgNames) {
105
110
  if (seenNames.has(name))
@@ -257,21 +262,21 @@ function writeUpgradeMarker(targetDir, marker) {
257
262
  mkdirSync(dir, { recursive: true });
258
263
  writeFileSync(join(dir, "upgrade-state.json"), `${JSON.stringify(marker, null, 2)}\n`, "utf-8");
259
264
  }
260
- // Missing, unreadable, or invalid marker means no baseline was ever recorded
261
- // — most commonly a bootstrap app that never ran `--apply` (fw#2299) — so
262
- // this reports "no marker" instead of throwing.
265
+ // A missing marker is a bootstrap app that never ran `--apply` (fw#2299); an
266
+ // existing but unreadable one must not silently fall back to the installed
267
+ // version, or a bare `--apply` would skip every codemod in between.
263
268
  function readMarkerVersion(targetDir) {
264
269
  const markerPath = join(targetDir, ".kumiko", "upgrade-state.json");
265
270
  if (!existsSync(markerPath))
266
- return null;
271
+ return { kind: "missing" };
267
272
  try {
268
273
  const parsed = JSON.parse(readFileSync(markerPath, "utf-8"));
269
274
  return typeof parsed.version === "string" && SEMVER_RE.test(parsed.version)
270
- ? parsed.version
271
- : null;
275
+ ? { kind: "ok", version: parsed.version }
276
+ : { kind: "invalid" };
272
277
  }
273
278
  catch {
274
- return null;
279
+ return { kind: "invalid" };
275
280
  }
276
281
  }
277
282
  /** Highest pending version strictly below the earliest open manual breaking
@@ -337,10 +342,12 @@ markerVersion) {
337
342
  : " ✓ No breaking changes pending.");
338
343
  if (!dryRun) {
339
344
  const markerVer = markerVersionForPending(pending, manualEntries, markerVersion);
345
+ const pendingManual = manualEntries.map((e) => ({ version: e.version, title: e.title }));
340
346
  writeUpgradeMarker(targetDir, {
341
347
  version: markerVer,
342
348
  appliedAt: Temporal.Now.instant().toString(),
343
349
  codemods: [],
350
+ ...(pendingManual.length > 0 && { pendingManual }),
344
351
  });
345
352
  out.log(` ✓ Applied 0 codemod(s). Wrote ${join(targetDir, ".kumiko/upgrade-state.json")}`);
346
353
  logManualMarkerOutcome(out, manualEntries, markerVer);
@@ -431,7 +438,14 @@ export async function runUpgradeCli(argv, cwd, out, options) {
431
438
  // when the marker says otherwise — a bare `--apply` would then always
432
439
  // report "Nothing new" and bootstrap the marker onto the installed
433
440
  // version, hiding breaking changes the marker never actually saw.
434
- const currentVersion = fromFlag ?? readMarkerVersion(targetDir) ?? installedVersion;
441
+ const marker = fromFlag === undefined ? readMarkerVersion(targetDir) : { kind: "missing" };
442
+ if (marker.kind === "invalid") {
443
+ out.err("");
444
+ out.err(" invalid .kumiko/upgrade-state.json, fix it or pass --from <version>");
445
+ out.err("");
446
+ return 1;
447
+ }
448
+ const currentVersion = fromFlag ?? (marker.kind === "ok" ? marker.version : undefined) ?? installedVersion;
435
449
  if (!currentVersion) {
436
450
  out.err("");
437
451
  out.err(" Could not detect Kumiko version.");
@@ -465,10 +479,17 @@ export async function runUpgradeCli(argv, cwd, out, options) {
465
479
  if (getFlag(args, "apply")) {
466
480
  // Marker must reflect what is actually installed under the target (or
467
481
  // cwd), not the filter baseline above — otherwise CI stays green forever.
468
- const markerVersion = (dirFlag ? readCurrentVersion(targetDir) : null) ?? installedVersion ?? currentVersion;
482
+ const markerVersion = dirFlag ? readCurrentVersion(targetDir) : installedVersion;
469
483
  const dryRun = getFlag(args, "dry-run");
484
+ if (markerVersion === null && !dryRun) {
485
+ out.err("");
486
+ out.err(` Could not detect the installed Kumiko version under ${targetDir}.`);
487
+ out.err(" Refusing to write an upgrade marker without it (--from is only a filter).");
488
+ out.err("");
489
+ return 1;
490
+ }
470
491
  out.log("");
471
- const code = await applyCodemods(out, pending, repoRoot, targetDir, dryRun, markerVersion);
492
+ const code = await applyCodemods(out, pending, repoRoot, targetDir, dryRun, markerVersion ?? currentVersion);
472
493
  out.log("");
473
494
  return code;
474
495
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.333.0",
3
+ "version": "0.335.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -206,8 +206,8 @@
206
206
  "./package.json": "./package.json"
207
207
  },
208
208
  "dependencies": {
209
- "@cosmicdrift/kumiko-http": "0.333.0",
210
- "@cosmicdrift/kumiko-types": "0.333.0",
209
+ "@cosmicdrift/kumiko-http": "0.335.0",
210
+ "@cosmicdrift/kumiko-types": "0.335.0",
211
211
  "bullmq": "^5.76.7",
212
212
  "bun-types": "^1.3.13",
213
213
  "hono": "^4.13.1",
@@ -223,7 +223,7 @@
223
223
  "zod": "^4.4.3"
224
224
  },
225
225
  "devDependencies": {
226
- "@cosmicdrift/kumiko-dispatcher-live": "0.333.0",
226
+ "@cosmicdrift/kumiko-dispatcher-live": "0.335.0",
227
227
  "bun-types": "^1.3.13",
228
228
  "pino-pretty": "^13.1.3"
229
229
  },