@cosmicdrift/kumiko-framework 0.371.1 → 0.373.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 (79) hide show
  1. package/AGENTS.md +4 -1
  2. package/agents/examples/custom-handlers.md +3 -5
  3. package/agents/examples/embedded-entity-form.md +1 -1
  4. package/agents/examples/pdf-audit-export.md +1 -1
  5. package/agents/examples/rate-limiting.md +65 -1
  6. package/agents/examples/user-data-rights.md +176 -0
  7. package/dist/api/jwt.js +2 -1
  8. package/dist/api/observability-middleware.d.ts +1 -2
  9. package/dist/api/observability-middleware.js +9 -8
  10. package/dist/api/pii-leak-guard.js +2 -1
  11. package/dist/api/route-label.d.ts +3 -0
  12. package/dist/api/route-label.js +17 -0
  13. package/dist/api/server.d.ts +1 -2
  14. package/dist/api/server.js +3 -5
  15. package/dist/config/index.d.ts +5 -0
  16. package/dist/db/event-store-executor.d.ts +2 -1
  17. package/dist/db/index.d.ts +2 -2
  18. package/dist/db/index.js +1 -1
  19. package/dist/db/located-timestamp.d.ts +7 -0
  20. package/dist/db/query.d.ts +1 -1
  21. package/dist/db/query.js +8 -2
  22. package/dist/db/table-builder.d.ts +45 -11
  23. package/dist/db/tenant-db.js +3 -1
  24. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  25. package/dist/engine/boot-validator/entity-handler.js +47 -16
  26. package/dist/engine/entity-handlers.d.ts +3 -3
  27. package/dist/engine/factories.d.ts +2 -1
  28. package/dist/engine/factories.js +2 -0
  29. package/dist/engine/feature-ast/extractors/handlers.js +1 -1
  30. package/dist/engine/feature-ast/extractors/hooks.js +8 -8
  31. package/dist/engine/feature-ast/pattern-change-schema.d.ts +150 -6
  32. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  33. package/dist/engine/membership-roles.d.ts +1 -0
  34. package/dist/engine/membership-roles.js +8 -0
  35. package/dist/engine/strip-disabled-features-from-app-schema.js +30 -2
  36. package/dist/engine/types/index.d.ts +2 -2
  37. package/dist/engine/types/index.js +1 -1
  38. package/dist/entrypoint/index.d.ts +1 -0
  39. package/dist/entrypoint/index.js +0 -2
  40. package/dist/files/assert-file-refs-belong-to-tenant.js +27 -1
  41. package/dist/files/bind-pending-file-refs.d.ts +31 -1
  42. package/dist/files/bind-pending-file-refs.js +80 -6
  43. package/dist/files/create-file-ref-from-bytes.js +12 -6
  44. package/dist/files/create-file-ref.d.ts +20 -2
  45. package/dist/files/create-file-ref.js +10 -1
  46. package/dist/files/feature.d.ts +1 -0
  47. package/dist/files/feature.js +25 -0
  48. package/dist/files/file-handle.d.ts +4 -1
  49. package/dist/files/file-handle.js +30 -4
  50. package/dist/files/file-ref-entity.d.ts +7 -7
  51. package/dist/files/file-ref-table.d.ts +7 -7
  52. package/dist/files/file-routes.js +6 -1
  53. package/dist/files/index.d.ts +5 -1
  54. package/dist/files/index.js +4 -1
  55. package/dist/files/load-save-file-refs.js +3 -1
  56. package/dist/files/provider-resolver.js +3 -1
  57. package/dist/files/sweep-orphaned-uploads.d.ts +16 -0
  58. package/dist/files/sweep-orphaned-uploads.js +53 -0
  59. package/dist/files/upload-ledger.d.ts +10 -0
  60. package/dist/files/upload-ledger.js +47 -0
  61. package/dist/i18n/index.d.ts +1 -1
  62. package/dist/i18n/index.js +1 -1
  63. package/dist/i18n/request-locale.d.ts +15 -4
  64. package/dist/i18n/request-locale.js +26 -9
  65. package/dist/jobs/job-runner.js +2 -4
  66. package/dist/pipeline/dispatch-shared.js +57 -39
  67. package/dist/pipeline/index.d.ts +1 -0
  68. package/dist/pipeline/index.js +1 -0
  69. package/dist/rate-limit/index.d.ts +2 -1
  70. package/dist/rate-limit/index.js +1 -0
  71. package/dist/rate-limit/resolver.d.ts +1 -1
  72. package/dist/rate-limit/resolver.js +104 -2
  73. package/dist/rate-limit/unlimited-resolver.d.ts +2 -0
  74. package/dist/rate-limit/unlimited-resolver.js +22 -0
  75. package/dist/schema-cli.js +46 -5
  76. package/package.json +5 -5
  77. package/src/changes.json +55 -0
  78. package/src/scripts/codemod/README.md +22 -0
  79. package/src/scripts/codemod/remove-engine-payload-casts.ts +318 -0
@@ -1,5 +1,5 @@
1
1
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
2
- import { RateLimitError } from "../errors/index.js";
2
+ import { InternalError, RateLimitError } from "../errors/index.js";
3
3
  import { RedisKeys } from "../pipeline/redis-keys.js";
4
4
  import { toPublicBucketName } from "./bucket.js";
5
5
  // Token-Bucket rate limiter, atomic via Redis Lua. One round-trip per
@@ -105,6 +105,64 @@ end
105
105
 
106
106
  return { math.floor(tokens), retryAfterMs }
107
107
  `;
108
+ // All-or-nothing variant for several buckets. Every bucket is refilled and
109
+ // checked first; tokens are deducted only when each one has enough, so a
110
+ // rejected request charges no bucket. Multi-key EVAL needs all keys in one
111
+ // hash slot on Redis Cluster; the framework uses standalone Redis.
112
+ //
113
+ // KEYS[i] — bucket keys
114
+ // ARGV[1] — nowMs
115
+ // ARGV[2 + 4(i-1) .. 5 + 4(i-1)] — limit, refillRatePerMs, cost, ttlSeconds of KEYS[i]
116
+ //
117
+ // Returns the flat list { allAllowed (1|0), remaining_1, retryAfterMs_1, remaining_2, ... }.
118
+ // retryAfterMs_i is 0 for buckets that had enough tokens.
119
+ const TOKEN_BUCKET_ALL_LUA = `
120
+ local nowMs = tonumber(ARGV[1])
121
+ local count = #KEYS
122
+ local tokens = {}
123
+ local costs = {}
124
+ local retries = {}
125
+ local allAllowed = 1
126
+
127
+ for i = 1, count do
128
+ local base = 2 + (i - 1) * 4
129
+ local limit = tonumber(ARGV[base])
130
+ local refillRatePerMs = tonumber(ARGV[base + 1])
131
+ costs[i] = tonumber(ARGV[base + 2])
132
+
133
+ local data = redis.call('HMGET', KEYS[i], 'tokens', 'ts')
134
+ local current = tonumber(data[1])
135
+ local ts = tonumber(data[2])
136
+ if current == nil then
137
+ current = limit
138
+ else
139
+ local elapsed = math.max(0, nowMs - ts)
140
+ current = math.min(limit, current + elapsed * refillRatePerMs)
141
+ end
142
+ tokens[i] = current
143
+
144
+ if current >= costs[i] then
145
+ retries[i] = 0
146
+ else
147
+ retries[i] = math.ceil((costs[i] - current) / refillRatePerMs)
148
+ allAllowed = 0
149
+ end
150
+ end
151
+
152
+ local result = { allAllowed }
153
+ for i = 1, count do
154
+ if allAllowed == 1 then
155
+ tokens[i] = tokens[i] - costs[i]
156
+ end
157
+ local base = 2 + (i - 1) * 4
158
+ redis.call('HMSET', KEYS[i], 'tokens', tokens[i], 'ts', nowMs)
159
+ redis.call('EXPIRE', KEYS[i], tonumber(ARGV[base + 3]))
160
+ result[#result + 1] = math.floor(tokens[i])
161
+ result[#result + 1] = retries[i]
162
+ end
163
+
164
+ return result
165
+ `;
108
166
  const REGISTERED = new WeakSet();
109
167
  function ensureCommand(redis) {
110
168
  if (!REGISTERED.has(redis)) {
@@ -116,6 +174,7 @@ function ensureCommand(redis) {
116
174
  numberOfKeys: 1,
117
175
  lua: TOKEN_BUCKET_PEEK_LUA,
118
176
  });
177
+ redis.defineCommand("kumikoRateLimitAll", { lua: TOKEN_BUCKET_ALL_LUA });
119
178
  REGISTERED.add(redis);
120
179
  }
121
180
  return redis; // @cast-boundary engine-bridge
@@ -154,6 +213,49 @@ export function createRateLimitResolver(opts) {
154
213
  resetAt: decision.resetAt.toString(),
155
214
  });
156
215
  }
216
+ async function enforceAll(requests) {
217
+ if (requests.length === 0)
218
+ return [];
219
+ const nowMs = now();
220
+ const keys = requests.map(({ bucket }) => `${prefix}${bucket}`);
221
+ // The script writes each key back once, so a repeated key would deduct only one of its costs.
222
+ const duplicate = keys.find((key, index) => keys.indexOf(key) !== index);
223
+ if (duplicate !== undefined) {
224
+ throw new InternalError({
225
+ message: `enforceAll received the bucket "${duplicate.slice(prefix.length)}" more than once — merge the requests into one.`,
226
+ });
227
+ }
228
+ const perBucketArgs = requests.flatMap(({ config }) => [
229
+ String(config.limit),
230
+ String(config.limit / (config.windowSeconds * 1000)),
231
+ String(config.cost ?? 1),
232
+ String(Math.max(1, config.windowSeconds * 2)),
233
+ ]);
234
+ const [allAllowedFlag, ...flat] = await client.kumikoRateLimitAll(keys.length, ...keys, String(nowMs), ...perBucketArgs);
235
+ const outcomes = requests.map(({ bucket, config }, index) => {
236
+ const retryAfterMs = flat[index * 2 + 1] ?? 0;
237
+ const decision = {
238
+ allowed: retryAfterMs === 0,
239
+ limit: config.limit,
240
+ remaining: flat[index * 2] ?? 0,
241
+ retryAfterSeconds: Math.ceil(retryAfterMs / 1000),
242
+ windowSeconds: config.windowSeconds,
243
+ resetAt: Temporal.Instant.fromEpochMilliseconds(nowMs + retryAfterMs),
244
+ };
245
+ return { bucket, retryAfterMs, decision };
246
+ });
247
+ if (allAllowedFlag === 1)
248
+ return outcomes.map(({ decision }) => decision);
249
+ const rejected = outcomes.reduce((worst, outcome) => outcome.retryAfterMs > worst.retryAfterMs ? outcome : worst);
250
+ throw new RateLimitError({
251
+ bucket: toPublicBucketName(rejected.bucket),
252
+ limit: rejected.decision.limit,
253
+ windowSeconds: rejected.decision.windowSeconds,
254
+ remaining: rejected.decision.remaining,
255
+ retryAfterSeconds: rejected.decision.retryAfterSeconds,
256
+ resetAt: rejected.decision.resetAt.toString(),
257
+ });
258
+ }
157
259
  async function peek(bucket, config) {
158
260
  const refillRatePerMs = config.limit / (config.windowSeconds * 1000);
159
261
  const nowMs = now();
@@ -171,5 +273,5 @@ export function createRateLimitResolver(opts) {
171
273
  resetAt,
172
274
  };
173
275
  }
174
- return { check, enforce, peek };
276
+ return { check, enforce, enforceAll, peek };
175
277
  }
@@ -0,0 +1,2 @@
1
+ import type { RateLimitResolver } from "@cosmicdrift/kumiko-types/rate-limit-types";
2
+ export declare function createUnlimitedRateLimitResolver(): RateLimitResolver;
@@ -0,0 +1,22 @@
1
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
2
+ // Resolver for callers that are bootstrap work rather than tenant traffic (boot seeds,
3
+ // seed migrations): a handler's `rateLimit` declaration must not fail them, and they must not
4
+ // drain the buckets real requests share. Nothing is stored, so the decision is always "full bucket".
5
+ export function createUnlimitedRateLimitResolver() {
6
+ function fullBucket(config) {
7
+ return {
8
+ allowed: true,
9
+ limit: config.limit,
10
+ remaining: config.limit,
11
+ retryAfterSeconds: 0,
12
+ windowSeconds: config.windowSeconds,
13
+ resetAt: Temporal.Instant.fromEpochMilliseconds(Date.now()),
14
+ };
15
+ }
16
+ return {
17
+ check: async (_bucket, config) => fullBucket(config),
18
+ enforce: async (_bucket, config) => fullBucket(config),
19
+ enforceAll: async (requests) => requests.map(({ config }) => fullBucket(config)),
20
+ peek: async (_bucket, config) => fullBucket(config),
21
+ };
22
+ }
@@ -35,6 +35,41 @@ function withFileStoragePresence(env) {
35
35
  ? { ...env, [FILE_STORAGE_PROVIDER_ENV]: FILE_STORAGE_PROVIDER_BOOT_SENTINEL }
36
36
  : { ...env };
37
37
  }
38
+ function isStringSet(value) {
39
+ return value instanceof Set && [...value].every((entry) => typeof entry === "string");
40
+ }
41
+ function isBoolean(value) {
42
+ return typeof value === "boolean";
43
+ }
44
+ function validateBootOptionsError(detail) {
45
+ return new Error(`kumiko/schema.ts: \`VALIDATE_BOOT_OPTIONS\` ${detail}`);
46
+ }
47
+ function readOptionalOption(raw, key, isValid, expected) {
48
+ const value = Reflect.get(raw, key);
49
+ if (value === undefined)
50
+ return undefined;
51
+ if (!isValid(value))
52
+ throw validateBootOptionsError(`\`${key}\` must be ${expected}.`);
53
+ return value;
54
+ }
55
+ // Boundary check for `export const VALIDATE_BOOT_OPTIONS` in kumiko/schema.ts.
56
+ // Only the options that change what validateBoot reports are honoured; `env` and
57
+ // `entityFieldCipher` are runner-injected and deliberately ignored here.
58
+ function parseValidateBootOptions(raw) {
59
+ if (raw === undefined)
60
+ return {};
61
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
62
+ throw validateBootOptionsError("must be an object (ValidateBootOptions).");
63
+ }
64
+ const navAllowlist = readOptionalOption(raw, "navAllowlist", isStringSet, "a Set<string>");
65
+ const navAllowlistExempt = readOptionalOption(raw, "navAllowlistExempt", isStringSet, "a Set<string>");
66
+ const warnOnUniqueAccessRoles = readOptionalOption(raw, "warnOnUniqueAccessRoles", isBoolean, "a boolean");
67
+ return {
68
+ ...(navAllowlist !== undefined && { navAllowlist }),
69
+ ...(navAllowlistExempt !== undefined && { navAllowlistExempt }),
70
+ ...(warnOnUniqueAccessRoles !== undefined && { warnOnUniqueAccessRoles }),
71
+ };
72
+ }
38
73
  // Single load-path for kumiko/schema.ts (512/3) — `generate` and `validate`
39
74
  // both need ENTITY_METAS (and validate additionally wants FEATURES); this is
40
75
  // the one place that enforces "ENTITY_METAS must be an array" so the two
@@ -42,13 +77,17 @@ function withFileStoragePresence(env) {
42
77
  // want a graceful exit code (validate) catch it themselves.
43
78
  async function loadSchemaModFromApp(schemaFile) {
44
79
  // bun imports TS directly — no spawn needed.
45
- const mod = (await import(__rewriteRelativeImportExtension(schemaFile)));
46
- if (!Array.isArray(mod.ENTITY_METAS)) {
80
+ const mod = await import(__rewriteRelativeImportExtension(schemaFile));
81
+ if (!Array.isArray(mod["ENTITY_METAS"])) {
47
82
  throw new Error(`Schema file ${schemaFile} muss \`export const ENTITY_METAS: EntityTableMeta[]\` haben.`);
48
83
  }
49
- const entityMetas = mod.ENTITY_METAS;
84
+ const entityMetas = mod["ENTITY_METAS"];
50
85
  assertUniqueTableNames(entityMetas);
51
- return { entityMetas, features: mod.FEATURES };
86
+ return {
87
+ entityMetas,
88
+ features: mod["FEATURES"],
89
+ validateBootOptions: mod["VALIDATE_BOOT_OPTIONS"],
90
+ };
52
91
  }
53
92
  function nextSequenceNumber(migrationsDir) {
54
93
  if (!existsSync(migrationsDir))
@@ -201,7 +240,8 @@ export async function runSchemaCli(argv, appCwd, out, options = {}) {
201
240
  // 1. schema drift: would `generate` write a migration? (= an entity was
202
241
  // added/changed but never generated → missing table → prod 500)
203
242
  // 2. boot validity: validateBoot over the composed FEATURES (QN/screen/
204
- // nav/role refs). Runs only if kumiko/schema.ts exports FEATURES.
243
+ // nav/role refs). Runs only if kumiko/schema.ts exports FEATURES;
244
+ // honours its optional VALIDATE_BOOT_OPTIONS export.
205
245
  // 3. migration-content drift: replay every committed *.sql file and
206
246
  // diff the result against .snapshot.json — catches a migration
207
247
  // file whose SQL body doesn't match what its own snapshot entry
@@ -257,6 +297,7 @@ export async function runSchemaCli(argv, appCwd, out, options = {}) {
257
297
  if (Array.isArray(mod.FEATURES)) {
258
298
  try {
259
299
  validateBoot(mod.FEATURES, {
300
+ ...parseValidateBootOptions(schemaMod.validateBootOptions),
260
301
  env: withFileStoragePresence(process.env),
261
302
  });
262
303
  out.log(" ✓ boot: feature configuration is valid");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.371.1",
3
+ "version": "0.373.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>",
@@ -199,8 +199,8 @@
199
199
  "./package.json": "./package.json"
200
200
  },
201
201
  "dependencies": {
202
- "@cosmicdrift/kumiko-http": "0.371.1",
203
- "@cosmicdrift/kumiko-types": "0.371.1",
202
+ "@cosmicdrift/kumiko-http": "0.373.0",
203
+ "@cosmicdrift/kumiko-types": "0.373.0",
204
204
  "bullmq": "^5.76.7",
205
205
  "bun-types": "^1.3.13",
206
206
  "hono": "^4.13.1",
@@ -216,8 +216,8 @@
216
216
  "zod": "^4.4.3"
217
217
  },
218
218
  "devDependencies": {
219
- "@cosmicdrift/kumiko-dispatcher-live": "0.371.1",
220
- "@cosmicdrift/kumiko-testing": "0.371.1",
219
+ "@cosmicdrift/kumiko-dispatcher-live": "0.373.0",
220
+ "@cosmicdrift/kumiko-testing": "0.373.0",
221
221
  "bun-types": "^1.3.13",
222
222
  "pino-pretty": "^13.1.3"
223
223
  },
package/src/changes.json CHANGED
@@ -1,4 +1,59 @@
1
1
  [
2
+ {
3
+ "version": "0.373.0",
4
+ "type": "improvement",
5
+ "title": "loadPendingFileRef and bindPendingFileRef bind a caller's own pending upload from a custom handler",
6
+ "detail": "Additive. fileName is not part of PendingFileRef because it is personal data; read it via the fileRef executor's detail. Unexpected update errors throw; version_conflict and unique_violation map to \"version-conflict\"."
7
+ },
8
+ {
9
+ "version": "0.373.0",
10
+ "type": "breaking",
11
+ "title": "rateLimit accepts a list of buckets that are enforced all-or-nothing",
12
+ "detail": "RateLimitDeclaration is now a single option, a non-empty list of options or the disabled form. RateLimitResolver gains the required method enforceAll(requests: readonly RateLimitBucketRequest[]); createRateLimitResolver and createUnlimitedRateLimitResolver implement it. Payload buckets in additionalRateLimits are charged through the same call.",
13
+ "migration": "Custom RateLimitResolver implementations and test fakes need an enforceAll method (check every bucket, then deduct from all or none, and throw RateLimitError for the rejected bucket with the largest retryAfter). Existing rateLimit declarations stay valid. A duplicate payloadField in additionalRateLimits is now a boot error; merge the entries into one."
14
+ },
15
+ {
16
+ "version": "0.372.0",
17
+ "type": "improvement",
18
+ "title": "kumiko check validates the boot configuration with the validateBootOptions from kumiko.config.ts",
19
+ "detail": "Set validateBootOptions in defineAppConfig (navAllowlist, navAllowlistExempt, warnOnUniqueAccessRoles); the managed kumiko/schema.ts exports it as VALIDATE_BOOT_OPTIONS and kumiko check validates with it. Apps with a hand-written kumiko/schema.ts can export VALIDATE_BOOT_OPTIONS themselves.",
20
+ "migration": "Existing apps get the new line in kumiko/schema.ts through `kumiko-upgrade --apply` (managed file). The bin files belong to the app: pass `...(appConfig.validateBootOptions && { validateBootOptions: appConfig.validateBootOptions })` to runProdApp in bin/main.ts and to runDevApp in bin/server.ts (import appConfig from \"../kumiko.config\") so runtime and kumiko check agree."
21
+ },
22
+ {
23
+ "version": "0.372.0",
24
+ "type": "fix",
25
+ "title": "createEntity no longer collapses to never for inline field literals such as { type: \"reference\", entity }",
26
+ "detail": "The fields type parameter is const, so the literal type of each inline field survives without as const."
27
+ },
28
+ {
29
+ "version": "0.372.0",
30
+ "type": "breaking",
31
+ "title": "executor.detail() and executor.list() return EntityRow<E>, derived from the entity definition",
32
+ "detail": "Rows of createEntityExecutor / createEventStoreExecutor are typed per entity; wide entities keep Record<string, unknown>. createBooleanField is typed non-null. UserMfaRow in the bundled auth-mfa feature now declares enabledAt and lastUsedAt as Temporal.Instant, which is what the row always held.",
33
+ "migration": "Remove the `row as T` casts on executor.detail() / executor.list() results with `bun kumiko-upgrade --apply` (codemod remove-engine-payload-casts). Casts to a type the row does not satisfy stay and are listed as manual; replace them with a field-level check or a narrower type.",
34
+ "codemod": "scripts/codemod/remove-engine-payload-casts.ts",
35
+ "manualAfterCodemod": true
36
+ },
37
+ {
38
+ "version": "0.372.0",
39
+ "type": "fix",
40
+ "title": "A save cannot reference a file that belongs to another record",
41
+ "detail": "Creating or updating a record with a file id that is already bound to a different record of the same tenant is now rejected with invalid_element on that field, the same error as for a foreign tenant. Pending uploads, ids the record already holds and moves between its own file fields are unaffected."
42
+ },
43
+ {
44
+ "version": "0.372.0",
45
+ "type": "breaking",
46
+ "title": "Tokens carrying system or all lose those roles on verify",
47
+ "detail": "jwt.verify strips the system and all roles that session minting never grants, so a self-signed or leaked-key token cannot act as System.",
48
+ "migration": "Apps that mint their own tokens with the `system` role must stop doing so and use `createSystemUser` or system dispatch in-process instead. Tokens issued by the framework are unaffected. Tests that send a system user over `stack.http.*` must switch to `stack.dispatcher.write/query`."
49
+ },
50
+ {
51
+ "version": "0.372.0",
52
+ "type": "breaking",
53
+ "title": "http.route and the metric route label carry the route pattern, http.target is removed",
54
+ "detail": "Spans are named \"METHOD pattern\" instead of \"http.request\"; paths no route matched are labelled \"unmatched\". The PII ciphertext guard message names the pattern as well.",
55
+ "migration": "Dashboards, alerts and trace queries keyed on raw paths or on the span name \"http.request\" must switch to route patterns and the \"METHOD pattern\" span name. Read the query string from your access logs if you relied on http.target."
56
+ },
2
57
  {
3
58
  "version": "0.371.0",
4
59
  "type": "improvement",
@@ -118,3 +118,25 @@ bun node_modules/@cosmicdrift/kumiko-framework/src/scripts/codemod/import-extens
118
118
  Runs via `bun kumiko-upgrade --apply`. Without a mode flag it writes. `--check` exits 1 while any
119
119
  rewritable or manual site is left. The `import-extensions` guard (`kumiko check guards`) reports
120
120
  sites that are still on `.js`.
121
+
122
+ ## remove-engine-payload-casts.ts
123
+
124
+ `executor.detail()` and `executor.list()` from `createEntityExecutor` / `createEventStoreExecutor`
125
+ return the entity's own row type (`EntityRow<E>`), so `row["f"] as T`, `row as T` and
126
+ `(await executor.detail(…)) as unknown as T` are usually redundant. The codemod removes such a cast,
127
+ and its `@cast-boundary` marker comment when no other cast stays in the statement, only when the
128
+ operand derives from an executor read (directly, through `rows[0]`, property access, `??` or a
129
+ single-assignment variable) and the type checker says its type is assignable to the cast target. A
130
+ file is left untouched if removing the casts would add type errors to it.
131
+
132
+ It type-checks against the tsconfig at or above each file, so run it with the new framework version
133
+ installed. Reported as `manual` and left alone: casts the row type does not satisfy (for example a
134
+ nullable column cast to a non-null type, or `rows[0]` under `noUncheckedIndexedAccess`), and files
135
+ without a tsconfig. A call site that still needs a narrower type than the row gives keeps its cast.
136
+
137
+ ```bash
138
+ bun node_modules/@cosmicdrift/kumiko-framework/src/scripts/codemod/remove-engine-payload-casts.ts <dir> [--check | --dry-run | --write] [--no-format]
139
+ ```
140
+
141
+ Runs via `bun kumiko-upgrade --apply`. Without a mode flag it writes, then formats the changed files
142
+ with Biome. `--check` exits 1 while any rewritable or manual site is left.