@cosmicdrift/kumiko-framework 0.334.0 → 0.336.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 (178) hide show
  1. package/dist/api/anonymous-live-entities.d.ts +2 -0
  2. package/dist/api/anonymous-live-entities.js +17 -0
  3. package/dist/api/auth-routes.js +19 -4
  4. package/dist/api/client-ip.js +3 -6
  5. package/dist/api/extra-route.js +3 -2
  6. package/dist/api/index.d.ts +2 -1
  7. package/dist/api/index.js +1 -0
  8. package/dist/api/pii-leak-guard.js +7 -4
  9. package/dist/api/redis-sse-broker.d.ts +2 -0
  10. package/dist/api/redis-sse-broker.js +6 -7
  11. package/dist/api/routes.js +16 -17
  12. package/dist/api/server.js +20 -10
  13. package/dist/api/sse-broker.d.ts +3 -0
  14. package/dist/api/sse-broker.js +14 -0
  15. package/dist/api/sse-route.d.ts +5 -2
  16. package/dist/api/sse-route.js +71 -11
  17. package/dist/bun-db/connection.js +0 -2
  18. package/dist/bun-db/index.d.ts +1 -1
  19. package/dist/bun-db/index.js +1 -1
  20. package/dist/bun-db/query.d.ts +1 -0
  21. package/dist/bun-db/query.js +15 -4
  22. package/dist/crypto/kek-source.js +40 -36
  23. package/dist/crypto/kms-adapter.js +2 -2
  24. package/dist/db/apply-entity-event.js +8 -2
  25. package/dist/db/blind-index-cleanup.js +32 -11
  26. package/dist/db/entity-table-meta.js +5 -3
  27. package/dist/db/event-store-executor-context.js +13 -19
  28. package/dist/db/event-store-executor-read.js +45 -38
  29. package/dist/db/event-store-executor-write.js +15 -17
  30. package/dist/db/migrate-generator.js +27 -28
  31. package/dist/db/pg-error.js +9 -5
  32. package/dist/db/queries/backfill-pii.js +15 -5
  33. package/dist/db/queries/shadow-swap.js +5 -4
  34. package/dist/db/table-builder.d.ts +1 -0
  35. package/dist/db/table-builder.js +7 -15
  36. package/dist/db/tenant-db.js +18 -2
  37. package/dist/derivatives/variant-key.js +3 -1
  38. package/dist/engine/boot-validator/access-declarations.js +12 -3
  39. package/dist/engine/boot-validator/entity-handler.js +13 -1
  40. package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
  41. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  42. package/dist/engine/boot-validator/index.js +6 -6
  43. package/dist/engine/boot-validator/nav.d.ts +2 -1
  44. package/dist/engine/boot-validator/nav.js +10 -20
  45. package/dist/engine/boot-validator/ownership.d.ts +1 -1
  46. package/dist/engine/boot-validator/ownership.js +2 -1
  47. package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
  48. package/dist/engine/boot-validator/pii-retention.js +5 -2
  49. package/dist/engine/boot-validator/projection-list-screens.js +8 -0
  50. package/dist/engine/boot-validator/query-output-columns.js +53 -5
  51. package/dist/engine/boot-validator/screens.d.ts +2 -2
  52. package/dist/engine/boot-validator/screens.js +125 -46
  53. package/dist/engine/boot-validator/transfer-graph.js +9 -6
  54. package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
  55. package/dist/engine/boot-validator/zod-shape.js +17 -4
  56. package/dist/engine/build-app-schema.js +65 -20
  57. package/dist/engine/build-config-feature-schema.js +8 -1
  58. package/dist/engine/changeset-changes.js +24 -10
  59. package/dist/engine/create-app.js +10 -1
  60. package/dist/engine/extension-names.d.ts +4 -0
  61. package/dist/engine/extension-names.js +4 -0
  62. package/dist/engine/feature-ast/extractors/events.js +11 -8
  63. package/dist/engine/feature-ast/extractors/handlers.js +39 -16
  64. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
  65. package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
  66. package/dist/engine/feature-ast/parse.d.ts +1 -0
  67. package/dist/engine/feature-ast/parse.js +18 -3
  68. package/dist/engine/feature-ast/patch.js +80 -61
  69. package/dist/engine/feature-ast/patcher.d.ts +1 -0
  70. package/dist/engine/feature-ast/patcher.js +2 -1
  71. package/dist/engine/feature-ast/render.js +1 -1
  72. package/dist/engine/feature-entity-handlers.d.ts +1 -0
  73. package/dist/engine/feature-entity-handlers.js +2 -0
  74. package/dist/engine/index.d.ts +4 -3
  75. package/dist/engine/index.js +3 -2
  76. package/dist/engine/ownership.d.ts +1 -0
  77. package/dist/engine/ownership.js +15 -2
  78. package/dist/engine/pattern-library/mixed-schemas.js +15 -29
  79. package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
  80. package/dist/engine/pattern-library/shared-fields.js +17 -0
  81. package/dist/engine/registry-facade.js +3 -0
  82. package/dist/engine/registry-validate.d.ts +1 -0
  83. package/dist/engine/registry-validate.js +14 -0
  84. package/dist/engine/registry.js +2 -1
  85. package/dist/engine/role-assignment.d.ts +7 -1
  86. package/dist/engine/role-assignment.js +27 -7
  87. package/dist/engine/schema-builder.d.ts +1 -0
  88. package/dist/engine/schema-builder.js +9 -2
  89. package/dist/engine/screen-helpers.js +15 -4
  90. package/dist/engine/steps/_event-match.js +34 -7
  91. package/dist/engine/steps/_read-source.js +8 -0
  92. package/dist/engine/system-user.d.ts +1 -0
  93. package/dist/engine/system-user.js +3 -0
  94. package/dist/engine/where-rule-lint.js +22 -4
  95. package/dist/env/dry-run.js +10 -3
  96. package/dist/env/index.js +21 -1
  97. package/dist/errors/reasons.d.ts +0 -1
  98. package/dist/errors/reasons.js +2 -5
  99. package/dist/event-store/event-store.js +10 -2
  100. package/dist/event-store/provenance-append.js +4 -1
  101. package/dist/files/file-routes.d.ts +0 -1
  102. package/dist/files/file-routes.js +22 -7
  103. package/dist/files/local-provider.js +13 -10
  104. package/dist/files/provider-resolver.js +20 -1
  105. package/dist/files/types.js +16 -1
  106. package/dist/i18n/request-locale.d.ts +3 -2
  107. package/dist/i18n/request-locale.js +16 -6
  108. package/dist/i18n/required-surface-keys.js +23 -35
  109. package/dist/i18n/settings-hub-keys.js +3 -0
  110. package/dist/jobs/index.d.ts +2 -2
  111. package/dist/jobs/index.js +1 -1
  112. package/dist/jobs/job-runner.d.ts +2 -0
  113. package/dist/jobs/job-runner.js +172 -59
  114. package/dist/migrations/index.d.ts +1 -1
  115. package/dist/migrations/index.js +1 -1
  116. package/dist/migrations/pending-rebuilds.d.ts +4 -0
  117. package/dist/migrations/pending-rebuilds.js +23 -1
  118. package/dist/observability/escape-hatch-report.d.ts +1 -0
  119. package/dist/observability/escape-hatch-report.js +3 -0
  120. package/dist/observability/index.d.ts +2 -1
  121. package/dist/observability/index.js +2 -1
  122. package/dist/observability/standard-metrics.d.ts +4 -0
  123. package/dist/observability/standard-metrics.js +16 -0
  124. package/dist/pipeline/dispatch-query.d.ts +1 -1
  125. package/dist/pipeline/dispatch-query.js +2 -2
  126. package/dist/pipeline/dispatch-shared.d.ts +2 -3
  127. package/dist/pipeline/dispatch-shared.js +14 -9
  128. package/dist/pipeline/dispatch-stream.js +2 -5
  129. package/dist/pipeline/dispatch-write.js +22 -5
  130. package/dist/pipeline/dispatcher.js +1 -2
  131. package/dist/pipeline/event-dispatcher-admin.js +12 -2
  132. package/dist/pipeline/event-dispatcher-delivery.js +5 -0
  133. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  134. package/dist/pipeline/event-dispatcher.js +30 -12
  135. package/dist/pipeline/index.d.ts +1 -1
  136. package/dist/pipeline/index.js +1 -1
  137. package/dist/pipeline/member-read-only-transaction.js +1 -14
  138. package/dist/pipeline/msp-rebuild.d.ts +1 -0
  139. package/dist/pipeline/msp-rebuild.js +1 -0
  140. package/dist/pipeline/system-hooks.d.ts +1 -1
  141. package/dist/pipeline/system-hooks.js +19 -20
  142. package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
  143. package/dist/pipeline/tenant-timezone-cache.js +20 -6
  144. package/dist/pipeline/write-origin.js +13 -6
  145. package/dist/redis/pubsub-signal.d.ts +2 -0
  146. package/dist/redis/pubsub-signal.js +2 -2
  147. package/dist/schema-cli.js +4 -14
  148. package/dist/search/meilisearch-adapter.js +17 -6
  149. package/dist/search/purge-subject.js +8 -3
  150. package/dist/stack/drain-event-consumers.js +3 -0
  151. package/dist/stack/drain-jobs.d.ts +3 -1
  152. package/dist/stack/drain-jobs.js +55 -13
  153. package/dist/stack/table-helpers.js +10 -8
  154. package/dist/testing/closed-connection-error.js +2 -3
  155. package/dist/testing/wait-for.js +1 -0
  156. package/dist/ui-types/index.d.ts +1 -1
  157. package/dist/ui-types/index.js +1 -1
  158. package/dist/upgrade-cli.d.ts +1 -0
  159. package/dist/upgrade-cli.js +36 -15
  160. package/package.json +4 -4
  161. package/src/changes.json +264 -0
  162. package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
  163. package/src/scripts/codemod/migrate-db-raw.ts +16 -8
  164. package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
  165. package/src/scripts/codemod/pii-personal-migration.ts +111 -44
  166. package/src/scripts/codemod/unprocessable-error-details-reason.ts +136 -42
  167. package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
  168. package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
  169. package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
  170. package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
  171. package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
  172. package/dist/scripts/codemod/migrate-db-raw.js +0 -345
  173. package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
  174. package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
  175. package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
  176. package/dist/scripts/codemod/pii-personal-migration.js +0 -615
  177. package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
  178. package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
@@ -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) {
@@ -17,6 +17,9 @@ export async function drainEventConsumers(stack, consumerNames, options) {
17
17
  "multiStreamProjection is wired for this stack.");
18
18
  }
19
19
  const maxPasses = options?.maxPasses ?? DEFAULT_MAX_PASSES;
20
+ if (!Number.isInteger(maxPasses) || maxPasses < 1) {
21
+ throw new Error("drainEventConsumers: maxPasses must be a positive integer");
22
+ }
20
23
  const target = await getEventsHighWaterMark(db);
21
24
  for (let pass = 0; pass < maxPasses; pass++) {
22
25
  await eventDispatcher.runOnce();
@@ -17,4 +17,6 @@ export type DrainJobsStack = {
17
17
  readonly eventDispatcher?: EventDispatcher;
18
18
  readonly jobRunner?: JobRunner;
19
19
  };
20
- export declare function drainJobs(stack: DrainJobsStack, tracker: JobFailureTracker): Promise<void>;
20
+ export declare function drainJobs(stack: DrainJobsStack, tracker: JobFailureTracker, options?: {
21
+ readonly timeoutMs?: number;
22
+ }): Promise<void>;
@@ -12,6 +12,9 @@ const DRAIN_IDLE_POLL_MS = 25;
12
12
  // enqueue that lands just after the first idle read still gets picked up
13
13
  // by the second pass instead of racing straight past it.
14
14
  const REQUIRED_IDLE_PASSES = 2;
15
+ // Below the 15s per-test budget so a stuck drain reports what is open rather
16
+ // than surfacing as a bare test timeout.
17
+ const DEFAULT_DRAIN_TIMEOUT_MS = 10_000;
15
18
  function sleep(ms) {
16
19
  return new Promise((resolve) => setTimeout(resolve, ms));
17
20
  }
@@ -47,6 +50,26 @@ export function createJobFailureTracker() {
47
50
  },
48
51
  };
49
52
  }
53
+ // Diagnostic only: lets the deadline error name what was still open.
54
+ async function describeLaggingConsumers(db, consumerNames) {
55
+ // skip: no shared consumers wired — nothing to catch up on.
56
+ if (consumerNames.length === 0)
57
+ return [];
58
+ const target = await getEventsHighWaterMark(db);
59
+ const lagging = [];
60
+ for (const name of consumerNames) {
61
+ const state = await getConsumerState(db, name);
62
+ if (state && state.lastProcessedEventId < target) {
63
+ lagging.push(`${name} (status=${state.status}, lastProcessedEventId=${state.lastProcessedEventId}, target=${target})`);
64
+ }
65
+ }
66
+ return lagging;
67
+ }
68
+ async function drainTimeoutError(db, consumerNames, timeoutMs, pending) {
69
+ const lagging = await describeLaggingConsumers(db, consumerNames);
70
+ return new Error(`drainJobs: not drained after ${timeoutMs}ms — pending jobs: ${pending}, ` +
71
+ `consumers behind: ${lagging.length === 0 ? "none" : lagging.join("; ")}`);
72
+ }
50
73
  async function sharedConsumersCaughtUp(db, consumerNames) {
51
74
  // skip: no shared consumers wired — nothing to catch up on.
52
75
  if (consumerNames.length === 0)
@@ -66,8 +89,12 @@ async function sharedConsumersCaughtUp(db, consumerNames) {
66
89
  }
67
90
  return allCaughtUp;
68
91
  }
69
- export async function drainJobs(stack, tracker) {
92
+ // Fails a stuck drain with a diagnosis instead of a generic test-runner timeout.
93
+ // It only bounds the loop; it never replaces waiting for real work.
94
+ export async function drainJobs(stack, tracker, options) {
70
95
  const { db, eventDispatcher, jobRunner } = stack;
96
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
97
+ const deadline = Date.now() + timeoutMs;
71
98
  if (!jobRunner) {
72
99
  throw new Error("drainJobs: stack.jobRunner is undefined — pass `jobs: {...}` to setupTestStack and " +
73
100
  "register at least one r.job(...) so a runner is built.");
@@ -76,24 +103,39 @@ export async function drainJobs(stack, tracker) {
76
103
  .filter((consumer) => (consumer.delivery ?? "shared") === "shared")
77
104
  .map((consumer) => consumer.name);
78
105
  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)]);
106
+ try {
107
+ let consecutiveIdlePasses = 0;
108
+ while (consecutiveIdlePasses < REQUIRED_IDLE_PASSES) {
109
+ await eventDispatcher?.runOnce();
110
+ const pending = await jobRunner.countPendingJobs();
111
+ const caughtUp = await sharedConsumersCaughtUp(db, sharedConsumerNames);
112
+ consecutiveIdlePasses = pending === 0 && caughtUp ? consecutiveIdlePasses + 1 : 0;
113
+ if (consecutiveIdlePasses < REQUIRED_IDLE_PASSES) {
114
+ if (Date.now() > deadline) {
115
+ throw await drainTimeoutError(db, sharedConsumerNames, timeoutMs, pending);
116
+ }
117
+ await Promise.race([tracker.waitForActivity(), sleep(DRAIN_IDLE_POLL_MS)]);
118
+ }
87
119
  }
88
120
  }
121
+ catch (err) {
122
+ // A failed job often kills its consumer, so the drain error is only the follow-up symptom.
123
+ if (tracker.failures.size === 0)
124
+ throw err;
125
+ const message = err instanceof Error ? err.message : String(err);
126
+ throw new Error(`${message}; ${describeJobFailures(tracker)}`, { cause: err });
127
+ }
89
128
  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}`);
129
+ throw new Error(`drainJobs: ${describeJobFailures(tracker)}`);
94
130
  }
95
131
  }
96
132
  finally {
97
133
  tracker.clear();
98
134
  }
99
135
  }
136
+ function describeJobFailures(tracker) {
137
+ const details = [...tracker.failures.values()]
138
+ .map((failure) => `${failure.jobName}: ${failure.error}`)
139
+ .join("; ");
140
+ return `job(s) failed: ${details}`;
141
+ }
@@ -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;
@@ -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;
@@ -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.334.0",
3
+ "version": "0.336.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.334.0",
210
- "@cosmicdrift/kumiko-types": "0.334.0",
209
+ "@cosmicdrift/kumiko-http": "0.336.0",
210
+ "@cosmicdrift/kumiko-types": "0.336.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.334.0",
226
+ "@cosmicdrift/kumiko-dispatcher-live": "0.336.0",
227
227
  "bun-types": "^1.3.13",
228
228
  "pino-pretty": "^13.1.3"
229
229
  },