@cosmicdrift/kumiko-framework 0.200.1 → 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.
- package/package.json +3 -3
- package/src/__tests__/entity-list-limits.integration.test.ts +84 -0
- package/src/api/__tests__/api.test.ts +116 -1
- package/src/api/__tests__/batch.integration.test.ts +53 -0
- package/src/api/__tests__/body-limit.test.ts +16 -0
- package/src/api/route-registrars.ts +4 -3
- package/src/api/routes.ts +47 -1
- package/src/db/__tests__/unchecked-system-db.test.ts +66 -0
- package/src/db/tenant-db.ts +46 -2
- package/src/engine/entity-handlers.ts +8 -1
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/constants.ts +2 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/feature.ts +8 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +98 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +155 -0
- package/src/engine/feature-ast/extractors/events.ts +5 -3
- package/src/engine/feature-ast/extractors/round3.ts +5 -3
- package/src/engine/feature-ast/extractors/round5.ts +5 -4
- package/src/engine/feature-ast/extractors/shared.ts +29 -4
- package/src/engine/feature-ast/patch.ts +28 -21
- package/src/engine/feature-ast/patterns.ts +18 -0
- package/src/engine/feature-ast/render.ts +19 -6
- package/src/engine/index.ts +1 -0
- package/src/files/__tests__/files.integration.test.ts +97 -1
- package/src/files/file-routes.ts +10 -2
- package/src/files/types.ts +72 -0
- package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +44 -8
- package/src/pipeline/__tests__/dispatcher.test.ts +23 -4
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +116 -22
- package/src/pipeline/dispatch-batch.ts +11 -5
- package/src/pipeline/dispatch-shared.ts +42 -16
- 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
|
|
161
|
-
// cross-tenant leak. HandlerContext
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
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
|
|
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
|
|
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<
|
|
6
|
-
store(
|
|
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.
|
|
10
|
-
//
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
105
|
+
return { status: "cached", result: value };
|
|
63
106
|
}
|
|
64
107
|
|
|
65
|
-
// Gave up waiting.
|
|
66
|
-
//
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
//
|
|
73
|
-
await redis.
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
}
|