@cosmicdrift/kumiko-framework 0.304.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.
- package/package.json +4 -4
- package/src/api/__tests__/extra-route-rejection.test.ts +38 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
- package/src/api/__tests__/server-boot-guards.test.ts +1 -0
- package/src/api/__tests__/server-error-logging.test.ts +104 -0
- package/src/api/api-constants.ts +13 -0
- package/src/api/extra-route.ts +33 -4
- package/src/api/index.ts +1 -0
- package/src/api/request-context.ts +5 -4
- package/src/api/routes.ts +26 -1
- package/src/api/server.ts +8 -2
- package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
- package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
- package/src/bun-db/query.ts +42 -18
- package/src/changes.json +108 -0
- package/src/db/__tests__/pg-error.test.ts +14 -0
- package/src/db/__tests__/system-db-view-export.test.ts +107 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
- package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
- package/src/db/event-store-executor-write.ts +7 -0
- package/src/db/index.ts +1 -1
- package/src/db/pg-error.ts +13 -0
- package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
- package/src/db/tenant-db.ts +140 -16
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
- package/src/engine/__tests__/boot-validator.test.ts +1 -1
- package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
- package/src/engine/boot-validator/access-declarations.ts +5 -66
- package/src/engine/extension-names.ts +55 -25
- package/src/engine/extensions/storage-provider.ts +14 -41
- package/src/engine/extensions/tenant-data.ts +4 -0
- package/src/engine/extensions/tenant-resource.ts +40 -0
- package/src/engine/extensions/user-data.ts +8 -7
- package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
- package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
- package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
- package/src/engine/feature-ast/entity-field-types.ts +41 -0
- package/src/engine/feature-ast/extractors/handlers.ts +217 -84
- package/src/engine/feature-ast/extractors/hooks.ts +72 -15
- package/src/engine/feature-ast/extractors/round2.ts +21 -0
- package/src/engine/feature-ast/extractors/shared.ts +9 -0
- package/src/engine/feature-ast/index.ts +11 -1
- package/src/engine/feature-ast/patch.ts +338 -5
- package/src/engine/feature-ast/patcher.ts +2 -2
- package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
- package/src/engine/feature-ast/patterns.ts +22 -15
- package/src/engine/feature-ast/render.ts +1 -0
- package/src/engine/feature-ui-extensions.ts +8 -7
- package/src/engine/index.ts +23 -5
- package/src/engine/personal-data-fields.ts +66 -0
- package/src/engine/registry-validate.ts +15 -0
- package/src/engine/registry.ts +2 -0
- package/src/engine/types/extension-options-map.ts +1 -0
- package/src/engine/types/index.ts +8 -0
- package/src/env/__tests__/dry-run.test.ts +43 -3
- package/src/env/dry-run.ts +28 -15
- package/src/errors/__tests__/write-failures.test.ts +47 -4
- package/src/errors/i18n/de.yaml +12 -0
- package/src/errors/i18n/en.yaml +12 -0
- package/src/errors/reasons.ts +4 -0
- package/src/errors/write-error-info.ts +12 -3
- package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -0
- package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
- package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +38 -3
- package/src/jobs/job-runner.ts +170 -19
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
- package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
- package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
- package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
- package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +425 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
- package/src/pipeline/active-membership.ts +5 -1
- package/src/pipeline/dispatch-batch.ts +59 -13
- package/src/pipeline/dispatch-query.ts +16 -5
- package/src/pipeline/dispatch-shared.ts +12 -5
- package/src/pipeline/dispatch-stream.ts +7 -2
- package/src/pipeline/dispatch-write.ts +22 -5
- package/src/pipeline/dispatcher.ts +9 -2
- package/src/pipeline/idempotency.ts +16 -0
- package/src/pipeline/member-reader.ts +3 -1
- package/src/pipeline/system-identity-switch.ts +22 -4
- package/src/pipeline/write-origin.ts +107 -0
- package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
- package/src/rate-limit/middleware.ts +3 -0
- package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
- package/src/stack/test-stack.ts +5 -0
- package/src/testing/closed-connection-error.ts +62 -0
- package/src/testing/index.ts +1 -0
- package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// selectMany retries a real captured closed-connection error, never a
|
|
2
|
+
// client abort or a transaction/reserved handle. Live-driver proof is in
|
|
3
|
+
// closed-connection-retry.integration.test.ts.
|
|
4
|
+
|
|
5
|
+
import { beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
+
import { buildEntityTable } from "../../db/table-builder";
|
|
7
|
+
import { captureClosedConnectionError } from "../../testing/closed-connection-error";
|
|
8
|
+
import { selectMany } from "../query";
|
|
9
|
+
|
|
10
|
+
let closedConnectionError: unknown;
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
closedConnectionError = await captureClosedConnectionError();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
type FakeClientOptions = {
|
|
17
|
+
reserve?: boolean;
|
|
18
|
+
begin?: boolean;
|
|
19
|
+
savepoint?: boolean;
|
|
20
|
+
release?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type FakeClient = {
|
|
24
|
+
unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
|
|
25
|
+
begin?: () => never;
|
|
26
|
+
savepoint?: () => never;
|
|
27
|
+
release?: () => never;
|
|
28
|
+
reserve?: () => never;
|
|
29
|
+
options?: { max: number };
|
|
30
|
+
calls: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function fakeClient(failures: unknown[], opts: FakeClientOptions = {}): FakeClient {
|
|
34
|
+
const remaining = [...failures];
|
|
35
|
+
const client: FakeClient = {
|
|
36
|
+
calls: 0,
|
|
37
|
+
unsafe: async () => {
|
|
38
|
+
client.calls++;
|
|
39
|
+
const err = remaining.shift();
|
|
40
|
+
if (err) throw err;
|
|
41
|
+
return [{ id: "r1", title: "ok", tenant_id: "t1", inserted_at: null, updated_at: null }];
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
const { begin = true, savepoint = false, release = false, reserve = true } = opts;
|
|
45
|
+
if (begin) {
|
|
46
|
+
client.begin = () => {
|
|
47
|
+
throw new Error("not used in test");
|
|
48
|
+
};
|
|
49
|
+
client.options = { max: 1 };
|
|
50
|
+
}
|
|
51
|
+
if (savepoint)
|
|
52
|
+
client.savepoint = () => {
|
|
53
|
+
throw new Error("not used in test");
|
|
54
|
+
};
|
|
55
|
+
if (release)
|
|
56
|
+
client.release = () => {
|
|
57
|
+
throw new Error("not used in test");
|
|
58
|
+
};
|
|
59
|
+
if (reserve)
|
|
60
|
+
client.reserve = () => {
|
|
61
|
+
throw new Error("not used in test");
|
|
62
|
+
};
|
|
63
|
+
return client;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const table = buildEntityTable("note", {
|
|
67
|
+
fields: { title: { type: "text", required: true } },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("selectMany — closed-connection retry", () => {
|
|
71
|
+
test("retries once on a real closed-connection error and returns rows", async () => {
|
|
72
|
+
const db = fakeClient([closedConnectionError]);
|
|
73
|
+
const rows = await selectMany(db, table);
|
|
74
|
+
expect(rows).toHaveLength(1);
|
|
75
|
+
expect(rows[0]?.title).toBe("ok");
|
|
76
|
+
expect(db.calls).toBe(2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("survives two dead connections in a row and returns rows", async () => {
|
|
80
|
+
const db = fakeClient([closedConnectionError, closedConnectionError]);
|
|
81
|
+
const rows = await selectMany(db, table);
|
|
82
|
+
expect(rows).toHaveLength(1);
|
|
83
|
+
expect(db.calls).toBe(3);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("gives up after exhausting pool-bounded retries (max: 1 → 3 total calls)", async () => {
|
|
87
|
+
const db = fakeClient([closedConnectionError, closedConnectionError, closedConnectionError]);
|
|
88
|
+
await expect(selectMany(db, table)).rejects.toBe(closedConnectionError);
|
|
89
|
+
expect(db.calls).toBe(3);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("never retries on a transaction handle (savepoint, no begin)", async () => {
|
|
93
|
+
const db = fakeClient([closedConnectionError], {
|
|
94
|
+
begin: false,
|
|
95
|
+
savepoint: true,
|
|
96
|
+
reserve: false,
|
|
97
|
+
});
|
|
98
|
+
await expect(selectMany(db, table)).rejects.toBe(closedConnectionError);
|
|
99
|
+
expect(db.calls).toBe(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("never retries on a Bun.SQL-tx-shaped handle (begin + savepoint)", async () => {
|
|
103
|
+
const db = fakeClient([closedConnectionError], {
|
|
104
|
+
begin: true,
|
|
105
|
+
savepoint: true,
|
|
106
|
+
reserve: false,
|
|
107
|
+
});
|
|
108
|
+
await expect(selectMany(db, table)).rejects.toBe(closedConnectionError);
|
|
109
|
+
expect(db.calls).toBe(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("never retries on a reserved handle (begin + release)", async () => {
|
|
113
|
+
const db = fakeClient([closedConnectionError], { begin: true, release: true, reserve: false });
|
|
114
|
+
await expect(selectMany(db, table)).rejects.toBe(closedConnectionError);
|
|
115
|
+
expect(db.calls).toBe(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("does not retry a genuine user abort", async () => {
|
|
119
|
+
const userAbort = new DOMException("The operation was aborted.", "AbortError");
|
|
120
|
+
const db = fakeClient([userAbort]);
|
|
121
|
+
await expect(selectMany(db, table)).rejects.toBe(userAbort);
|
|
122
|
+
expect(db.calls).toBe(1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("does not retry a client abort whose message mentions a closed connection", async () => {
|
|
126
|
+
const clientAbort = new DOMException("The connection was closed.", "AbortError");
|
|
127
|
+
const db = fakeClient([clientAbort]);
|
|
128
|
+
await expect(selectMany(db, table)).rejects.toBe(clientAbort);
|
|
129
|
+
expect(db.calls).toBe(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("does not retry generic query errors", async () => {
|
|
133
|
+
const syntax = Object.assign(new Error("syntax error at or near"), { name: "PostgresError" });
|
|
134
|
+
const db = fakeClient([syntax]);
|
|
135
|
+
await expect(selectMany(db, table)).rejects.toThrow("syntax error");
|
|
136
|
+
expect(db.calls).toBe(1);
|
|
137
|
+
});
|
|
138
|
+
});
|
package/src/bun-db/query.ts
CHANGED
|
@@ -729,26 +729,41 @@ function buildWhereClause(
|
|
|
729
729
|
return { sqlText: conditions.join(" AND "), values };
|
|
730
730
|
}
|
|
731
731
|
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
|
|
735
|
-
//
|
|
736
|
-
|
|
737
|
-
//
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
const
|
|
732
|
+
// A pool can briefly keep handing out connections the server just closed.
|
|
733
|
+
// Each failed attempt discards one, so retries are bounded by pool size.
|
|
734
|
+
const CLOSED_CONNECTION_CODES: ReadonlySet<string> = new Set([
|
|
735
|
+
"CONNECTION_CLOSED", // postgres-js
|
|
736
|
+
"ERR_POSTGRES_CONNECTION_CLOSED", // Bun.SQL
|
|
737
|
+
"57P01", // PG SQLSTATE: admin_shutdown / terminated backend
|
|
738
|
+
]);
|
|
739
|
+
|
|
740
|
+
export function isClosedConnectionError(err: unknown): boolean {
|
|
741
|
+
const code = extractPgError(err)?.code;
|
|
742
|
+
return code !== undefined && CLOSED_CONNECTION_CODES.has(code);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Tx/reserved handles are pinned to one physical connection — a retry there
|
|
746
|
+
// would run on the same dead connection, not swap in a fresh one.
|
|
747
|
+
function isPooledClient(raw: unknown): boolean {
|
|
748
|
+
if (raw === null || (typeof raw !== "object" && typeof raw !== "function")) return false;
|
|
749
|
+
// @cast-boundary driver handle shape — begin/savepoint/release are optional across drivers
|
|
750
|
+
const r = raw as { begin?: unknown; savepoint?: unknown; release?: unknown };
|
|
742
751
|
return (
|
|
743
|
-
|
|
744
|
-
typeof
|
|
745
|
-
|
|
752
|
+
typeof r.begin === "function" &&
|
|
753
|
+
typeof r.savepoint !== "function" &&
|
|
754
|
+
typeof r.release !== "function"
|
|
746
755
|
);
|
|
747
756
|
}
|
|
748
757
|
|
|
758
|
+
function poolMaxOf(raw: unknown): number {
|
|
759
|
+
// @cast-boundary driver pool options — both postgres-js and Bun.SQL expose options.max
|
|
760
|
+
const max = (raw as { options?: { max?: unknown } }).options?.max;
|
|
761
|
+
return typeof max === "number" && Number.isInteger(max) && max > 0 ? max : 10;
|
|
762
|
+
}
|
|
763
|
+
|
|
749
764
|
// Exported so raw-SQL query modules outside bun-db (e.g. bundled-features'
|
|
750
|
-
// db/queries/*.ts) can opt into the same
|
|
751
|
-
// asRawClient(db).unsafe(...) directly and losing it.
|
|
765
|
+
// db/queries/*.ts) can opt into the same closed-connection retry instead of
|
|
766
|
+
// calling asRawClient(db).unsafe(...) directly and losing it.
|
|
752
767
|
// READS ONLY — retry re-executes the statement; never pass INSERT/UPDATE/DELETE.
|
|
753
768
|
export async function unsafeReadRetrying<TRow>(
|
|
754
769
|
db: AnyDb,
|
|
@@ -759,9 +774,18 @@ export async function unsafeReadRetrying<TRow>(
|
|
|
759
774
|
try {
|
|
760
775
|
return (await raw.unsafe(sqlText, params)) as readonly TRow[];
|
|
761
776
|
} catch (err) {
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
777
|
+
if (!isPooledClient(raw) || !isClosedConnectionError(err)) throw err;
|
|
778
|
+
const maxAttempts = poolMaxOf(raw) + 1;
|
|
779
|
+
let lastErr: unknown = err;
|
|
780
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
781
|
+
try {
|
|
782
|
+
return (await raw.unsafe(sqlText, params)) as readonly TRow[];
|
|
783
|
+
} catch (retryErr) {
|
|
784
|
+
lastErr = retryErr;
|
|
785
|
+
if (!isClosedConnectionError(retryErr)) throw retryErr;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
throw lastErr;
|
|
765
789
|
}
|
|
766
790
|
}
|
|
767
791
|
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,112 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.306.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "A lifecycle hook's own escapeHatch now gates ctx.systemDb.unsafeRaw (fw#3198)",
|
|
6
|
+
"migration": "Hooks, die in r.systemScope()-Handlern ctx.systemDb.unsafeRaw nutzen, deklarieren escapeHatch: { reason } in den r.hook-Optionen"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.306.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "BullMQ jobs no longer stay in Redis forever: completed jobs are kept 24h, failed jobs 7d (fw#3199)",
|
|
12
|
+
"detail": "Both job-runner lane queues now set age-only retention via defaultJobOptions, so dispatch(), handleEvent(), perTenant wrappers and children, cron, runOnBoot and sequential re-enqueues are all covered. BullMQ sweeps retention queue-wide, so there is no count limit and no per-job retention: the cron template's count-based removeOnComplete/removeOnFail is gone because it evicted boot jobs and perTenant children in the same queue. runOnBoot now dedupes via a persistent per-queue marker, so it still runs at most once per Redis dataset after its job hash ages out. On existing datasets a boot job re-runs once only if its job hash was already evicted. A perTenant job whose retry window reaches the completed retention now fails at job-runner construction. Cron iterations scheduled before the deploy keep the old template options for one more run."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.306.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "Client disconnects answer 499 on queries and no longer abort writes",
|
|
18
|
+
"detail": "- Queries: a failure caused by this request's own abort signal now answers `499` and logs `[api] request aborted by client` on warn instead of a 5xx server fault.\n- Writes (`/api/write`, `/api/batch`, `command`): write dispatch no longer receives the request's abort signal, so a disconnect can't roll back a transaction halfway and leave a cached 500 under the request's idempotency key. `ctx.signal` is `undefined` inside write handlers and their hooks."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.306.0",
|
|
22
|
+
"type": "breaking",
|
|
23
|
+
"title": "createUncheckedSystemDb is no longer exported from /db; use createSystemDbView, whose unsafeRaw follows the source TenantDb's escapeHatch gate (fw#3205)",
|
|
24
|
+
"migration": "Import auf createSystemDbView umstellen; wer unsafeRaw auf einem selbstgebauten systemDb braucht, übergibt eine TenantDb mit unsafeRaw-Grant (createTenantDb(..., { unsafeRaw: { reason } })).\nDelivery: ein tenantUserIdsQuery-Handler ohne r.systemScope() bekommt jetzt wie im Dispatcher eine tenant-mode ctx.db und kein ctx.systemDb; Handler, die Cross-Tenant-Zugriff brauchen, deklarieren r.systemScope()."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.306.0",
|
|
28
|
+
"type": "fix",
|
|
29
|
+
"title": "Feature-AST keeps referenced handler access/rateLimit headers",
|
|
30
|
+
"detail": "Handler headers (access, rateLimit, escapeHatch, agent on write/query/stream handlers, and escapeHatch on r.hook) authored as an imported or same-file const now round-trip verbatim through the feature AST instead of being silently dropped. `rateLimit: { disabled: true, reason }` is now extracted. streamHandler's escapeHatch is now extracted and rendered. A new ParseError: a fully literal header value with an unrecognized shape (used to silently drop the header instead). Handler calls whose object/options contain a spread, an unmodeled key (e.g. `outputSchema`, `perform`) or a non-literal options argument are now kept verbatim as an opaque pattern instead of losing those parts on render. `parsePatternChanges` accepts these reference and disabled-rate-limit shapes too."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "0.306.0",
|
|
34
|
+
"type": "improvement",
|
|
35
|
+
"title": "PatternChange gets an update op that changes individual handler header fields (access, rateLimit, description, agent, escapeHatch, unsafeSkipTransitionGuard) without resending schema or handler bodies",
|
|
36
|
+
"detail": "applyChanges/updatePattern edit only the named properties of a write/query/stream handler's inline object literal; bodies, comments and all other properties stay byte-identical. parsePatternChanges validates set/unset per handler kind with exact paths (access cannot be unset). escapeHatch.reason must now be non-empty in PatternChange input, matching the boot validator."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"version": "0.306.0",
|
|
40
|
+
"type": "breaking",
|
|
41
|
+
"title": "r.useExtension options are typed per extension point; a hook with the wrong ctx signature is a compile error",
|
|
42
|
+
"migration": "Registrations of known extension points (tenantData, userData, fileProvider, derivativeRenderer, derivativeOverlayResolver, derivativePublicPredicate, principalStatus, tenantLifecycleStatus, tokenVerifier, sessionStore, tenantResolver, tenantExistence) now type-check their options, and options are required for them. Fix the reported mismatches: tenantData destroy hooks take TenantDataHookCtx and use ctx.db.* methods. For raw access such as archiveStream, declare escapeHatch: { reason } on the r.useExtension registration (runtime grant), call declareEscapeHatch({ reason }) as a direct statement in the hook body (Escape-Hatch-Declared guard), then use ctx.db.unsafeRaw(reason). userData registrations need at least one of export/delete (plus optional order). PrincipalStatusPlugin needs resolveProfile, FileProviderPlugin fakes need list(). App-owned points can opt in by augmenting KumikoExtensionOptionsMap via declare module \"@cosmicdrift/kumiko-framework/engine\"; unknown names keep the untyped options bag."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"version": "0.306.0",
|
|
46
|
+
"type": "fix",
|
|
47
|
+
"title": "Read retry recognizes real closed pool connections instead of client aborts",
|
|
48
|
+
"detail": "The closed-connection read retry now checks driver error codes (postgres-js CONNECTION_CLOSED, Bun ERR_POSTGRES_CONNECTION_CLOSED, SQLSTATE 57P01) instead of an AbortError name and message. It retries up to pool size plus one attempt, never retries a genuine client abort, and never retries on a transaction or reserved handle. extractPgError/isUniqueViolation/constraintOf now also work against Bun.SQL errors."
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"version": "0.306.0",
|
|
52
|
+
"type": "breaking",
|
|
53
|
+
"title": "Tenant-resource and tenantTierResolver extension options are typed; invalid registrations fail tenant destroy loudly",
|
|
54
|
+
"migration": "Registrations of storageProvider, searchAdapter, externalResource and infraResource now require options of type TenantResourceExtensionHooks (destroyTenant(tenantId, ctx) => Promise<void>); tenantTierResolver requires a TierResolverPlugin with build. StorageProvider* types remain as aliases of the new TenantResource* types. A tenantData or tenant-resource registration whose destroy hook is missing now fails the destruction stage with the extension and entity name instead of being skipped."
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"version": "0.306.0",
|
|
58
|
+
"type": "breaking",
|
|
59
|
+
"title": "Idempotent retries re-run after a rolled-back 5xx instead of replaying it",
|
|
60
|
+
"detail": "Because the retry re-runs, non-transactional side effects of the failed attempt (writes through `ctx.dbOutsideTransaction`, external calls made inside the handler) run again.\n`IdempotencyGuard` has a new required method `release(tenantId, userId, requestId, token)` that frees the in-progress lock (token-guarded, like `store`) instead of persisting a result.",
|
|
61
|
+
"migration": "Custom IdempotencyGuard implementations must add release(tenantId, userId, requestId, token), which deletes the pending lock only if it still holds that token"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"version": "0.306.0",
|
|
65
|
+
"type": "fix",
|
|
66
|
+
"title": "Cron, runOnBoot, perTenant and sequential re-enqueued jobs now retry per their retries/backoff (fw#3184)"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"version": "0.305.0",
|
|
70
|
+
"type": "breaking",
|
|
71
|
+
"title": "rateLimit.auth (L2) no longer throttles GET /api/auth/tenants",
|
|
72
|
+
"detail": "The SPA's own session bootstrap called GET /api/auth/tenants on every\npage load, which counted against the L2 auth-endpoint bucket and threw\n429 on the 6th page load within a minute. This route is now exempted\nfrom rateLimit.auth by exact method+path match; all other auth routes\n(including POST on the same path, if ever added) are unaffected.",
|
|
73
|
+
"migration": "Apps using the default rateLimit.auth need no changes. Apps that want\nto keep throttling GET /api/auth/tenants should rely on rateLimit.global\n(L1, IP-based) instead. Credential-submitting POST routes are unaffected\neven when rateLimit.auth's `path` option is customized."
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"version": "0.305.0",
|
|
77
|
+
"type": "fix",
|
|
78
|
+
"title": "KUMIKO_DRY_RUN_ENV=pulumi and =k8s now list optional env keys (e.g. PROMETHEUS_METRICS_TOKEN) as commented-out lines under an \"Optional\" header, with secret flag, generator and description; defaulted keys stay omitted",
|
|
79
|
+
"migration": "No action needed. Uncomment and set an optional line only when you want to enable that feature."
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"version": "0.305.0",
|
|
83
|
+
"type": "improvement",
|
|
84
|
+
"title": "ExtraRouteRejection supports 503 + Retry-After for signature routes whose verify() is temporarily unable to run"
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"version": "0.305.0",
|
|
88
|
+
"type": "fix",
|
|
89
|
+
"title": "Job backoff now waits between retries: backoff defaults to a 1000 ms base delay and accepts { type, delayMs } (fw#3167)",
|
|
90
|
+
"detail": "Previously, jobs with backoff set retried immediately: BullMQ received only { type } with no delay, and its fixed/exponential strategies compute NaN/undefined without one (falsy, so no wait). Now \"fixed\" waits a constant 1000 ms and \"exponential\" waits 1000/2000/4000 ms... between attempts by default. Jobs with a high retries count will therefore take noticeably longer to reach their final failure. A new object form, backoff: { type, delayMs }, lets a job configure its own base delay instead of the 1000 ms default."
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"version": "0.305.0",
|
|
94
|
+
"type": "breaking",
|
|
95
|
+
"title": "Writes under an anonymous root need access.personalData: \"public-intake\" at runtime, across feature boundaries (fw#3165)",
|
|
96
|
+
"detail": "The boot check from fw#2885 only sees personal-data keys in an anonymous write handler's own input schema, for entities of its own feature. Every public dispatch (write, batch command, query, stream) now computes a WriteOrigin (root handler, anonymous root, public-intake declared) once. Every nested call inherits it: ctx.write, ctx.writeAs, ctx.query, ctx.queryAs, nested writes and afterCommit hooks. When the root is anonymous and does not declare public-intake, a write that touches a personal-data field (pii / userOwned / recordOwned) of any registered entity fails with AccessDeniedError, details.reason \"public_intake_required\". The error details name the root handler, the table and the fields, never the values. The check runs in TenantDb.insertOne/updateMany, in db.global().insertOne/updateMany and in the event-sourced create/update executor, after preSave and before the event append. It also covers rebound TenantDbs (acknowledgeCrossTenant, hook re-gating). Authenticated sessions are not affected. Not gated: ctx.db.unsafeRaw (covered by escapeHatch plus audit); ctx.appendEvent on a feature's own events (foreign events are already rejected); tables outside the registered entities; jobs and event subscribers queued from an anonymous root; and TenantDbs that handler code builds directly with createTenantDb. The last two are tracked in fw#3185, which also lists the bundled auth-email-password and user-data-rights flows that go through unsafeRaw or createTenantDb.",
|
|
97
|
+
"migration": "A write handler that anonymous callers can reach (roles include \"anonymous\") and that writes a personal-data field (pii / userOwned / recordOwned) of any entity must declare access: { roles: [..., \"anonymous\"], personalData: \"public-intake\" }. This applies whether the handler writes the field itself or through ctx.db, the CRUD executor, ctx.write, ctx.writeAs/queryAs or a postSave/afterCommit hook, and it applies across features. Without the declaration the write now fails with AccessDeniedError (details.reason \"public_intake_required\"). A failing afterCommit hook is only logged, and its write does not happen. Known consumer handlers, measured on 23.09.2026: offlot-app waitlist:submit, vehicle-enquiry:submit and try-first:set-contact; publicstatus email-subscriber:subscribe; show-pony rsvp:submit. Add the declaration if the anonymous intake is intended (the handler's rateLimit is then the only protection). Otherwise stop writing the field from the anonymous path."
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"version": "0.305.0",
|
|
101
|
+
"type": "improvement",
|
|
102
|
+
"title": "setupTestStack accepts a metrics option, forwarded to buildServer like runProdApp (fw#3182)",
|
|
103
|
+
"detail": "TestStackOptions gains an optional metrics field forwarded to buildServer, same as runProdApp. Spread resolveObservabilityWiring(token) into setupTestStack/setupTestStackFromFeatures/setupAppTestStack to get /metrics mounted in integration tests with the same token-gated PrometheusMeter-backed behavior as prod."
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"version": "0.305.0",
|
|
107
|
+
"type": "fix",
|
|
108
|
+
"title": "Write-path 5xx logs now include the original cause chain"
|
|
109
|
+
},
|
|
2
110
|
{
|
|
3
111
|
"version": "0.303.0",
|
|
4
112
|
"type": "breaking",
|
|
@@ -18,6 +18,20 @@ describe("extractPgError", () => {
|
|
|
18
18
|
test("returns null for non-objects", () => {
|
|
19
19
|
expect(extractPgError("nope")).toBeNull();
|
|
20
20
|
});
|
|
21
|
+
|
|
22
|
+
test("normalizes Bun.SQL's errno/constraint into code/constraint_name", () => {
|
|
23
|
+
const info = extractPgError({
|
|
24
|
+
code: "ERR_POSTGRES_SERVER_ERROR",
|
|
25
|
+
errno: "23505",
|
|
26
|
+
constraint: "users_email_uq",
|
|
27
|
+
});
|
|
28
|
+
expect(info).toEqual({ code: "23505", constraint_name: "users_email_uq" });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("leaves ERR_POSTGRES_SERVER_ERROR untouched when errno isn't a string", () => {
|
|
32
|
+
const info = extractPgError({ code: "ERR_POSTGRES_SERVER_ERROR" });
|
|
33
|
+
expect(info).toEqual({ code: "ERR_POSTGRES_SERVER_ERROR", constraint_name: undefined });
|
|
34
|
+
});
|
|
21
35
|
});
|
|
22
36
|
|
|
23
37
|
describe("isUniqueViolation", () => {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// createUncheckedSystemDb() is framework-private (not re-exported from
|
|
2
|
+
// db/index.ts); createSystemDbView is the public replacement, whose
|
|
3
|
+
// unsafeRaw follows the source TenantDb's own escapeHatch gate instead of
|
|
4
|
+
// handing out an ungated tenantDbRunner(db).
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import * as dbBarrel from "@cosmicdrift/kumiko-framework/db";
|
|
8
|
+
import { createSystemDbView, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
9
|
+
import { AccessDeniedError } from "../../errors";
|
|
10
|
+
import { testTenantId } from "../../stack";
|
|
11
|
+
import type { DbRunner } from "../connection";
|
|
12
|
+
import { unsafeRawForDeclaredStep, withSystemDbUnsafeRawGrant } from "../tenant-db";
|
|
13
|
+
|
|
14
|
+
const tenantId = testTenantId(1);
|
|
15
|
+
|
|
16
|
+
function fakeRunner(): DbRunner {
|
|
17
|
+
return {
|
|
18
|
+
unsafe: async () => {
|
|
19
|
+
throw new Error("system-db-view-export tests must not reach the DB");
|
|
20
|
+
},
|
|
21
|
+
begin: async () => {
|
|
22
|
+
throw new Error("system-db-view-export tests must not reach the DB");
|
|
23
|
+
},
|
|
24
|
+
} as unknown as DbRunner;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("db barrel export", () => {
|
|
28
|
+
test("createUncheckedSystemDb is no longer exported from @cosmicdrift/kumiko-framework/db", () => {
|
|
29
|
+
expect("createUncheckedSystemDb" in dbBarrel).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("createSystemDbView(...).unsafeRaw", () => {
|
|
34
|
+
test("denies without a grant on the source TenantDb, without reporting on either side", () => {
|
|
35
|
+
const sourceReports: Array<{ kind: string; reason: string }> = [];
|
|
36
|
+
const viewReports: Array<{ kind: string; reason: string }> = [];
|
|
37
|
+
const source = createTenantDb(
|
|
38
|
+
fakeRunner(),
|
|
39
|
+
tenantId,
|
|
40
|
+
"tenant",
|
|
41
|
+
undefined,
|
|
42
|
+
undefined,
|
|
43
|
+
undefined,
|
|
44
|
+
{ report: (kind, reason) => sourceReports.push({ kind, reason }) },
|
|
45
|
+
);
|
|
46
|
+
const view = createSystemDbView(source, undefined, (kind, reason) =>
|
|
47
|
+
viewReports.push({ kind, reason }),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(() => view.unsafeRaw("x")).toThrow(AccessDeniedError);
|
|
51
|
+
expect(sourceReports).toEqual([]);
|
|
52
|
+
expect(viewReports).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("resolves to the source's own bound runner and reports through the source, not the view", () => {
|
|
56
|
+
const runner = fakeRunner();
|
|
57
|
+
const sourceReports: Array<{ kind: string; reason: string }> = [];
|
|
58
|
+
const viewReports: Array<{ kind: string; reason: string }> = [];
|
|
59
|
+
const source = createTenantDb(runner, tenantId, "tenant", undefined, undefined, undefined, {
|
|
60
|
+
unsafeRaw: { reason: "handler declared unsafeRaw" },
|
|
61
|
+
report: (kind, reason) => sourceReports.push({ kind, reason }),
|
|
62
|
+
});
|
|
63
|
+
const view = createSystemDbView(source, undefined, (kind, reason) =>
|
|
64
|
+
viewReports.push({ kind, reason }),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(view.unsafeRaw("x")).toBe(runner);
|
|
68
|
+
expect(sourceReports).toEqual([{ kind: "unsafe-raw", reason: "x" }]);
|
|
69
|
+
expect(viewReports).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("memberReadOnly on the source denies even with an unsafeRaw grant", () => {
|
|
73
|
+
const source = createTenantDb(
|
|
74
|
+
fakeRunner(),
|
|
75
|
+
tenantId,
|
|
76
|
+
"tenant",
|
|
77
|
+
undefined,
|
|
78
|
+
undefined,
|
|
79
|
+
undefined,
|
|
80
|
+
{ unsafeRaw: { reason: "handler declared unsafeRaw" }, memberReadOnly: true },
|
|
81
|
+
);
|
|
82
|
+
const view = createSystemDbView(source);
|
|
83
|
+
|
|
84
|
+
expect(() => view.unsafeRaw("x")).toThrow(AccessDeniedError);
|
|
85
|
+
expect(() => view.unsafeRaw("x")).toThrow(/read-only/);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("unsafeRawForDeclaredStep(createSystemDbView(...))", () => {
|
|
90
|
+
test("throws AccessDeniedError, not InternalError, without a grant on the source", () => {
|
|
91
|
+
const source = createTenantDb(fakeRunner(), tenantId, "tenant");
|
|
92
|
+
const view = createSystemDbView(source);
|
|
93
|
+
|
|
94
|
+
expect(() => unsafeRawForDeclaredStep(view, "x")).toThrow(AccessDeniedError);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("withSystemDbUnsafeRawGrant(createSystemDbView(...))", () => {
|
|
99
|
+
test("returns the view unchanged — no rebind, so no rights upgrade over the source's own gate", () => {
|
|
100
|
+
const source = createTenantDb(fakeRunner(), tenantId, "tenant");
|
|
101
|
+
const view = createSystemDbView(source);
|
|
102
|
+
|
|
103
|
+
const rebound = withSystemDbUnsafeRawGrant(view, { reason: "hook grant" }, "some hook");
|
|
104
|
+
expect(rebound).toBe(view);
|
|
105
|
+
expect(() => rebound.unsafeRaw("x")).toThrow(AccessDeniedError);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -3,6 +3,7 @@ import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
|
3
3
|
import { InternalError } from "../../errors";
|
|
4
4
|
import { testTenantId } from "../../stack";
|
|
5
5
|
import type { DbRunner } from "../connection";
|
|
6
|
+
import { asRawClient } from "../query";
|
|
6
7
|
import { createTenantDb, createUncheckedSystemDb, withUnsafeRawGrant } from "../tenant-db";
|
|
7
8
|
import { tenantDbRunner } from "../tenant-db-runner";
|
|
8
9
|
|
|
@@ -26,6 +27,15 @@ describe("TenantDb has no .raw", () => {
|
|
|
26
27
|
// @ts-expect-error TenantDb no longer exposes `raw`.
|
|
27
28
|
expect(tdb.raw).toBeUndefined();
|
|
28
29
|
});
|
|
30
|
+
|
|
31
|
+
test("createTenantDb rejects an already tenant-scoped TenantDb: compile error, and raw access throws", () => {
|
|
32
|
+
const tdb = createTenantDb(fakeRunner(), tenantId);
|
|
33
|
+
expect(() => {
|
|
34
|
+
// @ts-expect-error TenantDb is already tenant-scoped; createTenantDb only accepts a raw DbRunner.
|
|
35
|
+
const rewrapped = createTenantDb(tdb, tenantId);
|
|
36
|
+
asRawClient(tenantDbRunner(rewrapped));
|
|
37
|
+
}).toThrow();
|
|
38
|
+
});
|
|
29
39
|
});
|
|
30
40
|
|
|
31
41
|
describe("tenantDbRunner", () => {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// fw#3198 — unit coverage for withSystemDbUnsafeRawGrant: passthrough for
|
|
2
|
+
// unknown values, no stacking across repeated rebinds, and the gated runner
|
|
3
|
+
// registered under declaredUnsafeRawRunners (unsafeRawForDeclaredStep).
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import type { UncheckedSystemDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
7
|
+
import { AccessDeniedError } from "../../errors";
|
|
8
|
+
import { testTenantId } from "../../stack";
|
|
9
|
+
import type { DbRunner } from "../connection";
|
|
10
|
+
import {
|
|
11
|
+
createTenantDb,
|
|
12
|
+
createUncheckedSystemDb,
|
|
13
|
+
unsafeRawForDeclaredStep,
|
|
14
|
+
withSystemDbUnsafeRawGrant,
|
|
15
|
+
} from "../tenant-db";
|
|
16
|
+
|
|
17
|
+
const tenantId = testTenantId(1);
|
|
18
|
+
|
|
19
|
+
function fakeRunner(): DbRunner {
|
|
20
|
+
return {
|
|
21
|
+
unsafe: async () => [],
|
|
22
|
+
begin: async () => {
|
|
23
|
+
throw new Error("begin not used in these tests");
|
|
24
|
+
},
|
|
25
|
+
} as unknown as DbRunner;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("withSystemDbUnsafeRawGrant", () => {
|
|
29
|
+
test("passes through a value not built by createUncheckedSystemDb, unchanged", () => {
|
|
30
|
+
const notBuilt = { unsafeRaw: () => fakeRunner() } as unknown as UncheckedSystemDb;
|
|
31
|
+
const rebound = withSystemDbUnsafeRawGrant(notBuilt, { reason: "x" }, "some hook");
|
|
32
|
+
expect(rebound).toBe(notBuilt);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("without a grant, unsafeRaw denies with AccessDeniedError naming the caller", () => {
|
|
36
|
+
const runner = fakeRunner();
|
|
37
|
+
const tdb = createTenantDb(runner, tenantId, "system");
|
|
38
|
+
const systemDb = createUncheckedSystemDb(tdb);
|
|
39
|
+
const gated = withSystemDbUnsafeRawGrant(systemDb, undefined, 'postSave hook of feature "x"');
|
|
40
|
+
expect(() => gated.unsafeRaw("a reason")).toThrow(AccessDeniedError);
|
|
41
|
+
expect(() => gated.unsafeRaw("a reason")).toThrow(/postSave hook of feature "x"/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("with a grant, unsafeRaw resolves to the original runner", () => {
|
|
45
|
+
const runner = fakeRunner();
|
|
46
|
+
const tdb = createTenantDb(runner, tenantId, "system");
|
|
47
|
+
const systemDb = createUncheckedSystemDb(tdb);
|
|
48
|
+
const gated = withSystemDbUnsafeRawGrant(systemDb, { reason: "hook reason" }, "hook");
|
|
49
|
+
expect(gated.unsafeRaw("hook reason")).toBe(runner);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("repeated rebinding is not stacked — a second rebind reflects only its own grant", () => {
|
|
53
|
+
const runner = fakeRunner();
|
|
54
|
+
const tdb = createTenantDb(runner, tenantId, "system");
|
|
55
|
+
const systemDb = createUncheckedSystemDb(tdb);
|
|
56
|
+
const grantedFirst = withSystemDbUnsafeRawGrant(systemDb, { reason: "first" }, "hook-1");
|
|
57
|
+
const deniedSecond = withSystemDbUnsafeRawGrant(grantedFirst, undefined, "hook-2");
|
|
58
|
+
expect(() => deniedSecond.unsafeRaw("first")).toThrow(AccessDeniedError);
|
|
59
|
+
// Re-granting from the ORIGINAL systemDb (not stacked through grantedFirst) still works.
|
|
60
|
+
const grantedThird = withSystemDbUnsafeRawGrant(systemDb, { reason: "third" }, "hook-3");
|
|
61
|
+
expect(grantedThird.unsafeRaw("third")).toBe(runner);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("the gated runner is registered for unsafeRawForDeclaredStep", () => {
|
|
65
|
+
const runner = fakeRunner();
|
|
66
|
+
const tdb = createTenantDb(runner, tenantId, "system");
|
|
67
|
+
const systemDb = createUncheckedSystemDb(tdb);
|
|
68
|
+
const gated = withSystemDbUnsafeRawGrant(systemDb, { reason: "hook reason" }, "hook");
|
|
69
|
+
expect(unsafeRawForDeclaredStep(gated, "hook reason")).toBe(runner);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
tryMapUniqueViolation,
|
|
32
32
|
} from "./event-store-executor-context";
|
|
33
33
|
import { runInSavepointIfSupported } from "./query";
|
|
34
|
+
import { assertPersonalDataWrite, tableNameOf } from "./tenant-db";
|
|
34
35
|
import { tenantDbRunner } from "./tenant-db-runner";
|
|
35
36
|
|
|
36
37
|
// Art. 17 erasure runs as the framework operator, not as a row owner; a
|
|
@@ -145,6 +146,9 @@ export function createWriteVerbs(
|
|
|
145
146
|
if ("failure" in preSaveResult) return preSaveResult.failure;
|
|
146
147
|
const data = preSaveResult.data;
|
|
147
148
|
|
|
149
|
+
// After preSave so derived fields count, before the event append so nothing persists.
|
|
150
|
+
assertPersonalDataWrite(db, tableNameOf(table), Object.keys(data), entity);
|
|
151
|
+
|
|
148
152
|
// H.2 — entity-level write-ownership on create. No oldRow exists, so
|
|
149
153
|
// only the new row is checked. No Straddle concern for creates.
|
|
150
154
|
if (!userCanCreateFieldRow(user, entity.access?.write, data)) {
|
|
@@ -334,6 +338,9 @@ export function createWriteVerbs(
|
|
|
334
338
|
if ("failure" in preSaveResult) return preSaveResult.failure;
|
|
335
339
|
const changes = preSaveResult.data;
|
|
336
340
|
|
|
341
|
+
// After preSave so derived fields count, before the event append so nothing persists.
|
|
342
|
+
assertPersonalDataWrite(db, tableNameOf(table), Object.keys(changes), entity);
|
|
343
|
+
|
|
337
344
|
// H.2 — entity-level write-ownership on update. Load old row (already
|
|
338
345
|
// done above), build post-change row via shallow merge. Straddle-safe
|
|
339
346
|
// multi-role check: at least one role must accept BOTH old and new —
|
package/src/db/index.ts
CHANGED
|
@@ -155,7 +155,7 @@ export {
|
|
|
155
155
|
export type { TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb } from "./tenant-db";
|
|
156
156
|
export {
|
|
157
157
|
castTenantRows,
|
|
158
|
+
createSystemDbView,
|
|
158
159
|
createTenantDb,
|
|
159
|
-
createUncheckedSystemDb,
|
|
160
160
|
SYSTEM_SCOPE_CHECK_BRAND,
|
|
161
161
|
} from "./tenant-db";
|
package/src/db/pg-error.ts
CHANGED
|
@@ -21,6 +21,19 @@ export function extractPgError(e: unknown): PgErrorInfo | null {
|
|
|
21
21
|
// @cast-boundary error-details — postgres-js error shape (code, constraint_name)
|
|
22
22
|
const code = (layer as { code?: string }).code;
|
|
23
23
|
const constraintName = (layer as { constraint_name?: string }).constraint_name; // @cast-boundary error-details
|
|
24
|
+
// Bun.SQL carries the SQLSTATE in `errno` (string) and the constraint name
|
|
25
|
+
// in `constraint`, not in postgres-js's `code`/`constraint_name`.
|
|
26
|
+
if (code === "ERR_POSTGRES_SERVER_ERROR") {
|
|
27
|
+
const errno = (layer as { errno?: unknown }).errno; // @cast-boundary error-details
|
|
28
|
+
const constraint = (layer as { constraint?: unknown }).constraint; // @cast-boundary error-details
|
|
29
|
+
if (typeof errno === "string") {
|
|
30
|
+
return {
|
|
31
|
+
code: errno,
|
|
32
|
+
constraint_name:
|
|
33
|
+
constraintName ?? (typeof constraint === "string" ? constraint : undefined),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
24
37
|
if (code !== undefined || constraintName !== undefined) {
|
|
25
38
|
return { code, constraint_name: constraintName };
|
|
26
39
|
}
|