@cosmicdrift/kumiko-framework 0.200.0 → 0.201.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 (31) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/entity-list-limits.integration.test.ts +84 -0
  3. package/src/api/__tests__/api.test.ts +116 -1
  4. package/src/api/__tests__/batch.integration.test.ts +53 -0
  5. package/src/api/__tests__/body-limit.test.ts +16 -0
  6. package/src/api/route-registrars.ts +4 -3
  7. package/src/api/routes.ts +47 -1
  8. package/src/db/__tests__/unchecked-system-db.test.ts +66 -0
  9. package/src/db/tenant-db.ts +46 -2
  10. package/src/engine/entity-handlers.ts +8 -1
  11. package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/constants.ts +2 -0
  12. package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/feature.ts +8 -0
  13. package/src/engine/feature-ast/__tests__/patch.test.ts +98 -0
  14. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +155 -0
  15. package/src/engine/feature-ast/extractors/events.ts +5 -3
  16. package/src/engine/feature-ast/extractors/round3.ts +5 -3
  17. package/src/engine/feature-ast/extractors/round5.ts +5 -4
  18. package/src/engine/feature-ast/extractors/shared.ts +29 -4
  19. package/src/engine/feature-ast/patch.ts +28 -21
  20. package/src/engine/feature-ast/patterns.ts +18 -0
  21. package/src/engine/feature-ast/render.ts +19 -6
  22. package/src/engine/index.ts +1 -0
  23. package/src/files/__tests__/files.integration.test.ts +97 -1
  24. package/src/files/file-routes.ts +10 -2
  25. package/src/files/types.ts +72 -0
  26. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +44 -8
  27. package/src/pipeline/__tests__/dispatcher.test.ts +23 -4
  28. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +116 -22
  29. package/src/pipeline/dispatch-batch.ts +11 -5
  30. package/src/pipeline/dispatch-shared.ts +42 -16
  31. package/src/pipeline/idempotency.ts +91 -30
@@ -157,19 +157,23 @@ async function appendDomainEvent(
157
157
 
158
158
  // r.systemScope() handlers must go through ctx.systemDb's guarded methods
159
159
  // (assertTenantMatch / acknowledgeCrossTenant) instead of plain ctx.db —
160
- // "system" mode has no tenant filter at all, so silent ctx.db use there is a
161
- // cross-tenant leak. HandlerContext.db has no way to become optional per
162
- // handler (isSystem is a runtime-only registry lookup, not a type-level
163
- // discriminant), so instead of omitting the key we hand back a Proxy that
164
- // throws on first touch, naming the handler and pointing at ctx.systemDb.
165
- function createSystemScopedDbGuard(handlerType: string): TenantDb {
160
+ // "system" mode has no tenant filter at all, so silent ctx.db or
161
+ // ctx.dbOutsideTransaction use there is a cross-tenant leak. HandlerContext
162
+ // has no way to make either field optional per handler (isSystem is a
163
+ // runtime-only registry lookup, not a type-level discriminant), so instead
164
+ // of omitting the key we hand back a Proxy that throws on first touch,
165
+ // naming the handler and pointing at the matching ctx.systemDb escape hatch.
166
+ function createSystemScopedDbGuard(
167
+ handlerType: string,
168
+ fieldName: "db" | "dbOutsideTransaction",
169
+ hint: string,
170
+ ): TenantDb {
166
171
  return new Proxy({} as TenantDb, {
167
172
  get(_target, prop) {
168
173
  throw new InternalError({
169
174
  message:
170
- `Handler "${handlerType}" is r.systemScope()'d — ctx.db is unavailable ` +
171
- `(it would be unfiltered across every tenant). Use ` +
172
- `ctx.systemDb.assertTenantMatch(...) or ctx.systemDb.acknowledgeCrossTenant(...) ` +
175
+ `Handler "${handlerType}" is r.systemScope()'d — ctx.${fieldName} is unavailable ` +
176
+ `(it would be unfiltered across every tenant). Use ${hint} ` +
173
177
  `instead (attempted to read "${String(prop)}").`,
174
178
  });
175
179
  },
@@ -204,19 +208,41 @@ export async function buildHandlerContext(
204
208
  // the client has disconnected — handlers with many sequential queries skip
205
209
  // the rest of the chain instead of burning DB-CPU for results no one reads.
206
210
  const db = dbSource ? buildTenantScopedDb(dbSource, reqCtx?.signal) : undefined;
207
- const systemDb = isSystem && db ? createUncheckedSystemDb(db) : undefined;
208
- // Exposed as ctx.db below — the internal `db` above stays the real,
209
- // working TenantDb for this function's own use (config/derivatives
210
- // accessors, systemDb construction).
211
- const exposedDb = isSystem && db ? createSystemScopedDbGuard(type) : db;
212
211
  // Unbound pool, tenant-scoped like `db` but never tx-bound — writes
213
212
  // through it survive a rollback of the handler's own transaction. No
214
213
  // AbortSignal here: a client disconnect must not abort a durability write
215
- // that is meant to outlive the request.
214
+ // that is meant to outlive the request. Computed before `systemDb` below
215
+ // so its guarded escape hatch (ctx.systemDb.outsideTransaction) can wrap
216
+ // this same TenantDb instead of a second, independently-built one.
216
217
  const outsideTxSource = resolveDbSource(ctx, undefined);
217
- const dbOutsideTransaction = outsideTxSource
218
+ const rawDbOutsideTransaction = outsideTxSource
218
219
  ? buildTenantScopedDb(outsideTxSource, undefined)
219
220
  : undefined;
221
+ const systemDb =
222
+ isSystem && db ? createUncheckedSystemDb(db, rawDbOutsideTransaction) : undefined;
223
+ // Exposed as ctx.db below — the internal `db` above stays the real,
224
+ // working TenantDb for this function's own use (config/derivatives
225
+ // accessors, systemDb construction).
226
+ const exposedDb =
227
+ isSystem && db
228
+ ? createSystemScopedDbGuard(
229
+ type,
230
+ "db",
231
+ "ctx.systemDb.assertTenantMatch(...) or ctx.systemDb.acknowledgeCrossTenant(...)",
232
+ )
233
+ : db;
234
+ // Same fail-closed treatment as `exposedDb` — a system handler reaching
235
+ // ctx.dbOutsideTransaction directly would be a second, unguarded door to
236
+ // the same cross-tenant access ctx.db closes off above.
237
+ const dbOutsideTransaction =
238
+ isSystem && rawDbOutsideTransaction
239
+ ? createSystemScopedDbGuard(
240
+ type,
241
+ "dbOutsideTransaction",
242
+ "ctx.systemDb.outsideTransaction.assertTenantMatch(...) or " +
243
+ "ctx.systemDb.outsideTransaction.acknowledgeCrossTenant(...)",
244
+ )
245
+ : rawDbOutsideTransaction;
220
246
  const log = context.log?.child({
221
247
  handler: type,
222
248
  tenantId: user.tenantId,
@@ -1,14 +1,31 @@
1
1
  import type Redis from "ioredis";
2
+ import { InternalError } from "../errors";
3
+ import { generateId } from "../utils";
2
4
  import { RedisKeys } from "./redis-keys";
3
5
 
6
+ // Discriminated so a truthy "acquired" object can never be misread as a
7
+ // cache hit by a callsite doing `if (result)` — the caller must switch on
8
+ // `status`.
9
+ export type IdempotencyCheckResult =
10
+ | { readonly status: "cached"; readonly result: string }
11
+ | { readonly status: "acquired"; readonly token: string };
12
+
4
13
  export type IdempotencyGuard = {
5
- check(tenantId: string, userId: string, requestId: string): Promise<string | null>;
6
- store(tenantId: string, userId: string, requestId: string, result: unknown): Promise<void>;
14
+ check(tenantId: string, userId: string, requestId: string): Promise<IdempotencyCheckResult>;
15
+ store(
16
+ tenantId: string,
17
+ userId: string,
18
+ requestId: string,
19
+ token: string,
20
+ result: unknown,
21
+ ): Promise<void>;
7
22
  };
8
23
 
9
- // Sentinel stored under the key while the handler is running. A second
10
- // request that sees this waits for the real result instead of racing.
11
- const PENDING_MARKER = "__pending__";
24
+ // Sentinel prefix stored under the key while the handler is running. Each
25
+ // acquisition appends a unique token so store() can later prove it still
26
+ // owns the lock it started with (see storeScript below) instead of blindly
27
+ // overwriting whatever is currently there.
28
+ const PENDING_PREFIX = "__pending__:";
12
29
 
13
30
  export function createIdempotencyGuard(
14
31
  redis: Redis,
@@ -25,15 +42,40 @@ export function createIdempotencyGuard(
25
42
  // handler doesn't permanently block retries, long enough to cover normal
26
43
  // batch durations.
27
44
  const pendingTtl = options.pendingTtlSeconds ?? 30;
28
- const waitTimeoutMs = options.waitTimeoutMs ?? 25_000;
45
+ // Must stay comfortably above pendingTtl: a wait shorter than the lock's
46
+ // own TTL makes a retry give up and re-run the handler while the original
47
+ // call is still legitimately in flight — the exact case this lock exists
48
+ // to prevent. Clamped rather than trusted so a misconfigured explicit
49
+ // option can't reintroduce that inversion.
50
+ const waitTimeoutMs = Math.max(options.waitTimeoutMs ?? 35_000, pendingTtl * 1000 + 5_000);
29
51
  const pollIntervalMs = options.pollIntervalMs ?? 50;
30
52
  const prefix = RedisKeys.idempotency;
31
53
 
54
+ // Atomic compare-and-swap: only persist the result if the key still holds
55
+ // the exact pending token this run acquired. If a parallel retry reclaimed
56
+ // an expired lock in the meantime, this is a no-op — the reclaiming run
57
+ // owns the key now and will persist the authoritative result itself.
58
+ const storeScript = `
59
+ if redis.call("get", KEYS[1]) == ARGV[1] then
60
+ redis.call("set", KEYS[1], ARGV[2], "EX", ARGV[3])
61
+ return 1
62
+ else
63
+ return 0
64
+ end
65
+ `;
66
+
67
+ async function tryAcquire(key: string): Promise<string | null> {
68
+ const token = `${PENDING_PREFIX}${generateId()}`;
69
+ const acquired = await redis.set(key, token, "EX", pendingTtl, "NX");
70
+ return acquired === "OK" ? token : null;
71
+ }
72
+
32
73
  return {
33
74
  // Returns:
34
- // null — caller owns the in-progress lock, proceed to run the handler
35
- // and then call store() with the real result.
36
- // string — serialized cached result from a concurrent or prior request.
75
+ // { status: "acquired", token } — caller owns the in-progress lock,
76
+ // proceed to run the handler and then call store() with this token.
77
+ // { status: "cached", result } — serialized result from a concurrent
78
+ // or prior request; do not run the handler.
37
79
  //
38
80
  // The old behaviour (pure GET + SET-NX-store) let two parallel requests
39
81
  // both see a cache miss, both execute side-effects, and only one persist
@@ -42,40 +84,59 @@ export function createIdempotencyGuard(
42
84
  async check(tenantId, userId, requestId) {
43
85
  const key = `${prefix}${tenantId}:${userId}:${requestId}`;
44
86
 
45
- // Try to acquire the in-progress lock.
46
- const acquired = await redis.set(key, PENDING_MARKER, "EX", pendingTtl, "NX");
47
- if (acquired === "OK") return null;
87
+ const token = await tryAcquire(key);
88
+ if (token) return { status: "acquired", token };
48
89
 
49
90
  // Lost the race. Poll until the lock holder stores a result, or the
50
91
  // lock expires (handler crashed) and we can try again.
51
92
  const deadline = Date.now() + waitTimeoutMs;
52
93
  while (Date.now() < deadline) {
53
94
  const value = await redis.get(key);
54
- if (value === null) {
55
- // Lock expired before a result was stored — try to claim it
56
- // ourselves and proceed as the new owner.
57
- const reclaimed = await redis.set(key, PENDING_MARKER, "EX", pendingTtl, "NX");
58
- if (reclaimed === "OK") return null;
95
+ if (value === null || value.startsWith(PENDING_PREFIX)) {
96
+ if (value === null) {
97
+ // Lock expired before a result was stored — try to claim it
98
+ // ourselves and proceed as the new owner.
99
+ const reclaimed = await tryAcquire(key);
100
+ if (reclaimed) return { status: "acquired", token: reclaimed };
101
+ }
102
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
59
103
  continue;
60
104
  }
61
- if (value !== PENDING_MARKER) return value;
62
- await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
105
+ return { status: "cached", result: value };
63
106
  }
64
107
 
65
- // Gave up waiting. Treat as a fresh request forces the caller to
66
- // re-run the handler rather than hang indefinitely.
67
- return null;
108
+ // Gave up waiting. By construction waitTimeoutMs > pendingTtl, so the
109
+ // lock must have expired already make one last claim attempt rather
110
+ // than silently reporting ownership we never acquired (that would
111
+ // reintroduce the double-execute bug). If even that loses the race,
112
+ // fail loudly instead of running the handler a second time.
113
+ const finalValue = await redis.get(key);
114
+ if (finalValue !== null && !finalValue.startsWith(PENDING_PREFIX)) {
115
+ return { status: "cached", result: finalValue };
116
+ }
117
+ const lastResort = await tryAcquire(key);
118
+ if (lastResort) return { status: "acquired", token: lastResort };
119
+ throw new InternalError({
120
+ message: `idempotency lock contention: gave up waiting for requestId ${requestId}`,
121
+ });
68
122
  },
69
123
 
70
- async store(tenantId, userId, requestId, result) {
71
- // Overwrite the pending marker with the real result. Plain SET (no NX)
72
- // on purpose: we own the lock; writing the result is the final step.
73
- await redis.set(
74
- `${prefix}${tenantId}:${userId}:${requestId}`,
124
+ async store(tenantId, userId, requestId, token, result) {
125
+ const key = `${prefix}${tenantId}:${userId}:${requestId}`;
126
+ // @cast-boundary db-operator Lua EVAL return type is untyped in ioredis
127
+ const written = (await redis.eval(
128
+ storeScript,
129
+ 1,
130
+ key,
131
+ token,
75
132
  JSON.stringify(result),
76
- "EX",
77
- ttl,
78
- );
133
+ String(ttl),
134
+ )) as number;
135
+ // written === 0 means another process reclaimed this key after our
136
+ // lock expired and is (or already did) persist its own result —
137
+ // silently skipping here is the fix: the old code did an unconditional
138
+ // SET and could stomp that fresher result with our stale one.
139
+ void written;
79
140
  },
80
141
  };
81
142
  }