@cosmicdrift/kumiko-framework 0.305.0 → 0.306.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 (60) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/server-boot-guards.test.ts +1 -0
  3. package/src/api/__tests__/server-error-logging.test.ts +71 -0
  4. package/src/api/request-context.ts +5 -4
  5. package/src/api/routes.ts +26 -1
  6. package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
  7. package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
  8. package/src/bun-db/query.ts +42 -18
  9. package/src/changes.json +66 -0
  10. package/src/db/__tests__/pg-error.test.ts +14 -0
  11. package/src/db/__tests__/system-db-view-export.test.ts +107 -0
  12. package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
  13. package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
  14. package/src/db/index.ts +1 -1
  15. package/src/db/pg-error.ts +13 -0
  16. package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
  17. package/src/db/tenant-db.ts +90 -13
  18. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
  19. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  20. package/src/engine/__tests__/boot-validator.test.ts +1 -1
  21. package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
  22. package/src/engine/extension-names.ts +55 -25
  23. package/src/engine/extensions/storage-provider.ts +14 -41
  24. package/src/engine/extensions/tenant-data.ts +4 -0
  25. package/src/engine/extensions/tenant-resource.ts +40 -0
  26. package/src/engine/extensions/user-data.ts +8 -7
  27. package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
  28. package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
  29. package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
  30. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
  31. package/src/engine/feature-ast/entity-field-types.ts +41 -0
  32. package/src/engine/feature-ast/extractors/handlers.ts +217 -84
  33. package/src/engine/feature-ast/extractors/hooks.ts +72 -15
  34. package/src/engine/feature-ast/extractors/round2.ts +21 -0
  35. package/src/engine/feature-ast/extractors/shared.ts +9 -0
  36. package/src/engine/feature-ast/index.ts +11 -1
  37. package/src/engine/feature-ast/patch.ts +338 -5
  38. package/src/engine/feature-ast/patcher.ts +2 -2
  39. package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
  40. package/src/engine/feature-ast/patterns.ts +22 -15
  41. package/src/engine/feature-ast/render.ts +1 -0
  42. package/src/engine/feature-ui-extensions.ts +8 -7
  43. package/src/engine/index.ts +21 -5
  44. package/src/engine/types/extension-options-map.ts +1 -0
  45. package/src/engine/types/index.ts +6 -0
  46. package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
  47. package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
  48. package/src/jobs/__tests__/jobs.integration.test.ts +3 -3
  49. package/src/jobs/job-runner.ts +151 -14
  50. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
  51. package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
  52. package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
  53. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
  54. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
  55. package/src/pipeline/dispatch-batch.ts +56 -13
  56. package/src/pipeline/idempotency.ts +16 -0
  57. package/src/pipeline/system-identity-switch.ts +22 -4
  58. package/src/testing/closed-connection-error.ts +62 -0
  59. package/src/testing/index.ts +1 -0
  60. package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
@@ -223,6 +223,65 @@ describe("idempotency guard", () => {
223
223
  if (final.status !== "cached") throw new Error("expected cached value");
224
224
  expect(JSON.parse(final.result)).toEqual({ isSuccess: true, data: { owner: "reclaimer" } });
225
225
  });
226
+
227
+ test("release() frees the lock — a waiting check() reclaims immediately instead of polling out", async () => {
228
+ const guard = createIdempotencyGuard(testRedis.redis, {
229
+ pendingTtlSeconds: 5,
230
+ pollIntervalMs: 20,
231
+ waitTimeoutMs: 10_000,
232
+ });
233
+ const requestId = "req-release-1";
234
+
235
+ const first = await guard.check(tenantA, userA, requestId);
236
+ expect(first.status).toBe("acquired");
237
+ if (first.status !== "acquired") throw new Error("expected to acquire the lock");
238
+
239
+ // Waiter starts BEFORE release — if release is a no-op this only resolves
240
+ // once waitTimeoutMs elapses (10s), which the 500ms race below catches.
241
+ const waiterPromise = guard.check(tenantA, userA, requestId);
242
+
243
+ await guard.release(tenantA, userA, requestId, first.token);
244
+
245
+ const raced = await Promise.race([
246
+ waiterPromise.then((v) => ({ done: true as const, v })),
247
+ new Promise<{ done: false }>((r) => setTimeout(() => r({ done: false }), 500)),
248
+ ]);
249
+ expect(raced.done).toBe(true);
250
+ if (!raced.done) throw new Error("waiter did not reclaim after release()");
251
+ expect(raced.v.status).toBe("acquired");
252
+ });
253
+
254
+ test("release() with a stale token is a no-op — it must not clear a new owner's lock", async () => {
255
+ const guard = createIdempotencyGuard(testRedis.redis, {
256
+ pendingTtlSeconds: 1,
257
+ pollIntervalMs: 20,
258
+ waitTimeoutMs: 6_000,
259
+ });
260
+ const requestId = "req-release-stale";
261
+
262
+ const original = await guard.check(tenantA, userA, requestId);
263
+ expect(original.status).toBe("acquired");
264
+ if (original.status !== "acquired") throw new Error("expected to acquire the lock");
265
+
266
+ // Let the lock expire, then a new owner reclaims it.
267
+ await new Promise((r) => setTimeout(r, 1100));
268
+ const reclaimer = await guard.check(tenantA, userA, requestId);
269
+ expect(reclaimer.status).toBe("acquired");
270
+ if (reclaimer.status !== "acquired") throw new Error("expected to reclaim the lock");
271
+
272
+ // The original (stale) owner's release() must not touch the reclaimer's lock.
273
+ await guard.release(tenantA, userA, requestId, original.token);
274
+
275
+ await guard.store(tenantA, userA, requestId, reclaimer.token, {
276
+ isSuccess: true,
277
+ data: { owner: "reclaimer" },
278
+ });
279
+
280
+ const final = await guard.check(tenantA, userA, requestId);
281
+ expect(final.status).toBe("cached");
282
+ if (final.status !== "cached") throw new Error("expected cached value");
283
+ expect(JSON.parse(final.result)).toEqual({ isSuccess: true, data: { owner: "reclaimer" } });
284
+ });
226
285
  });
227
286
 
228
287
  // --- Event Dedup ---
@@ -1,3 +1,4 @@
1
+ import { requestContext } from "../api/request-context";
1
2
  import type { DbConnection } from "../db/connection";
2
3
  import { transaction } from "../db/query";
3
4
  import type { DeleteContext, SaveContext, SessionUser, WriteResult } from "../engine/types";
@@ -22,6 +23,22 @@ export async function runBatch(
22
23
  commands: readonly BatchCommand[],
23
24
  user: SessionUser,
24
25
  requestId?: string,
26
+ ): Promise<BatchResult> {
27
+ const current = requestContext.get();
28
+ if (!current?.signal) {
29
+ return runBatchBody(ctx, commands, user, requestId);
30
+ }
31
+ // Strip the signal: a disconnect would roll back the tx, idempotency would
32
+ // cache a 500 for the uncommitted write and afterCommit effects would be lost.
33
+ const { signal: _signal, ...withoutSignal } = current;
34
+ return requestContext.run(withoutSignal, () => runBatchBody(ctx, commands, user, requestId));
35
+ }
36
+
37
+ async function runBatchBody(
38
+ ctx: DispatchContext,
39
+ commands: readonly BatchCommand[],
40
+ user: SessionUser,
41
+ requestId?: string,
25
42
  ): Promise<BatchResult> {
26
43
  const { idempotency, lifecycle, appContext: context } = ctx;
27
44
  if (commands.length === 0) {
@@ -45,8 +62,8 @@ export async function runBatch(
45
62
  }
46
63
  }
47
64
 
48
- // Wrap return paths: cache the final result under requestId so retries get
49
- // the same answer (both success and failure results are cached).
65
+ // Cache the result under requestId so retries get the same answer. Only a
66
+ // provably rolled-back 5xx releases the lock instead (releaseOrFinalize).
50
67
  const finalize = async (result: BatchResult): Promise<BatchResult> => {
51
68
  if (requestId && idempotency && idempotencyToken) {
52
69
  await idempotency.store(user.tenantId, user.id, requestId, idempotencyToken, result);
@@ -54,6 +71,19 @@ export async function runBatch(
54
71
  return result;
55
72
  };
56
73
 
74
+ // Never for the no-tx fallback: without a rollback, a re-run would repeat
75
+ // the side effects of the commands that already ran.
76
+ const releaseOrFinalize = async (
77
+ result: BatchResult,
78
+ isRetryableRollback: boolean,
79
+ ): Promise<BatchResult> => {
80
+ if (isRetryableRollback && requestId && idempotency && idempotencyToken) {
81
+ await idempotency.release(user.tenantId, user.id, requestId, idempotencyToken);
82
+ return result;
83
+ }
84
+ return finalize(result);
85
+ };
86
+
57
87
  const afterCommitHooks: AfterCommitHook[] = [];
58
88
  const results: WriteResult[] = [];
59
89
 
@@ -134,6 +164,7 @@ export async function runBatch(
134
164
  return finalize({ isSuccess: true, results });
135
165
  }
136
166
 
167
+ let transactionCallbackCompleted = false;
137
168
  try {
138
169
  await transaction(db, async (tx) => {
139
170
  for (let i = 0; i < commands.length; i++) {
@@ -153,22 +184,34 @@ export async function runBatch(
153
184
  throw new BatchRollback(i, res.error);
154
185
  }
155
186
  }
187
+ transactionCallbackCompleted = true;
156
188
  });
157
189
  } catch (e) {
158
190
  if (e instanceof BatchRollback) {
159
- return finalize({
191
+ // Thrown inside the callback, so the tx rolled back. A 4xx is
192
+ // deterministic and stays cached; a 5xx may be transient.
193
+ return releaseOrFinalize(
194
+ {
195
+ isSuccess: false,
196
+ error: e.failureError,
197
+ failedIndex: e.failedIndex,
198
+ results,
199
+ },
200
+ e.failureError.httpStatus >= 500,
201
+ );
202
+ }
203
+ // A completed callback means the throw came from COMMIT (outcome unknown),
204
+ // so cache it; otherwise COMMIT was never sent and releasing is safe.
205
+ const error = toWriteErrorInfo(wrapToKumiko(e));
206
+ return releaseOrFinalize(
207
+ {
160
208
  isSuccess: false,
161
- error: e.failureError,
162
- failedIndex: e.failedIndex,
209
+ error,
210
+ failedIndex: results.length,
163
211
  results,
164
- });
165
- }
166
- return finalize({
167
- isSuccess: false,
168
- error: toWriteErrorInfo(wrapToKumiko(e)),
169
- failedIndex: results.length,
170
- results,
171
- });
212
+ },
213
+ !transactionCallbackCompleted && error.httpStatus >= 500,
214
+ );
172
215
  }
173
216
 
174
217
  // Commit succeeded — fire deferred side-effects.
@@ -19,6 +19,7 @@ export type IdempotencyGuard = {
19
19
  token: string,
20
20
  result: unknown,
21
21
  ): Promise<void>;
22
+ release(tenantId: string, userId: string, requestId: string, token: string): Promise<void>;
22
23
  };
23
24
 
24
25
  // Sentinel prefix stored under the key while the handler is running. Each
@@ -64,6 +65,16 @@ export function createIdempotencyGuard(
64
65
  end
65
66
  `;
66
67
 
68
+ // Same CAS guard as storeScript: only clear the lock if we still own it.
69
+ // A stale token (lock already reclaimed by a new owner) is a no-op.
70
+ const releaseScript = `
71
+ if redis.call("get", KEYS[1]) == ARGV[1] then
72
+ return redis.call("del", KEYS[1])
73
+ else
74
+ return 0
75
+ end
76
+ `;
77
+
67
78
  async function tryAcquire(key: string): Promise<string | null> {
68
79
  const token = `${PENDING_PREFIX}${generateId()}`;
69
80
  const acquired = await redis.set(key, token, "EX", pendingTtl, "NX");
@@ -138,5 +149,10 @@ export function createIdempotencyGuard(
138
149
  // SET and could stomp that fresher result with our stale one.
139
150
  void written;
140
151
  },
152
+
153
+ async release(tenantId, userId, requestId, token) {
154
+ const key = `${prefix}${tenantId}:${userId}:${requestId}`;
155
+ await redis.eval(releaseScript, 1, key, token);
156
+ },
141
157
  };
142
158
  }
@@ -1,4 +1,9 @@
1
- import { type TenantDb, withUnsafeRawGrant } from "../db/tenant-db";
1
+ import {
2
+ type TenantDb,
3
+ type UncheckedSystemDb,
4
+ withSystemDbUnsafeRawGrant,
5
+ withUnsafeRawGrant,
6
+ } from "../db/tenant-db";
2
7
  import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user";
3
8
  import type {
4
9
  ActiveMembershipResult,
@@ -214,7 +219,10 @@ function readIdentitySwitchFn<
214
219
  }
215
220
 
216
221
  // context's own keys only — never touch a property of the resolved value, which may be a Proxy that throws on any get.
217
- function readDbLikeValue(context: object, key: "db" | "dbOutsideTransaction"): object | undefined {
222
+ function readDbLikeValue(
223
+ context: object,
224
+ key: "db" | "dbOutsideTransaction" | "systemDb",
225
+ ): object | undefined {
218
226
  if (!(key in context)) return undefined;
219
227
  const value = (context as Record<string, unknown>)[key];
220
228
  return typeof value === "object" && value !== null ? value : undefined;
@@ -340,6 +348,7 @@ export function withHookEscapeHatchGrant<TContext extends object>(
340
348
  const ctxQueryProjection = readIdentitySwitchFn<ProjectionReader>(context, "queryProjection");
341
349
  const ctxDb = readDbLikeValue(context, "db");
342
350
  const ctxDbOutsideTransaction = readDbLikeValue(context, "dbOutsideTransaction");
351
+ const ctxSystemDb = readDbLikeValue(context, "systemDb");
343
352
  if (
344
353
  !ctxQueryAs &&
345
354
  !ctxWriteAs &&
@@ -347,7 +356,8 @@ export function withHookEscapeHatchGrant<TContext extends object>(
347
356
  !ctxQueryAsMember &&
348
357
  !ctxQueryProjection &&
349
358
  !ctxDb &&
350
- !ctxDbOutsideTransaction
359
+ !ctxDbOutsideTransaction &&
360
+ !ctxSystemDb
351
361
  ) {
352
362
  return context;
353
363
  }
@@ -366,10 +376,18 @@ export function withHookEscapeHatchGrant<TContext extends object>(
366
376
  ...(ctxDbOutsideTransaction && {
367
377
  dbOutsideTransaction: withUnsafeRawGrant(ctxDbOutsideTransaction as TenantDb, escapeHatch),
368
378
  }),
379
+ // @cast-boundary engine-bridge — withSystemDbUnsafeRawGrant passes non-UncheckedSystemDb values through unchanged.
380
+ ...(ctxSystemDb && {
381
+ systemDb: withSystemDbUnsafeRawGrant(
382
+ ctxSystemDb as UncheckedSystemDb,
383
+ escapeHatch,
384
+ callerLabel,
385
+ ),
386
+ }),
369
387
  };
370
388
  }
371
389
 
372
- // Re-gates a hook's own ctx.queryAs/ctx.writeAs/ctx.queryProjection/ctx.db/ctx.dbOutsideTransaction instead of inheriting the handler's grant.
390
+ // Re-gates a hook's own ctx.queryAs/ctx.writeAs/ctx.queryProjection/ctx.db/ctx.dbOutsideTransaction/ctx.systemDb instead of inheriting the handler's grant.
373
391
  export function bindHookEscapeHatchGrant(
374
392
  fn: LifecycleHookFn,
375
393
  label: string,
@@ -0,0 +1,62 @@
1
+ // Captures a real closed-connection error instead of a hand-built fake —
2
+ // the production matcher checks driver-specific codes a fake can't reproduce.
3
+
4
+ import postgres from "postgres";
5
+ import { isClosedConnectionError } from "../bun-db/query";
6
+
7
+ export function testDatabaseUrl(): string {
8
+ return (
9
+ process.env["TEST_DATABASE_URL"] ??
10
+ process.env["DATABASE_URL"] ??
11
+ "postgresql://kumiko:kumiko@localhost:15432/kumiko_test"
12
+ );
13
+ }
14
+
15
+ const MAX_WARMUP_ROUNDS = 10;
16
+ const READS_PER_ROUND = 5;
17
+
18
+ type AdminClient = { unsafe(sql: string, params?: readonly unknown[]): Promise<unknown> };
19
+
20
+ // A per-round admin client hits a postgres-js reconnect timing bug (the first
21
+ // retry after terminate hangs); a single long-lived admin client avoids it.
22
+ export async function terminateBackendsByApplicationName(
23
+ admin: AdminClient,
24
+ applicationName: string,
25
+ ): Promise<void> {
26
+ await admin.unsafe(
27
+ "select pg_terminate_backend(pid) from pg_stat_activity where application_name = $1",
28
+ [applicationName],
29
+ );
30
+ }
31
+
32
+ // Terminates a throwaway pool's backends and races reads right after — the
33
+ // dead-connection window is only a few ms wide, so this retries rounds until one lands.
34
+ export async function captureClosedConnectionError(
35
+ url: string = testDatabaseUrl(),
36
+ ): Promise<unknown> {
37
+ const admin = postgres(url, { max: 1 });
38
+ try {
39
+ for (let round = 0; round < MAX_WARMUP_ROUNDS; round++) {
40
+ const applicationName = `kumiko-closed-conn-test-${crypto.randomUUID()}`;
41
+ const pool = postgres(url, { max: 1, connection: { application_name: applicationName } });
42
+ try {
43
+ await pool.unsafe("select 1");
44
+ await terminateBackendsByApplicationName(admin, applicationName);
45
+ for (let read = 0; read < READS_PER_ROUND; read++) {
46
+ try {
47
+ await pool.unsafe("select 1");
48
+ } catch (err) {
49
+ if (isClosedConnectionError(err)) return err;
50
+ }
51
+ }
52
+ } finally {
53
+ await pool.end({ timeout: 0 });
54
+ }
55
+ }
56
+ throw new Error(
57
+ `captureClosedConnectionError: no closed-connection error observed after ${MAX_WARMUP_ROUNDS} rounds.`,
58
+ );
59
+ } finally {
60
+ await admin.end({ timeout: 0 });
61
+ }
62
+ }
@@ -15,6 +15,7 @@ export { resetEntityFieldEncryptionCacheForTests } from "../db/entity-field-encr
15
15
  export { rolesOf } from "./access-assertions";
16
16
  export { expectError, expectSuccess } from "./assertions";
17
17
  export { withBootValidatorFixture } from "./boot-validator-fixture";
18
+ export { captureClosedConnectionError } from "./closed-connection-error";
18
19
  export { type ClearableTable, clearTables, resetTestTables } from "./db-cleanup";
19
20
  export {
20
21
  type E2EGeneratorOptions,
@@ -1,79 +0,0 @@
1
- // #1163: Bun.SQL can hand out a closed connection under load (AbortError
2
- // "The connection was closed."). Pure reads retry exactly once on a fresh
3
- // pool checkout; tx handles, non-matching errors, and genuine user aborts
4
- // must NOT retry.
5
-
6
- import { describe, expect, test } from "bun:test";
7
- import { buildEntityTable } from "../../db/table-builder";
8
- import { selectMany } from "../query";
9
-
10
- function closedConnectionError(): Error {
11
- return Object.assign(new Error("The connection was closed."), { name: "AbortError" });
12
- }
13
-
14
- type FakeClient = {
15
- unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
16
- begin?: () => never;
17
- calls: number;
18
- };
19
-
20
- function fakeClient(failures: Error[], opts: { tx?: boolean } = {}): FakeClient {
21
- const remaining = [...failures];
22
- const client: FakeClient = {
23
- calls: 0,
24
- unsafe: async () => {
25
- client.calls++;
26
- const err = remaining.shift();
27
- if (err) throw err;
28
- return [{ id: "r1", title: "ok", tenant_id: "t1", inserted_at: null, updated_at: null }];
29
- },
30
- };
31
- // A top-level pool client has begin(); a transaction handle does not.
32
- if (!opts.tx)
33
- client.begin = () => {
34
- throw new Error("not used in test");
35
- };
36
- return client;
37
- }
38
-
39
- const table = buildEntityTable("note", {
40
- fields: { title: { type: "text", required: true } },
41
- });
42
-
43
- describe("selectMany — closed-connection retry (#1163)", () => {
44
- test("retries once on AbortError 'connection was closed' and returns rows", async () => {
45
- const db = fakeClient([closedConnectionError()]);
46
- const rows = await selectMany(db, table);
47
- expect(rows).toHaveLength(1);
48
- expect(rows[0]?.title).toBe("ok");
49
- expect(db.calls).toBe(2);
50
- });
51
-
52
- test("gives up after the single retry when the connection stays closed", async () => {
53
- const db = fakeClient([closedConnectionError(), closedConnectionError()]);
54
- await expect(selectMany(db, table)).rejects.toThrow("connection was closed");
55
- expect(db.calls).toBe(2);
56
- });
57
-
58
- test("never retries on a transaction handle (no begin)", async () => {
59
- const db = fakeClient([closedConnectionError()], { tx: true });
60
- await expect(selectMany(db, table)).rejects.toThrow("connection was closed");
61
- expect(db.calls).toBe(1);
62
- });
63
-
64
- test("does not retry a genuine user abort (different message)", async () => {
65
- const userAbort = Object.assign(new Error("The operation was aborted."), {
66
- name: "AbortError",
67
- });
68
- const db = fakeClient([userAbort]);
69
- await expect(selectMany(db, table)).rejects.toThrow("operation was aborted");
70
- expect(db.calls).toBe(1);
71
- });
72
-
73
- test("does not retry generic query errors", async () => {
74
- const syntax = Object.assign(new Error("syntax error at or near"), { name: "PostgresError" });
75
- const db = fakeClient([syntax]);
76
- await expect(selectMany(db, table)).rejects.toThrow("syntax error");
77
- expect(db.calls).toBe(1);
78
- });
79
- });