@cosmicdrift/kumiko-framework 0.341.0 → 0.343.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 (74) hide show
  1. package/dist/api/auth-routes.d.ts +2 -0
  2. package/dist/api/auth-routes.js +35 -2
  3. package/dist/api/request-context.d.ts +2 -0
  4. package/dist/api/request-id-middleware.d.ts +2 -0
  5. package/dist/api/request-id-middleware.js +4 -1
  6. package/dist/api/routes.js +5 -5
  7. package/dist/api/server.d.ts +3 -0
  8. package/dist/api/server.js +4 -1
  9. package/dist/compliance/duration-spec.d.ts +2 -1
  10. package/dist/compliance/duration-spec.js +11 -0
  11. package/dist/compliance/index.d.ts +2 -2
  12. package/dist/compliance/index.js +1 -1
  13. package/dist/compliance/profiles.d.ts +6 -5
  14. package/dist/crypto/kek-source.d.ts +3 -4
  15. package/dist/crypto/kek-source.js +2 -5
  16. package/dist/crypto/kms-wiring.d.ts +2 -2
  17. package/dist/crypto/kms-wiring.js +2 -2
  18. package/dist/db/entity-field-encryption.d.ts +1 -1
  19. package/dist/db/entity-field-encryption.js +5 -5
  20. package/dist/db/event-store-executor-write.js +38 -11
  21. package/dist/db/index.d.ts +2 -1
  22. package/dist/db/index.js +2 -1
  23. package/dist/db/migrate-generator.d.ts +6 -0
  24. package/dist/db/migrate-generator.js +15 -1
  25. package/dist/db/migrate-runner.d.ts +1 -0
  26. package/dist/db/migrate-runner.js +11 -1
  27. package/dist/db/projection-registry-binding.d.ts +3 -0
  28. package/dist/db/projection-registry-binding.js +12 -0
  29. package/dist/db/retired-framework-tables.d.ts +10 -0
  30. package/dist/db/retired-framework-tables.js +72 -0
  31. package/dist/db/tenant-db.d.ts +2 -0
  32. package/dist/db/tenant-db.js +22 -7
  33. package/dist/engine/boot-validator/index.d.ts +11 -0
  34. package/dist/engine/boot-validator/index.js +45 -1
  35. package/dist/engine/boot-validator/query-output-columns.js +8 -7
  36. package/dist/engine/boot-validator/query-refs.js +3 -0
  37. package/dist/engine/boot-validator/screens.js +28 -0
  38. package/dist/engine/index.d.ts +2 -2
  39. package/dist/engine/index.js +1 -1
  40. package/dist/engine/soft-delete-cleanup.d.ts +2 -0
  41. package/dist/engine/soft-delete-cleanup.js +28 -9
  42. package/dist/engine/types/index.d.ts +2 -2
  43. package/dist/engine/types/index.js +1 -1
  44. package/dist/env/index.d.ts +3 -0
  45. package/dist/env/index.js +12 -0
  46. package/dist/errors/docs-url.d.ts +7 -0
  47. package/dist/errors/docs-url.js +36 -0
  48. package/dist/errors/index.d.ts +2 -0
  49. package/dist/errors/index.js +1 -0
  50. package/dist/errors/kumiko-error.d.ts +0 -2
  51. package/dist/errors/kumiko-error.js +0 -24
  52. package/dist/errors/serialize.d.ts +3 -2
  53. package/dist/errors/serialize.js +4 -2
  54. package/dist/jobs/job-runner.js +37 -2
  55. package/dist/observability/escape-hatch-report.d.ts +10 -1
  56. package/dist/observability/escape-hatch-report.js +19 -1
  57. package/dist/observability/index.d.ts +2 -2
  58. package/dist/observability/index.js +2 -2
  59. package/dist/observability/standard-metrics.d.ts +1 -0
  60. package/dist/observability/standard-metrics.js +14 -0
  61. package/dist/pipeline/dispatch-shared.js +3 -1
  62. package/dist/pipeline/event-retention.d.ts +1 -0
  63. package/dist/pipeline/event-retention.js +6 -0
  64. package/dist/pipeline/index.d.ts +2 -0
  65. package/dist/pipeline/index.js +1 -0
  66. package/dist/pipeline/projections-runner.d.ts +1 -0
  67. package/dist/pipeline/projections-runner.js +23 -14
  68. package/dist/rate-limit/middleware.js +1 -1
  69. package/dist/schema-cli.d.ts +8 -6
  70. package/dist/schema-cli.js +37 -4
  71. package/dist/stack/test-stack.d.ts +7 -0
  72. package/dist/stack/test-stack.js +6 -0
  73. package/package.json +4 -4
  74. package/src/changes.json +60 -0
@@ -10,7 +10,7 @@
10
10
  import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
11
11
  import { join, resolve as resolvePath } from "node:path";
12
12
  import { configureBlindIndexKey, configurePiiSubjectKms, resolveKmsWiringAsync, } from "./crypto/index.js";
13
- import { assertValidMigrationName, baselineMigrations, createDbConnection, diffReplayAgainstSnapshot, fetchAppliedMigrations, generateMigration, loadMigrationsFromDir, loadSnapshotJson, rebuildTablesFromDiff, replayMigrationsDir, runMigrationsFromDir, tableExists, writeRebuildMarker, writeSnapshotJson, } from "./db/index.js";
13
+ import { assertValidMigrationName, baselineMigrations, createDbConnection, diffReplayAgainstSnapshot, fetchAppliedMigrations, findCommentedDropTables, generateMigration, isRetiredFrameworkTable, loadMigrationsFromDir, loadSnapshotJson, readMigrationSqlTexts, rebuildTablesFromDiff, replayMigrationsDir, runMigrationsFromDir, tableExists, writeRebuildMarker, writeSnapshotJson, } from "./db/index.js";
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";
@@ -49,12 +49,40 @@ function nextSequenceNumber(migrationsDir) {
49
49
  }
50
50
  return max + 1;
51
51
  }
52
+ // A table that a migration only drops as a commented DESTRUCTIVE marker and
53
+ // that the snapshot no longer has is gone on paper but still in the live DB.
54
+ async function reportLeftoverDroppedTables(db, migrationsDir, out) {
55
+ const snapshotPath = join(migrationsDir, SNAPSHOT_FILENAME);
56
+ const snapshot = existsSync(snapshotPath) ? loadSnapshotJson(snapshotPath) : null;
57
+ const leftovers = [];
58
+ for (const tableName of findCommentedDropTables(readMigrationSqlTexts(migrationsDir), snapshot)) {
59
+ if (await tableExists(db, tableName))
60
+ leftovers.push(tableName);
61
+ }
62
+ // skip: nothing left over, nothing to report
63
+ if (leftovers.length === 0)
64
+ return;
65
+ out.log(" Tables dropped only as a commented DESTRUCTIVE marker but still in the database:");
66
+ for (const tableName of leftovers) {
67
+ out.log(isRetiredFrameworkTable(tableName)
68
+ ? ` ! ${tableName} — retired framework table: the next \`schema generate <name>\` creates the drop`
69
+ : ` ! ${tableName} — app table: after a backup, write a new migration with the drop`);
70
+ }
71
+ out.log("");
72
+ }
52
73
  const SCHEMA_SUBCOMMANDS = ["generate", "validate", "apply", "baseline", "status"];
53
74
  /**
54
75
  * Runs a schema-CLI subcommand. `appCwd` is the app workspace root (where
55
76
  * `kumiko/schema.ts` + `kumiko/migrations/` live). Returns a process exit code.
56
77
  */
57
78
  export async function runSchemaCli(argv, appCwd, out, options = {}) {
79
+ // Runtime guard for untyped callers: the rebuild after `apply` needs the
80
+ // slots, and failing there would leave migrations applied but unrebuilt.
81
+ if (options.features && !options.kmsSlots) {
82
+ out.err("runSchemaCli: `features` requires `kmsSlots` — pass kmsSlots: kmsSlotsOf(<composedEnv>.schema), " +
83
+ "or [] when the app wires no Key Manager slots.");
84
+ return 1;
85
+ }
58
86
  // runProdApp/runDevApp install this at boot; the standalone CLI (the
59
87
  // migrate-db initContainer, `bun kumiko.js schema apply`) never goes through
60
88
  // that boot path, so a projection rebuild's tz/timestamp coercion throws
@@ -114,10 +142,12 @@ export async function runSchemaCli(argv, appCwd, out, options = {}) {
114
142
  prevSnapshot,
115
143
  name,
116
144
  sequenceNumber: nextSequenceNumber(migrationsDir),
145
+ priorMigrationsSql: readMigrationSqlTexts(migrationsDir),
117
146
  });
118
147
  const isEmpty = result.diff.newTables.length === 0 &&
119
148
  result.diff.changedTables.length === 0 &&
120
- result.diff.droppedTables.length === 0;
149
+ result.diff.droppedTables.length === 0 &&
150
+ result.retiredDrops.length === 0;
121
151
  if (isEmpty) {
122
152
  out.log(" No schema changes detected — kein neues Migration-File geschrieben.");
123
153
  return 0;
@@ -133,7 +163,7 @@ export async function runSchemaCli(argv, appCwd, out, options = {}) {
133
163
  writeRebuildMarker(migrationsDir, result.filename, rebuildTables);
134
164
  out.log("");
135
165
  out.log(` ✓ ${result.filename}`);
136
- out.log(` new tables: ${result.diff.newTables.length}, changed: ${result.diff.changedTables.length}, dropped: ${result.diff.droppedTables.length}`);
166
+ out.log(` new tables: ${result.diff.newTables.length}, changed: ${result.diff.changedTables.length}, dropped: ${result.diff.droppedTables.length}, retired framework tables dropped: ${result.retiredDrops.length}`);
137
167
  if (rebuildTables.length > 0) {
138
168
  out.log(` rebuild-marker: ${result.filename.replace(/\.sql$/, ".rebuild.json")} (${rebuildTables.length} table(s))`);
139
169
  }
@@ -182,11 +212,13 @@ export async function runSchemaCli(argv, appCwd, out, options = {}) {
182
212
  prevSnapshot,
183
213
  name: "validate",
184
214
  sequenceNumber: nextSequenceNumber(migrationsDir),
215
+ priorMigrationsSql: readMigrationSqlTexts(migrationsDir),
185
216
  });
186
217
  const pendingTables = [
187
218
  ...drift.diff.newTables.map((t) => t.tableName),
188
219
  ...drift.diff.changedTables.map((t) => t.tableName),
189
220
  ...drift.diff.droppedTables,
221
+ ...drift.retiredDrops.map((t) => t.tableName),
190
222
  ];
191
223
  if (pendingTables.length === 0) {
192
224
  out.log(" ✓ schema: migrations match the entity definitions");
@@ -308,7 +340,7 @@ export async function runSchemaCli(argv, appCwd, out, options = {}) {
308
340
  wiring = await resolveKmsWiringAsync(process.env, {
309
341
  logPrefix: "[kumiko schema apply]",
310
342
  log: out.log,
311
- ...(options.kmsSlots && { slots: options.kmsSlots }),
343
+ slots: options.kmsSlots,
312
344
  });
313
345
  if ("kms" in wiring) {
314
346
  configurePiiSubjectKms(wiring.kms);
@@ -398,6 +430,7 @@ export async function runSchemaCli(argv, appCwd, out, options = {}) {
398
430
  out.log("");
399
431
  out.log(` ${applied.size} applied, ${pending} pending.`);
400
432
  out.log("");
433
+ await reportLeftoverDroppedTables(db, migrationsDir, out);
401
434
  return pending === 0 ? 0 : 1;
402
435
  }
403
436
  finally {
@@ -3,6 +3,7 @@ import type { AuthRoutesConfig } from "../api/auth-routes.js";
3
3
  import type { JwtHelper } from "../api/jwt.js";
4
4
  import { type SseBroker } from "../api/sse-broker.js";
5
5
  import type { AppContext, FeatureDefinition, JobRunIn, Registry, TenantId } from "../engine/types/index.js";
6
+ import type { ErrorDocsConfig } from "../errors/docs-url.js";
6
7
  import { type JobRunner, type JobRunnerOptions } from "../jobs/index.js";
7
8
  import type { Lifecycle } from "../lifecycle/index.js";
8
9
  import { type ObservabilityProvider } from "../observability/index.js";
@@ -159,6 +160,8 @@ export type TestStackOptions = {
159
160
  /** Forwarded to buildServer's top-level `ServerOptions.trustedProxyHops`
160
161
  * — see there. Default 0. */
161
162
  trustedProxyHops?: number;
163
+ /** Links the app's own error reasons to its own docs (`docsUrl` in error responses); framework reasons keep the framework docs link. Passed to buildServer's `ServerOptions.errorDocs`. */
164
+ errorDocs?: ErrorDocsConfig;
162
165
  /** Second stack on the Redis namespace of `owner`: own connection, same
163
166
  * keyPrefix, so rate-limit buckets, locks, idempotency keys and the
164
167
  * cache-sync channel are shared across both. The derived
@@ -172,5 +175,9 @@ export type TestStackOptions = {
172
175
  * Implied by `sharedRedisWith`; set it on the owner stack too, or its writes
173
176
  * never reach the borrower. Without it the stack uses a process-local bus. */
174
177
  cacheSync?: boolean;
178
+ /** "full" runs the prod-boot validateBoot over `features` (screens and ref
179
+ * entities across features included). Use it with the app's complete feature
180
+ * composition; the default only checks what is safe on a feature subset. */
181
+ validateBoot?: "full";
175
182
  };
176
183
  export declare function setupTestStack(options: TestStackOptions): Promise<TestStack>;
@@ -1,5 +1,6 @@
1
1
  import { buildServer } from "../api/server.js";
2
2
  import { createSseBroker } from "../api/sse-broker.js";
3
+ import { validateBoot, validateNavBootSubset } from "../engine/boot-validator/index.js";
3
4
  import { validateOwnershipBoot } from "../engine/boot-validator/ownership.js";
4
5
  import { createRegistry } from "../engine/registry.js";
5
6
  import { createArchivedStreamsTable, createEventsTable } from "../event-store/index.js";
@@ -31,6 +32,10 @@ export async function setupTestStack(options) {
31
32
  // columns) and before the ephemeral DB/Redis exist, so a throw can't leak
32
33
  // a database that nothing will clean up.
33
34
  validateOwnershipBoot(options.features);
35
+ // Nav/workspace refs fail the prod boot too; the subset check tolerates refs into unmounted features.
36
+ validateNavBootSubset(options.features);
37
+ if (options.validateBoot === "full")
38
+ validateBoot(options.features);
34
39
  // Forward db-name/persistent-flag through to createTestDb. The
35
40
  // defaults (undefined dbName, persistent:false) keep the legacy
36
41
  // test contract: fresh kumiko_test_<random> DB per setup, dropped
@@ -280,6 +285,7 @@ export async function setupTestStack(options) {
280
285
  eventDedup,
281
286
  sseBroker,
282
287
  ...(options.trustedProxyHops !== undefined && { trustedProxyHops: options.trustedProxyHops }),
288
+ ...(options.errorDocs && { errorDocs: options.errorDocs }),
283
289
  ...(options.extraRoutes && { extraRoutes: options.extraRoutes }),
284
290
  ...(options.metrics && { metrics: options.metrics }),
285
291
  // Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.341.0",
3
+ "version": "0.343.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.341.0",
210
- "@cosmicdrift/kumiko-types": "0.341.0",
209
+ "@cosmicdrift/kumiko-http": "0.343.0",
210
+ "@cosmicdrift/kumiko-types": "0.343.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.341.0",
226
+ "@cosmicdrift/kumiko-dispatcher-live": "0.343.0",
227
227
  "bun-types": "^1.3.13",
228
228
  "pino-pretty": "^13.1.3"
229
229
  },
package/src/changes.json CHANGED
@@ -1,4 +1,64 @@
1
1
  [
2
+ {
3
+ "version": "0.343.0",
4
+ "type": "breaking",
5
+ "title": "App error reasons only carry a docsUrl when errorDocs covers them",
6
+ "detail": "`docsUrl` in error responses is now resolved at serialization by `resolveErrorDocsUrl`. Framework reasons (and errors without a reason) link to the framework docs as before. An app's own reasons no longer link to the framework docs, where no page exists for them: they get a `docsUrl` only if the new option `errorDocs: { baseUrl, reasons: string[] | \"all\" }` (on `runProdApp`, `buildServer`, `createKumikoServer`, `setupTestStack`) covers them.\nMigration: `KumikoError.docsUrl` (the getter) is removed; use `resolveErrorDocsUrl(err, errorDocs?)`. `ErrorResponseBody.docsUrl` is now optional. Apps whose clients read `docsUrl` for app reasons pass `errorDocs` or handle the missing field.",
7
+ "migration": "KumikoError.docsUrl (the getter) is removed; call resolveErrorDocsUrl(err, errorDocs?) instead. ErrorResponseBody.docsUrl is optional. Apps whose clients read docsUrl for app reasons pass errorDocs or handle the missing field."
8
+ },
9
+ {
10
+ "version": "0.343.0",
11
+ "type": "fix",
12
+ "title": "Executor writes outside the dispatcher no longer skip custom projections",
13
+ "detail": "Executor writes run custom projections\n`EventStoreExecutor` create, update, delete, forget and restore now run the custom projections registered for the written event, in the same savepoint as the write. The registry is bound to the `TenantDb` by the dispatcher and the job runner; a `TenantDb` built elsewhere skips custom projections. Projection runs are idempotent per event object, so the dispatcher's own pass after the handler does not run them twice.",
14
+ "migration": "Remove manual `runProjections` calls after executor writes in hooks or jobs together with the bump. A call is a no-op only when it passes the exact event object the executor returned; a spread or rebuilt event projects twice."
15
+ },
16
+ {
17
+ "version": "0.343.0",
18
+ "type": "improvement",
19
+ "title": "isFailedWriteResult is exported from the pipeline entry",
20
+ "detail": "`isFailedWriteResult` and `FailedWriteResult` are exported from the pipeline entry\nConsumers can narrow a `WriteResult` to its failure branch with `import { isFailedWriteResult } from \"@cosmicdrift/kumiko-framework/pipeline\"` instead of re-implementing the check."
21
+ },
22
+ {
23
+ "version": "0.343.0",
24
+ "type": "improvement",
25
+ "title": "retiredCookieDomains clears auth cookies left on a former cookieDomain",
26
+ "detail": "`AuthRoutesConfig.retiredCookieDomains` lists domains that used to carry the auth cookies. Login and logout also send delete headers for `kumiko_auth` and `kumiko_csrf` with `Domain=<entry>`, so a stale cookie from an earlier `cookieDomain` no longer shadows the new session after the value changes. Login sends these deletes before the new `Set-Cookie` headers. `createAuthRoutes` throws on an empty entry, on `;`, comma, whitespace, CR or LF, and on an entry equal to the current `cookieDomain`. Without the option nothing changes. Remove an entry once the session TTL has passed since the switch."
27
+ },
28
+ {
29
+ "version": "0.343.0",
30
+ "type": "improvement",
31
+ "title": "Test stacks validate nav references and can run the full boot validation",
32
+ "detail": "`setupTestStack` now validates nav, workspace and tree-action references on the mounted features, with the same error messages as the prod boot. References into features that are not mounted are skipped. The new option `validateBoot: \"full\"` runs the complete `validateBoot`, including the screen and ref-entity checks that span features; give apps one boot test with their prod feature composition and this option."
33
+ },
34
+ {
35
+ "version": "0.342.0",
36
+ "type": "improvement",
37
+ "title": "System cron escape hatches are audited once per process, with a use counter and audit retention",
38
+ "detail": "System crons audit their declared escape hatch once per process; audit events get a retention\nA cron job with a declared `escapeHatch` that uses `unsafeRaw()` or `db.global()` writes one `audit:event:escape-hatch-used` per process, handler and tenant instead of one per run. Manual runs by a user, identity switches, `acknowledge-cross-tenant` and `unsafe-all-tenants` keep the 60-second dedup window. Every use, audited or not, counts in the new metric `kumiko_escape_hatch_uses_total{handler,kind}`; `createEscapeHatchReporter` takes optional `processDedup`, `meter` and `now` for this. The audit feature gains a daily job `audit:job:escape-hatch-retention` that removes `escapeHatchUse` events through `pruneEvents`, with the period in the system config key `audit:config:escape-hatch-retention-days` (default 90). A new runbook, `docs/runbooks/kumiko-events-bloat.md`, shows how to measure `kumiko_events` bloat and reclaim the space.",
39
+ "migration": "The first run of `audit:job:escape-hatch-retention` deletes `escapeHatchUse` events older than 90 days. To keep them longer, set the system config key `audit:config:escape-hatch-retention-days` (for example through an app override) before the bump rolls out. The audit feature now also requires the `config` feature, which every app with `tenant` already mounts."
40
+ },
41
+ {
42
+ "version": "0.342.0",
43
+ "type": "breaking",
44
+ "title": "Key Manager slots are required, LEGACY_SLOTS default removed",
45
+ "detail": "Key Manager slots are required: no more LEGACY_SLOTS default\n`resolvePlatformKeks`, `resolveKmsWiringAsync` and `requireKmsWiringAsync` no longer fall back to the three platform slots; `slots` is a required option. `runSchemaCli` called with `features` now requires `kmsSlots` and exits 1 with a hint before any DB connect or migration apply when it is missing. `kmsSlots: []` stays valid for apps that wire no Key Manager slots. The scaffolded `bin/kumiko.ts` passes `kmsSlots` derived from the composed env schema.",
46
+ "migration": "Pass `slots` (for example `kmsSlotsOf(<app>ComposedEnv.schema)`) to `resolvePlatformKeks`, `resolveKmsWiringAsync` and `requireKmsWiringAsync`. In `bin/kumiko.ts`, `runSchemaCli(..., { features })` needs `kmsSlots: kmsSlotsOf(<app>ComposedEnv.schema)` (or `[]` when the app wires no Key Manager slots. With `[]` every `*_CIPHERTEXT` env var is ignored, so an app that ships a ciphertext must pass its slots); without it the CLI exits 1 before applying migrations.\nWorkspace state at release: show-pony `bin/kumiko.ts` and its docs copy `kumiko-platform/apps/docs/_samples/show-pony/bin/kumiko.ts` still call `runSchemaCli` with `features` only. publicstatus, solon, offlot-app, money-horse, phronexsis and kumiko-studio already pass `kmsSlots` / `slots`."
47
+ },
48
+ {
49
+ "version": "0.342.0",
50
+ "type": "fix",
51
+ "title": "Retired framework tables (read_job_runs) are dropped by the next schema generate",
52
+ "detail": "`kumiko schema generate` now writes an executable `DROP TABLE IF EXISTS \"read_job_runs\";` for retired framework tables (new `retiredFrameworkTables` list next to the migration code). Until now the drop stayed a commented `-- DESTRUCTIVE:` marker in every app, so the table lived on forever. A retired table is dropped only if an earlier migration created it or carries the commented drop, no earlier migration dropped it for real, and the schema no longer declares it. App tables stay commented out. `generate` writes a migration even when the retired drop is its only content, and `schema validate` reports it as pending. `kumiko schema status` now lists tables that a migration only drops as a commented marker but that still exist in the database, with a hint per case.",
53
+ "migration": "Run `kumiko schema generate <name>` in publicstatus, solon, money-horse, phronexsis, show-pony and kumiko-studio, review and commit the drop of read_job_runs (old job run history is not preserved), then apply."
54
+ },
55
+ {
56
+ "version": "0.342.0",
57
+ "type": "fix",
58
+ "title": "Soft-delete cleanup works for global tables such as user",
59
+ "detail": "Soft-delete cleanup no longer aborts on `tenancy: \"global\"` tables\n`soft-delete:job:cleanup` aborted for a tenant as soon as an entity with `tenancy: \"global\"` and a `tenantId` column (such as `user`) had softDelete enabled, so the tenant's other entities were not cleaned either. Both cleanup jobs now decide by the entity's tenancy declaration. The per-tenant job skips global tables; `soft-delete:job:cleanup-system` hard-deletes their expired rows once, system-wide, through `db.global()`. Jobs can now receive the `globalWrites` grant from their `escapeHatch` declaration.\nThe first run of `soft-delete:job:cleanup-system` after the update hard-deletes soft-deleted users older than the fixed 30-day grace period in every app with the user feature. Earlier versions never removed them.",
60
+ "migration": "None required. The first nightly run of `soft-delete:job:cleanup-system` after the bump hard-deletes soft-deleted users older than 30 days; restore any you still need before it runs."
61
+ },
2
62
  {
3
63
  "version": "0.341.0",
4
64
  "type": "improvement",