@cosmicdrift/kumiko-framework 0.341.0 → 0.343.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/dist/api/auth-routes.d.ts +2 -0
- package/dist/api/auth-routes.js +35 -2
- package/dist/api/request-context.d.ts +2 -0
- package/dist/api/request-id-middleware.d.ts +2 -0
- package/dist/api/request-id-middleware.js +4 -1
- package/dist/api/routes.js +5 -5
- package/dist/api/server.d.ts +3 -0
- package/dist/api/server.js +4 -1
- package/dist/compliance/duration-spec.d.ts +2 -1
- package/dist/compliance/duration-spec.js +11 -0
- package/dist/compliance/index.d.ts +2 -2
- package/dist/compliance/index.js +1 -1
- package/dist/compliance/profiles.d.ts +6 -5
- package/dist/crypto/kek-source.d.ts +3 -4
- package/dist/crypto/kek-source.js +2 -5
- package/dist/crypto/kms-wiring.d.ts +2 -2
- package/dist/crypto/kms-wiring.js +2 -2
- package/dist/db/entity-field-encryption.d.ts +1 -1
- package/dist/db/entity-field-encryption.js +5 -5
- package/dist/db/event-store-executor-write.js +38 -11
- package/dist/db/index.d.ts +2 -1
- package/dist/db/index.js +2 -1
- package/dist/db/migrate-generator.d.ts +6 -0
- package/dist/db/migrate-generator.js +15 -1
- package/dist/db/migrate-runner.d.ts +1 -0
- package/dist/db/migrate-runner.js +11 -1
- package/dist/db/projection-registry-binding.d.ts +3 -0
- package/dist/db/projection-registry-binding.js +12 -0
- package/dist/db/retired-framework-tables.d.ts +10 -0
- package/dist/db/retired-framework-tables.js +72 -0
- package/dist/db/tenant-db.d.ts +2 -0
- package/dist/db/tenant-db.js +22 -7
- package/dist/engine/boot-validator/index.d.ts +11 -0
- package/dist/engine/boot-validator/index.js +45 -1
- package/dist/engine/boot-validator/query-output-columns.js +8 -7
- package/dist/engine/boot-validator/query-refs.js +3 -0
- package/dist/engine/boot-validator/screens.js +28 -0
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.js +1 -1
- package/dist/engine/soft-delete-cleanup.d.ts +2 -0
- package/dist/engine/soft-delete-cleanup.js +28 -9
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/types/index.js +1 -1
- package/dist/env/index.d.ts +3 -0
- package/dist/env/index.js +12 -0
- package/dist/errors/docs-url.d.ts +7 -0
- package/dist/errors/docs-url.js +36 -0
- package/dist/errors/index.d.ts +2 -0
- package/dist/errors/index.js +1 -0
- package/dist/errors/kumiko-error.d.ts +0 -2
- package/dist/errors/kumiko-error.js +0 -24
- package/dist/errors/serialize.d.ts +3 -2
- package/dist/errors/serialize.js +4 -2
- package/dist/jobs/job-runner.js +37 -2
- package/dist/observability/escape-hatch-report.d.ts +10 -1
- package/dist/observability/escape-hatch-report.js +19 -1
- package/dist/observability/index.d.ts +2 -2
- package/dist/observability/index.js +2 -2
- package/dist/observability/standard-metrics.d.ts +1 -0
- package/dist/observability/standard-metrics.js +14 -0
- package/dist/pipeline/dispatch-shared.js +3 -1
- package/dist/pipeline/event-retention.d.ts +1 -0
- package/dist/pipeline/event-retention.js +6 -0
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/projections-runner.d.ts +1 -0
- package/dist/pipeline/projections-runner.js +23 -14
- package/dist/rate-limit/middleware.js +1 -1
- package/dist/schema-cli.d.ts +8 -6
- package/dist/schema-cli.js +37 -4
- package/dist/stack/test-stack.d.ts +7 -0
- package/dist/stack/test-stack.js +6 -0
- package/package.json +4 -4
- package/src/changes.json +60 -0
|
@@ -6,6 +6,7 @@ import { type AuthSessionChecker, type AuthSessionStatus, type TokenVerifier } f
|
|
|
6
6
|
import { type ClientIpResolver } from "./client-ip.js";
|
|
7
7
|
import type { JwtHelper } from "./jwt.js";
|
|
8
8
|
import type { PostAuthLandingResolver } from "./post-auth-landing.js";
|
|
9
|
+
export declare function assertRetiredCookieDomains(retiredCookieDomains: readonly string[] | undefined, cookieDomain: string | undefined): void;
|
|
9
10
|
export type LoginRateLimiter = {
|
|
10
11
|
check(key: string): Promise<boolean>;
|
|
11
12
|
reset(key: string): Promise<void>;
|
|
@@ -48,6 +49,7 @@ export type AuthRoutesConfig = {
|
|
|
48
49
|
invite?: InviteConfig;
|
|
49
50
|
cookieSameSite?: "lax" | "strict";
|
|
50
51
|
cookieDomain?: string;
|
|
52
|
+
retiredCookieDomains?: readonly string[];
|
|
51
53
|
allowedOrigins?: readonly string[];
|
|
52
54
|
unsafeSkipOriginCheck?: boolean;
|
|
53
55
|
trustedProxyHops?: number;
|
package/dist/api/auth-routes.js
CHANGED
|
@@ -44,12 +44,18 @@ function setAuthCookies(c, opts) {
|
|
|
44
44
|
deleteCookie(c, AUTH_COOKIE_NAME, { path: "/" });
|
|
45
45
|
deleteCookie(c, CSRF_COOKIE_NAME, { path: "/" });
|
|
46
46
|
}
|
|
47
|
+
// Must precede the setCookie calls: browsers apply Set-Cookie in order and
|
|
48
|
+
// a retired domain equal to the new one is rejected at config time.
|
|
49
|
+
for (const retiredDomain of opts.retiredDomains) {
|
|
50
|
+
deleteCookie(c, AUTH_COOKIE_NAME, { path: "/", domain: retiredDomain });
|
|
51
|
+
deleteCookie(c, CSRF_COOKIE_NAME, { path: "/", domain: retiredDomain });
|
|
52
|
+
}
|
|
47
53
|
setCookie(c, AUTH_COOKIE_NAME, opts.token, { ...common, httpOnly: true });
|
|
48
54
|
// Intentionally NOT HttpOnly — the web client has to read this from
|
|
49
55
|
// document.cookie to include it in the X-CSRF-Token request header.
|
|
50
56
|
setCookie(c, CSRF_COOKIE_NAME, opts.csrfToken, { ...common, httpOnly: false });
|
|
51
57
|
}
|
|
52
|
-
function clearAuthCookies(c, domain) {
|
|
58
|
+
function clearAuthCookies(c, domain, retiredDomains) {
|
|
53
59
|
// Beide Varianten löschen: mit Domain (aktuelle Cookies) UND host-only
|
|
54
60
|
// (Bestand aus der Zeit vor cookieDomain) — sonst bleibt nach einem
|
|
55
61
|
// Deploy mit neu gesetztem cookieDomain der alte Cookie liegen und der
|
|
@@ -60,6 +66,30 @@ function clearAuthCookies(c, domain) {
|
|
|
60
66
|
deleteCookie(c, AUTH_COOKIE_NAME, { path: "/", domain });
|
|
61
67
|
deleteCookie(c, CSRF_COOKIE_NAME, { path: "/", domain });
|
|
62
68
|
}
|
|
69
|
+
for (const retiredDomain of retiredDomains) {
|
|
70
|
+
deleteCookie(c, AUTH_COOKIE_NAME, { path: "/", domain: retiredDomain });
|
|
71
|
+
deleteCookie(c, CSRF_COOKIE_NAME, { path: "/", domain: retiredDomain });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const INVALID_COOKIE_DOMAIN_CHARS = /[;,\s]/;
|
|
75
|
+
function normalizeCookieDomain(domain) {
|
|
76
|
+
return domain.replace(/^\./, "").toLowerCase();
|
|
77
|
+
}
|
|
78
|
+
// A malformed entry would corrupt the Set-Cookie header; an entry equal to the
|
|
79
|
+
// current cookieDomain would make login delete the cookie it just sets.
|
|
80
|
+
export function assertRetiredCookieDomains(retiredCookieDomains, cookieDomain) {
|
|
81
|
+
const current = cookieDomain === undefined ? undefined : normalizeCookieDomain(cookieDomain);
|
|
82
|
+
for (const entry of retiredCookieDomains ?? []) {
|
|
83
|
+
if (entry === "" || normalizeCookieDomain(entry) === "") {
|
|
84
|
+
throw new Error("createAuthRoutes: retiredCookieDomains must not contain an empty entry");
|
|
85
|
+
}
|
|
86
|
+
if (INVALID_COOKIE_DOMAIN_CHARS.test(entry)) {
|
|
87
|
+
throw new Error(`createAuthRoutes: retiredCookieDomains entry "${entry}" contains an invalid character (;, comma or whitespace)`);
|
|
88
|
+
}
|
|
89
|
+
if (normalizeCookieDomain(entry) === current) {
|
|
90
|
+
throw new Error(`createAuthRoutes: retiredCookieDomains entry "${entry}" equals cookieDomain; login would delete its own cookie`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
63
93
|
}
|
|
64
94
|
// Body schema for POST /auth/login. Enforced BEFORE rate-limit so that a
|
|
65
95
|
// malformed body (`email: 42`, missing password, …) returns 400 instead of
|
|
@@ -297,6 +327,8 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
297
327
|
// working. High-security apps can opt into "strict" — see AuthRoutesConfig.
|
|
298
328
|
const cookieSameSite = config.cookieSameSite ?? "lax";
|
|
299
329
|
const cookieDomain = config.cookieDomain;
|
|
330
|
+
assertRetiredCookieDomains(config.retiredCookieDomains, cookieDomain);
|
|
331
|
+
const retiredCookieDomains = config.retiredCookieDomains ?? [];
|
|
300
332
|
// Single hop-count-aware IP getter for every rate-limit call site below —
|
|
301
333
|
// see AuthRoutesConfig.trustedProxyHops and client-ip.ts's resolveClientIp
|
|
302
334
|
// doc comment. createClientIpResolver fails loud on a non-finite/negative
|
|
@@ -322,6 +354,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
322
354
|
csrfToken,
|
|
323
355
|
sameSite: cookieSameSite,
|
|
324
356
|
domain: cookieDomain,
|
|
357
|
+
retiredDomains: retiredCookieDomains,
|
|
325
358
|
ttlSeconds: jwt.ttlSeconds,
|
|
326
359
|
});
|
|
327
360
|
return token;
|
|
@@ -849,7 +882,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
849
882
|
}
|
|
850
883
|
// Clear cookies on the cookie-transport path. Idempotent — clearing a
|
|
851
884
|
// missing cookie is a no-op, so bearer-only clients aren't affected.
|
|
852
|
-
clearAuthCookies(c, cookieDomain);
|
|
885
|
+
clearAuthCookies(c, cookieDomain, retiredCookieDomains);
|
|
853
886
|
return c.json({ isSuccess: true });
|
|
854
887
|
});
|
|
855
888
|
// GET /auth/tenants — list tenants the current user belongs to
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { type WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
2
2
|
import type { AgentRisk } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
|
+
import type { ErrorDocsConfig } from "../errors/docs-url.js";
|
|
3
4
|
export type RequestContextData = {
|
|
4
5
|
readonly requestId: string;
|
|
5
6
|
readonly correlationId: string;
|
|
6
7
|
readonly causationId?: string;
|
|
7
8
|
readonly signal?: AbortSignal;
|
|
9
|
+
readonly errorDocs?: ErrorDocsConfig;
|
|
8
10
|
readonly ip?: string;
|
|
9
11
|
readonly userAgent?: string;
|
|
10
12
|
readonly locale?: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Context, Next } from "hono";
|
|
2
|
+
import type { ErrorDocsConfig } from "../errors/docs-url.js";
|
|
2
3
|
import { type ClientIpResolver } from "./client-ip.js";
|
|
3
4
|
import { type RequestContextData } from "./request-context.js";
|
|
4
5
|
/**
|
|
@@ -34,4 +35,5 @@ export declare function buildRequestContextData(c: Context, resolver?: ClientIpR
|
|
|
34
35
|
export declare function requestIdMiddleware(options?: {
|
|
35
36
|
readonly resolver?: ClientIpResolver;
|
|
36
37
|
readonly trustedProxyHops?: number;
|
|
38
|
+
readonly errorDocs?: ErrorDocsConfig;
|
|
37
39
|
}): (c: Context, next: Next) => Promise<void>;
|
|
@@ -103,7 +103,10 @@ export function requestIdMiddleware(options) {
|
|
|
103
103
|
const resolver = options?.resolver ??
|
|
104
104
|
createClientIpResolver(options?.trustedProxyHops ?? 0, "requestIdMiddleware");
|
|
105
105
|
return async (c, next) => {
|
|
106
|
-
const data =
|
|
106
|
+
const data = {
|
|
107
|
+
...buildRequestContextData(c, resolver),
|
|
108
|
+
...(options?.errorDocs && { errorDocs: options.errorDocs }),
|
|
109
|
+
};
|
|
107
110
|
c.header(REQUEST_ID_HEADER, data.requestId);
|
|
108
111
|
c.header(CORRELATION_ID_HEADER, data.correlationId);
|
|
109
112
|
c.set("requestId", data.requestId);
|
package/dist/api/routes.js
CHANGED
|
@@ -71,7 +71,7 @@ export function createApiRoutes(dispatcher, options = {}) {
|
|
|
71
71
|
const requestId = requestContext.get()?.requestId;
|
|
72
72
|
const failedType = result.failedIndex != null ? body.commands[result.failedIndex]?.type : undefined;
|
|
73
73
|
logServerFault(err, requestId, failedType);
|
|
74
|
-
const { error } = serializeError(err, requestId);
|
|
74
|
+
const { error } = serializeError(err, requestId, requestContext.get()?.errorDocs);
|
|
75
75
|
// Keep failedIndex + results alongside the error envelope so callers
|
|
76
76
|
// can tell which command in the batch failed and inspect the partial
|
|
77
77
|
// results from the successful commands before the rollback.
|
|
@@ -182,7 +182,7 @@ export function createApiRoutes(dispatcher, options = {}) {
|
|
|
182
182
|
catch (e) {
|
|
183
183
|
const err = toKumiko(e);
|
|
184
184
|
logServerFault(err, requestId, body.type);
|
|
185
|
-
const { error } = serializeError(err, requestId);
|
|
185
|
+
const { error } = serializeError(err, requestId, requestContext.get()?.errorDocs);
|
|
186
186
|
await stream.writeSSE({ event: StreamFrame.error, data: stringifyJson(error) });
|
|
187
187
|
}
|
|
188
188
|
});
|
|
@@ -354,7 +354,7 @@ function logServerFault(err, requestId, type) {
|
|
|
354
354
|
function writeErrorResponse(c, err, type) {
|
|
355
355
|
const requestId = requestContext.get()?.requestId;
|
|
356
356
|
logServerFault(err, requestId, type);
|
|
357
|
-
const { error } = serializeError(err, requestId);
|
|
357
|
+
const { error } = serializeError(err, requestId, requestContext.get()?.errorDocs);
|
|
358
358
|
return c.json({ isSuccess: false, error }, err.httpStatus); // @cast-boundary engine-payload
|
|
359
359
|
}
|
|
360
360
|
function clientAbortResponse(c, requestId, type) {
|
|
@@ -370,7 +370,7 @@ function queryErrorResponse(c, err, type) {
|
|
|
370
370
|
if (isClientAbort(err))
|
|
371
371
|
return clientAbortResponse(c, requestId, type);
|
|
372
372
|
logServerFault(err, requestId, type);
|
|
373
|
-
const body = serializeError(err, requestId);
|
|
373
|
+
const body = serializeError(err, requestId, requestContext.get()?.errorDocs);
|
|
374
374
|
return c.json(body, err.httpStatus); // @cast-boundary engine-payload
|
|
375
375
|
}
|
|
376
376
|
// A raw throw under the request signal (signal.throwIfAborted(), an aborted
|
|
@@ -391,5 +391,5 @@ export function handleUncaughtRouteError(err, c) {
|
|
|
391
391
|
return clientAbortResponse(c, requestId);
|
|
392
392
|
const kumikoError = toKumiko(err);
|
|
393
393
|
logServerFault(kumikoError, requestId);
|
|
394
|
-
return c.json(serializeError(kumikoError, requestId), kumikoError.httpStatus); // @cast-boundary engine-payload
|
|
394
|
+
return c.json(serializeError(kumikoError, requestId, requestContext.get()?.errorDocs), kumikoError.httpStatus); // @cast-boundary engine-payload
|
|
395
395
|
}
|
package/dist/api/server.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { CacheSyncBus } from "@cosmicdrift/kumiko-types/cache-sync-types";
|
|
|
2
2
|
import { Hono } from "hono";
|
|
3
3
|
import type { PgClient } from "../db/connection.js";
|
|
4
4
|
import { type AppContext, type Registry, type RunIn, type WriteResult } from "../engine/types/index.js";
|
|
5
|
+
import type { ErrorDocsConfig } from "../errors/docs-url.js";
|
|
5
6
|
import type { Lifecycle } from "../lifecycle/index.js";
|
|
6
7
|
import { type ObservabilityOptions, type ObservabilityProvider } from "../observability/index.js";
|
|
7
8
|
import type { DispatcherOptions } from "../pipeline/dispatcher.js";
|
|
@@ -27,6 +28,8 @@ export type ServerOptions = {
|
|
|
27
28
|
sseBroker?: SseBroker;
|
|
28
29
|
auth?: AuthRoutesConfig;
|
|
29
30
|
trustedProxyHops?: number;
|
|
31
|
+
/** Links the app's own error reasons to its own docs (`docsUrl` in error responses). Framework reasons keep linking to the framework docs. */
|
|
32
|
+
errorDocs?: ErrorDocsConfig;
|
|
30
33
|
eventDispatcher?: {
|
|
31
34
|
pollIntervalMs?: number;
|
|
32
35
|
batchSize?: number;
|
package/dist/api/server.js
CHANGED
|
@@ -422,7 +422,10 @@ export function buildServer(options) {
|
|
|
422
422
|
if (options.metrics) {
|
|
423
423
|
registerMetricsRoute(app, observability.meter, options.metrics);
|
|
424
424
|
}
|
|
425
|
-
app.use("/api/*", requestIdMiddleware({
|
|
425
|
+
app.use("/api/*", requestIdMiddleware({
|
|
426
|
+
resolver: clientIpResolver,
|
|
427
|
+
...(options.errorDocs && { errorDocs: options.errorDocs }),
|
|
428
|
+
}));
|
|
426
429
|
// Cap JSON bodies before rate-limit/auth/observability even run. Header-
|
|
427
430
|
// check is O(1); oversized requests never allocate memory for a full body
|
|
428
431
|
// parse. Upload route keeps its own per-field maxSize.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { getTemporal } from "../time/index.js";
|
|
2
|
-
import type { DurationSpec } from "./profiles.js";
|
|
2
|
+
import type { DurationSpec, RetentionSpec } from "./profiles.js";
|
|
3
3
|
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
4
4
|
export declare function durationSpecToMs(spec: DurationSpec): number;
|
|
5
5
|
export declare function addDurationSpec(now: Instant, spec: DurationSpec): Instant;
|
|
6
6
|
export declare function describeDurationSpec(spec: DurationSpec): string;
|
|
7
|
+
export declare function subtractRetentionSpec(now: Instant, spec: RetentionSpec): Instant;
|
|
7
8
|
export {};
|
|
@@ -35,3 +35,14 @@ export function describeDurationSpec(spec) {
|
|
|
35
35
|
return `${spec.days} day${spec.days === 1 ? "" : "s"}`;
|
|
36
36
|
return `${spec.hours} hour${spec.hours === 1 ? "" : "s"}`;
|
|
37
37
|
}
|
|
38
|
+
// Calendar-aware cutoff: months/years are subtracted on a UTC calendar so
|
|
39
|
+
// "24 months" is not approximated as 730 fixed days (leap years, month ends).
|
|
40
|
+
export function subtractRetentionSpec(now, spec) {
|
|
41
|
+
if ("months" in spec) {
|
|
42
|
+
return now.toZonedDateTimeISO("UTC").subtract({ months: spec.months }).toInstant();
|
|
43
|
+
}
|
|
44
|
+
if ("years" in spec) {
|
|
45
|
+
return now.toZonedDateTimeISO("UTC").subtract({ years: spec.years }).toInstant();
|
|
46
|
+
}
|
|
47
|
+
return getTemporal().Instant.fromEpochMilliseconds(now.epochMilliseconds - durationSpecToMs(spec));
|
|
48
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export { addDurationSpec, describeDurationSpec, durationSpecToMs, } from "./duration-spec.js";
|
|
1
|
+
export { addDurationSpec, describeDurationSpec, durationSpecToMs, subtractRetentionSpec, } from "./duration-spec.js";
|
|
2
2
|
export { complianceProfileOverrideSchema } from "./override-schema.js";
|
|
3
|
-
export type { AuthorityNotificationDeadline, ComplianceProfile, ComplianceProfileKey, ComplianceProfileOverride, DurationSpec, EffectiveComplianceProfile, UserNotificationRequiredPolicy, } from "./profiles.js";
|
|
3
|
+
export type { AuthorityNotificationDeadline, ComplianceProfile, ComplianceProfileKey, ComplianceProfileOverride, DurationSpec, EffectiveComplianceProfile, RetentionSpec, UserNotificationRequiredPolicy, } from "./profiles.js";
|
|
4
4
|
export { COMPLIANCE_PROFILES, OVERRIDABLE_PROFILE_KEYS, resolveComplianceProfile, SELECTABLE_PROFILE_KEYS, } from "./profiles.js";
|
|
5
5
|
export type { BundleTier, SubProcessor } from "./sub-processors.js";
|
|
6
6
|
export { getActiveSubProcessors, getPlannedSubProcessors, KUMIKO_SUB_PROCESSORS, } from "./sub-processors.js";
|
package/dist/compliance/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// `@cosmicdrift/kumiko-framework/compliance` — Datenschutz/Compliance-
|
|
2
2
|
// Foundation. Wird von Sprint-1+ Features genutzt (compliance-profiles,
|
|
3
3
|
// data-retention, user-data-rights, ...).
|
|
4
|
-
export { addDurationSpec, describeDurationSpec, durationSpecToMs, } from "./duration-spec.js";
|
|
4
|
+
export { addDurationSpec, describeDurationSpec, durationSpecToMs, subtractRetentionSpec, } from "./duration-spec.js";
|
|
5
5
|
export { complianceProfileOverrideSchema } from "./override-schema.js";
|
|
6
6
|
export { COMPLIANCE_PROFILES, OVERRIDABLE_PROFILE_KEYS, resolveComplianceProfile, SELECTABLE_PROFILE_KEYS, } from "./profiles.js";
|
|
7
7
|
export { getActiveSubProcessors, getPlannedSubProcessors, KUMIKO_SUB_PROCESSORS, } from "./sub-processors.js";
|
|
@@ -5,6 +5,11 @@ export type DurationSpec = {
|
|
|
5
5
|
} | {
|
|
6
6
|
readonly hours: number;
|
|
7
7
|
};
|
|
8
|
+
export type RetentionSpec = DurationSpec | {
|
|
9
|
+
readonly months: number;
|
|
10
|
+
} | {
|
|
11
|
+
readonly years: number;
|
|
12
|
+
};
|
|
8
13
|
export type AuthorityNotificationDeadline = DurationSpec | "as-soon-as-feasible" | "in-most-expedient-time" | "manual";
|
|
9
14
|
export type UserNotificationRequiredPolicy = "if-high-risk" | "if-real-risk-of-significant-harm" | "if-serious-risk-of-injury" | "always-if-encrypted-data-or-pii" | "always-without-undue-delay" | "manual";
|
|
10
15
|
export interface ComplianceProfile {
|
|
@@ -42,11 +47,7 @@ export interface ComplianceProfile {
|
|
|
42
47
|
readonly mandatoryRegisterOfBreaches?: boolean;
|
|
43
48
|
};
|
|
44
49
|
readonly auditLog: {
|
|
45
|
-
readonly retention:
|
|
46
|
-
readonly months: number;
|
|
47
|
-
} | {
|
|
48
|
-
readonly years: number;
|
|
49
|
-
};
|
|
50
|
+
readonly retention: RetentionSpec;
|
|
50
51
|
readonly reportFrequency: "quarterly" | "yearly" | "annual-required" | "manual";
|
|
51
52
|
};
|
|
52
53
|
readonly subProcessor: {
|
|
@@ -16,8 +16,7 @@ export type KekSourceOptions = {
|
|
|
16
16
|
readonly logPrefix?: string;
|
|
17
17
|
/** Where the boot line naming the KEK source goes. Defaults to `console.info`. */
|
|
18
18
|
readonly log?: (message: string) => void;
|
|
19
|
-
/** Env names to resolve, e.g. `kmsSlotsOf(schema)`.
|
|
20
|
-
|
|
21
|
-
readonly slots?: readonly string[];
|
|
19
|
+
/** Env names to resolve, e.g. `kmsSlotsOf(schema)`. */
|
|
20
|
+
readonly slots: readonly string[];
|
|
22
21
|
};
|
|
23
|
-
export declare function resolvePlatformKeks(env: KekSourceEnv, options
|
|
22
|
+
export declare function resolvePlatformKeks(env: KekSourceEnv, options: KekSourceOptions): Promise<KekSourceEnv>;
|
|
@@ -7,9 +7,6 @@ const SCALEWAY_KEY_MANAGER_API_VERSION = "v1alpha1";
|
|
|
7
7
|
const DEFAULT_REGION = "fr-par";
|
|
8
8
|
const DECRYPT_TIMEOUT_MS = 5_000;
|
|
9
9
|
const RETRY_DELAYS_MS = [200, 800];
|
|
10
|
-
// Used when the caller names no slots. Apps that predate `kms` schema meta
|
|
11
|
-
// rely on it; drop once every consumer declares its slots in the env schema.
|
|
12
|
-
const LEGACY_SLOTS = ["PLATFORM_KEK", "PLATFORM_KEK_PREVIOUS", "KUMIKO_BLIND_INDEX_KEY"];
|
|
13
10
|
function isRetryableStatus(status) {
|
|
14
11
|
return status >= 500;
|
|
15
12
|
}
|
|
@@ -97,10 +94,10 @@ async function resolveSlot(name, env, options, fetchImpl) {
|
|
|
97
94
|
// another slot's fallback path — the trio check downstream still applies.
|
|
98
95
|
// Slots resolve in parallel: sequentially, a slow Key Manager would cost up to
|
|
99
96
|
// ~16 s per slot (3 x 5 s timeout + backoff) and could trip startup probes.
|
|
100
|
-
export async function resolvePlatformKeks(env, options
|
|
97
|
+
export async function resolvePlatformKeks(env, options) {
|
|
101
98
|
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
102
99
|
const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
|
|
103
|
-
const slots = options
|
|
100
|
+
const { slots } = options;
|
|
104
101
|
// Misconfiguration must not cost a Key Manager round trip.
|
|
105
102
|
if (slots.includes("PLATFORM_KEK_PREVIOUS") &&
|
|
106
103
|
(env.PLATFORM_KEK_PREVIOUS || env.PLATFORM_KEK_PREVIOUS_CIPHERTEXT) &&
|
|
@@ -62,6 +62,6 @@ export declare function requireKmsWiring(env: KmsWiringEnv, options?: KmsWiringO
|
|
|
62
62
|
* from a Key Manager ciphertext first when no plaintext is set — see
|
|
63
63
|
* `resolvePlatformKeks`. The validation stays in the sync function; this only
|
|
64
64
|
* adds the KEK-fetching step in front of it. */
|
|
65
|
-
export declare function resolveKmsWiringAsync(env: KmsWiringEnv, options
|
|
65
|
+
export declare function resolveKmsWiringAsync(env: KmsWiringEnv, options: KmsWiringOptions & KekSourceOptions): Promise<KmsWiring>;
|
|
66
66
|
/** Async counterpart to `requireKmsWiring`, KEK-resolving like `resolveKmsWiringAsync`. */
|
|
67
|
-
export declare function requireKmsWiringAsync(env: KmsWiringEnv, options
|
|
67
|
+
export declare function requireKmsWiringAsync(env: KmsWiringEnv, options: KmsWiringOptions & KekSourceOptions): Promise<ActiveKmsWiring>;
|
|
@@ -116,10 +116,10 @@ export function requireKmsWiring(env, options = {}) {
|
|
|
116
116
|
* from a Key Manager ciphertext first when no plaintext is set — see
|
|
117
117
|
* `resolvePlatformKeks`. The validation stays in the sync function; this only
|
|
118
118
|
* adds the KEK-fetching step in front of it. */
|
|
119
|
-
export async function resolveKmsWiringAsync(env, options
|
|
119
|
+
export async function resolveKmsWiringAsync(env, options) {
|
|
120
120
|
return resolveKmsWiring(await resolvePlatformKeks(env, options), options);
|
|
121
121
|
}
|
|
122
122
|
/** Async counterpart to `requireKmsWiring`, KEK-resolving like `resolveKmsWiringAsync`. */
|
|
123
|
-
export async function requireKmsWiringAsync(env, options
|
|
123
|
+
export async function requireKmsWiringAsync(env, options) {
|
|
124
124
|
return requireKmsWiring(await resolvePlatformKeks(env, options), options);
|
|
125
125
|
}
|
|
@@ -12,4 +12,4 @@ export declare function resolveEntityFieldEncryption(): EnvelopeCipher;
|
|
|
12
12
|
export declare function configuredEntityFieldEncryption(): EnvelopeCipher | undefined;
|
|
13
13
|
/** @internal test-only */
|
|
14
14
|
export declare function resetEntityFieldEncryptionCacheForTests(): void;
|
|
15
|
-
export declare function validateEntityFieldEncryptionAvailable(env?: Readonly<Record<string, string | undefined
|
|
15
|
+
export declare function validateEntityFieldEncryptionAvailable(env?: Readonly<Record<string, string | undefined>>, bootCipher?: EnvelopeCipher): void;
|
|
@@ -106,9 +106,9 @@ export function resetEntityFieldEncryptionCacheForTests() {
|
|
|
106
106
|
// surfaces missing AND malformed keys (wrong length, bad base64) at boot
|
|
107
107
|
// instead of on the first encrypted read in prod. validateBoot runs before
|
|
108
108
|
// configureEntityFieldEncryption, so the env probe is the common path.
|
|
109
|
-
export function validateEntityFieldEncryptionAvailable(env = process.env) {
|
|
110
|
-
// skip: an injected cipher (test seam / custom KMS) satisfies availability
|
|
111
|
-
if (injectedCipher)
|
|
109
|
+
export function validateEntityFieldEncryptionAvailable(env = process.env, bootCipher) {
|
|
110
|
+
// skip: an injected cipher (test seam / custom KMS) or the runner's boot cipher satisfies availability
|
|
111
|
+
if (bootCipher ?? injectedCipher)
|
|
112
112
|
return;
|
|
113
113
|
try {
|
|
114
114
|
createEnvMasterKeyProvider({
|
|
@@ -121,7 +121,7 @@ export function validateEntityFieldEncryptionAvailable(env = process.env) {
|
|
|
121
121
|
catch (err) {
|
|
122
122
|
const reason = err instanceof Error ? err.message : String(err);
|
|
123
123
|
throw new Error(`encrypted entity fields in use but no usable master key (${reason}) — set ` +
|
|
124
|
-
"KUMIKO_SECRETS_MASTER_KEY_V1 (32 bytes, base64) or
|
|
125
|
-
"configureEntityFieldEncryption().");
|
|
124
|
+
"KUMIKO_SECRETS_MASTER_KEY_V1 (32 bytes, base64) or pass `masterKey` to " +
|
|
125
|
+
"runProdApp/runWorkerApp (or inject a cipher via configureEntityFieldEncryption()).");
|
|
126
126
|
}
|
|
127
127
|
}
|
|
@@ -5,13 +5,28 @@ import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user.js";
|
|
|
5
5
|
import { AccessDeniedError, VersionConflictError as FrameworkVersionConflict, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, UnprocessableError, writeFailure, } from "../errors/index.js";
|
|
6
6
|
import { append, IdempotentAppendConflictError as EventStoreIdempotentAppendConflict, VersionConflictError as EventStoreVersionConflict, getStreamVersion, } from "../event-store/index.js";
|
|
7
7
|
import { assertInstructionFieldWriteAllowed, assertIrreversibleOperationAllowed, isIrreversibleEntityVerb, } from "../pipeline/irreversible-operation-gate.js";
|
|
8
|
+
import { hasLiveProjectionsForEvent, runProjectionsForEvent, } from "../pipeline/projections-runner.js";
|
|
8
9
|
import { generateId } from "../utils/index.js";
|
|
9
10
|
import { applyEntityEvent } from "./apply-entity-event.js";
|
|
10
11
|
import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
|
|
11
12
|
import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, tryMapUniqueViolation, } from "./event-store-executor-context.js";
|
|
13
|
+
import { projectionRegistryOf } from "./projection-registry-binding.js";
|
|
12
14
|
import { runInSavepointIfSupported } from "./query.js";
|
|
13
15
|
import { assertPersonalDataWrite, tableNameOf } from "./tenant-db.js";
|
|
14
16
|
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
17
|
+
// Custom projections for a write that did not go through the dispatcher (hook or
|
|
18
|
+
// job writing a second aggregate). Savepoint like applyEntityEvent: a throwing
|
|
19
|
+
// projection fails the write and the caller's transaction rolls it back.
|
|
20
|
+
async function projectEntityEvent(db, runner, event) {
|
|
21
|
+
const registry = projectionRegistryOf(db);
|
|
22
|
+
// skip: TenantDb not built by a dispatcher/job runner — nothing bound to project with
|
|
23
|
+
if (!registry)
|
|
24
|
+
return;
|
|
25
|
+
// skip: no live projection listens — avoid a SAVEPOINT round trip on every plain write
|
|
26
|
+
if (!hasLiveProjectionsForEvent(event, registry))
|
|
27
|
+
return;
|
|
28
|
+
await runInSavepointIfSupported(runner, (sp) => runProjectionsForEvent(event, registry, sp));
|
|
29
|
+
}
|
|
15
30
|
// Art. 17 erasure runs as the framework operator, not as a row owner; a
|
|
16
31
|
// per-role ownership map can never cover it, and a silent deny means the
|
|
17
32
|
// erasure never happened.
|
|
@@ -215,6 +230,10 @@ export function createWriteVerbs(ctx) {
|
|
|
215
230
|
if (entityCache && entityName) {
|
|
216
231
|
await entityCache.del(user.tenantId, entityName, aggregateId);
|
|
217
232
|
}
|
|
233
|
+
// The echoed event is the one projections see (and the dispatcher later
|
|
234
|
+
// skips), so build it before projecting.
|
|
235
|
+
const echoEvent = { ...event, payload: stripSensitive(flatCreateData) };
|
|
236
|
+
await projectEntityEvent(db, runner, echoEvent);
|
|
218
237
|
return {
|
|
219
238
|
isSuccess: true,
|
|
220
239
|
data: {
|
|
@@ -227,7 +246,7 @@ export function createWriteVerbs(ctx) {
|
|
|
227
246
|
entityName,
|
|
228
247
|
// Persisted event carries ciphertext by design — the caller-facing
|
|
229
248
|
// echo must be plaintext like every other response field (#820).
|
|
230
|
-
event:
|
|
249
|
+
event: echoEvent,
|
|
231
250
|
},
|
|
232
251
|
};
|
|
233
252
|
},
|
|
@@ -404,6 +423,14 @@ export function createWriteVerbs(ctx) {
|
|
|
404
423
|
if (entityCache && entityName) {
|
|
405
424
|
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
406
425
|
}
|
|
426
|
+
const echoEvent = {
|
|
427
|
+
...event,
|
|
428
|
+
payload: {
|
|
429
|
+
changes: stripSensitive(flatChangesPlain),
|
|
430
|
+
previous: stripSensitive(previous),
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
await projectEntityEvent(db, runner, echoEvent);
|
|
407
434
|
return {
|
|
408
435
|
isSuccess: true,
|
|
409
436
|
data: {
|
|
@@ -414,13 +441,7 @@ export function createWriteVerbs(ctx) {
|
|
|
414
441
|
previous,
|
|
415
442
|
isNew: false,
|
|
416
443
|
entityName,
|
|
417
|
-
event:
|
|
418
|
-
...event,
|
|
419
|
-
payload: {
|
|
420
|
-
changes: stripSensitive(flatChangesPlain),
|
|
421
|
-
previous: stripSensitive(previous),
|
|
422
|
-
},
|
|
423
|
-
},
|
|
444
|
+
event: echoEvent,
|
|
424
445
|
},
|
|
425
446
|
};
|
|
426
447
|
}
|
|
@@ -508,6 +529,8 @@ export function createWriteVerbs(ctx) {
|
|
|
508
529
|
if (entityCache && entityName) {
|
|
509
530
|
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
510
531
|
}
|
|
532
|
+
const echoEvent = { ...event, payload: { previous: stripSensitive(existing) } };
|
|
533
|
+
await projectEntityEvent(db, runner, echoEvent);
|
|
511
534
|
return {
|
|
512
535
|
isSuccess: true,
|
|
513
536
|
data: {
|
|
@@ -515,7 +538,7 @@ export function createWriteVerbs(ctx) {
|
|
|
515
538
|
id: payload.id,
|
|
516
539
|
data: existing,
|
|
517
540
|
entityName,
|
|
518
|
-
event:
|
|
541
|
+
event: echoEvent,
|
|
519
542
|
},
|
|
520
543
|
};
|
|
521
544
|
},
|
|
@@ -579,6 +602,8 @@ export function createWriteVerbs(ctx) {
|
|
|
579
602
|
if (entityCache && entityName) {
|
|
580
603
|
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
581
604
|
}
|
|
605
|
+
const echoEvent = { ...event, payload: { previous: stripSensitive(existing) } };
|
|
606
|
+
await projectEntityEvent(db, runner, echoEvent);
|
|
582
607
|
return {
|
|
583
608
|
isSuccess: true,
|
|
584
609
|
data: {
|
|
@@ -586,7 +611,7 @@ export function createWriteVerbs(ctx) {
|
|
|
586
611
|
id: payload.id,
|
|
587
612
|
data: existing,
|
|
588
613
|
entityName,
|
|
589
|
-
event:
|
|
614
|
+
event: echoEvent,
|
|
590
615
|
},
|
|
591
616
|
};
|
|
592
617
|
},
|
|
@@ -685,6 +710,8 @@ export function createWriteVerbs(ctx) {
|
|
|
685
710
|
// (db.fetchOne / applyEntityEvent), never decrypted before this point.
|
|
686
711
|
const restoredHydrated = await decryptForRead(rehydrateCompoundTypes(restored, entity));
|
|
687
712
|
const previousPlain = await decryptForRead(data);
|
|
713
|
+
const echoEvent = { ...event, payload: { previous: stripSensitive(previousPlain) } };
|
|
714
|
+
await projectEntityEvent(db, runner, echoEvent);
|
|
688
715
|
return {
|
|
689
716
|
isSuccess: true,
|
|
690
717
|
data: {
|
|
@@ -695,7 +722,7 @@ export function createWriteVerbs(ctx) {
|
|
|
695
722
|
previous: previousPlain,
|
|
696
723
|
isNew: false,
|
|
697
724
|
entityName,
|
|
698
|
-
event:
|
|
725
|
+
event: echoEvent,
|
|
699
726
|
},
|
|
700
727
|
};
|
|
701
728
|
},
|
package/dist/db/index.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export { createEventStoreExecutor, entityEventName } from "./event-store-executo
|
|
|
20
20
|
export { enumerateFeatureTableSources, type FeatureTableSource, } from "./feature-table-sources.js";
|
|
21
21
|
export { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp.js";
|
|
22
22
|
export { assertValidMigrationName, diffSnapshots, type GenerateMigrationInput, type GenerateMigrationOutput, generateMigration, loadSnapshotJson, renderMigrationSql, type SchemaDiff, type Snapshot, snapshotFromMetas, writeSnapshotJson, } from "./migrate-generator.js";
|
|
23
|
-
export { type AppliedMigration, type ApplyResult, type BaselineResult, baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, type Migration, MigrationChecksumMismatchError, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.js";
|
|
23
|
+
export { type AppliedMigration, type ApplyResult, type BaselineResult, baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, type Migration, MigrationChecksumMismatchError, readMigrationSqlTexts, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.js";
|
|
24
24
|
export { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.js";
|
|
25
25
|
export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, type PgErrorInfo, } from "./pg-error.js";
|
|
26
26
|
export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
|
|
@@ -31,6 +31,7 @@ export { readRebuildMarker, rebuildTablesFromDiff, writeRebuildMarker, } from ".
|
|
|
31
31
|
export { seedReferenceData } from "./reference-data.js";
|
|
32
32
|
export { renderTableDdl, renderTablesDdl } from "./render-ddl.js";
|
|
33
33
|
export { diffReplayAgainstSnapshot, type ReplayedSchema, type ReplayedTable, type ReplayMismatch, replayMigrationsDir, } from "./replay-migration-sql.js";
|
|
34
|
+
export { findCommentedDropTables, isRetiredFrameworkTable, type RetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.js";
|
|
34
35
|
export { tableExists } from "./schema-inspection.js";
|
|
35
36
|
export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityTable, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
|
|
36
37
|
export type { TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb } from "./tenant-db.js";
|
package/dist/db/index.js
CHANGED
|
@@ -14,7 +14,7 @@ export { createEventStoreExecutor, entityEventName } from "./event-store-executo
|
|
|
14
14
|
export { enumerateFeatureTableSources, } from "./feature-table-sources.js";
|
|
15
15
|
export { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp.js";
|
|
16
16
|
export { assertValidMigrationName, diffSnapshots, generateMigration, loadSnapshotJson, renderMigrationSql, snapshotFromMetas, writeSnapshotJson, } from "./migrate-generator.js";
|
|
17
|
-
export { baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, MigrationChecksumMismatchError, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.js";
|
|
17
|
+
export { baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, MigrationChecksumMismatchError, readMigrationSqlTexts, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.js";
|
|
18
18
|
export { flattenMoney, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.js";
|
|
19
19
|
export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, } from "./pg-error.js";
|
|
20
20
|
export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
|
|
@@ -24,6 +24,7 @@ export { readRebuildMarker, rebuildTablesFromDiff, writeRebuildMarker, } from ".
|
|
|
24
24
|
export { seedReferenceData } from "./reference-data.js";
|
|
25
25
|
export { renderTableDdl, renderTablesDdl } from "./render-ddl.js";
|
|
26
26
|
export { diffReplayAgainstSnapshot, replayMigrationsDir, } from "./replay-migration-sql.js";
|
|
27
|
+
export { findCommentedDropTables, isRetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.js";
|
|
27
28
|
export { tableExists } from "./schema-inspection.js";
|
|
28
29
|
export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
|
|
29
30
|
export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, } from "./tenant-db.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ColumnMeta, EntityTableMeta, IndexMeta } from "./entity-table-meta.js";
|
|
2
|
+
import { type RetiredFrameworkTable } from "./retired-framework-tables.js";
|
|
2
3
|
declare const SNAPSHOT_VERSION: 1;
|
|
3
4
|
export type Snapshot = {
|
|
4
5
|
readonly version: typeof SNAPSHOT_VERSION;
|
|
@@ -62,18 +63,23 @@ export declare function managedChangeRequiresRecreate(td: TableDiff): boolean;
|
|
|
62
63
|
export declare function renderMigrationSql(diff: SchemaDiff, options: {
|
|
63
64
|
readonly name: string;
|
|
64
65
|
readonly sequenceNumber: number;
|
|
66
|
+
readonly retiredDrops?: readonly RetiredFrameworkTable[];
|
|
65
67
|
}): string;
|
|
66
68
|
export type GenerateMigrationInput = {
|
|
67
69
|
readonly metas: readonly EntityTableMeta[];
|
|
68
70
|
readonly prevSnapshot: Snapshot | null;
|
|
69
71
|
readonly name: string;
|
|
70
72
|
readonly sequenceNumber: number;
|
|
73
|
+
/** Raw SQL of all earlier migrations, in order — lets the generator decide
|
|
74
|
+
* whether a retired framework table still needs its executable drop. */
|
|
75
|
+
readonly priorMigrationsSql?: readonly string[];
|
|
71
76
|
};
|
|
72
77
|
export type GenerateMigrationOutput = {
|
|
73
78
|
readonly filename: string;
|
|
74
79
|
readonly sqlContent: string;
|
|
75
80
|
readonly snapshot: Snapshot;
|
|
76
81
|
readonly diff: SchemaDiff;
|
|
82
|
+
readonly retiredDrops: readonly RetiredFrameworkTable[];
|
|
77
83
|
};
|
|
78
84
|
export declare const MIGRATION_NAME_RE: RegExp;
|
|
79
85
|
export declare function assertValidMigrationName(name: string): void;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
18
18
|
import { compareByCodepoint } from "../utils/index.js";
|
|
19
19
|
import { renderIndex, renderTableDdl } from "./render-ddl.js";
|
|
20
|
+
import { findRetiredTableDrops } from "./retired-framework-tables.js";
|
|
20
21
|
const SNAPSHOT_VERSION = 1;
|
|
21
22
|
export function snapshotFromMetas(metas) {
|
|
22
23
|
// Stable ordering by tableName so the snapshot.json diff in PRs is
|
|
@@ -321,6 +322,7 @@ function describeIndexChange(change) {
|
|
|
321
322
|
// the index serves performance) are emitted as commented-out statements
|
|
322
323
|
// for explicit reviewer-action.
|
|
323
324
|
export function renderMigrationSql(diff, options) {
|
|
325
|
+
const retiredDrops = options.retiredDrops ?? [];
|
|
324
326
|
const lines = [];
|
|
325
327
|
const seq = options.sequenceNumber.toString().padStart(4, "0");
|
|
326
328
|
lines.push(`-- Migration ${seq}_${options.name}`);
|
|
@@ -393,9 +395,18 @@ export function renderMigrationSql(diff, options) {
|
|
|
393
395
|
}
|
|
394
396
|
lines.push("");
|
|
395
397
|
}
|
|
398
|
+
if (retiredDrops.length > 0) {
|
|
399
|
+
lines.push("-- === Retired framework tables ===");
|
|
400
|
+
for (const retired of retiredDrops) {
|
|
401
|
+
lines.push(`-- ${retired.tableName}: retired since framework ${retired.sinceVersion} — ${retired.explanation}`);
|
|
402
|
+
lines.push(`DROP TABLE IF EXISTS ${quoteIdent(retired.tableName)};`);
|
|
403
|
+
}
|
|
404
|
+
lines.push("");
|
|
405
|
+
}
|
|
396
406
|
if (diff.newTables.length === 0 &&
|
|
397
407
|
diff.changedTables.length === 0 &&
|
|
398
|
-
diff.droppedTables.length === 0
|
|
408
|
+
diff.droppedTables.length === 0 &&
|
|
409
|
+
retiredDrops.length === 0) {
|
|
399
410
|
lines.push("-- No schema changes detected.");
|
|
400
411
|
}
|
|
401
412
|
return lines.join("\n");
|
|
@@ -413,9 +424,11 @@ export function generateMigration(input) {
|
|
|
413
424
|
assertValidMigrationName(input.name);
|
|
414
425
|
const nextSnapshot = snapshotFromMetas(input.metas);
|
|
415
426
|
const diff = diffSnapshots(input.prevSnapshot, nextSnapshot);
|
|
427
|
+
const retiredDrops = findRetiredTableDrops(input.priorMigrationsSql ?? [], nextSnapshot);
|
|
416
428
|
const sqlContent = renderMigrationSql(diff, {
|
|
417
429
|
name: input.name,
|
|
418
430
|
sequenceNumber: input.sequenceNumber,
|
|
431
|
+
retiredDrops,
|
|
419
432
|
});
|
|
420
433
|
const seq = input.sequenceNumber.toString().padStart(4, "0");
|
|
421
434
|
return {
|
|
@@ -423,5 +436,6 @@ export function generateMigration(input) {
|
|
|
423
436
|
sqlContent,
|
|
424
437
|
snapshot: nextSnapshot,
|
|
425
438
|
diff,
|
|
439
|
+
retiredDrops,
|
|
426
440
|
};
|
|
427
441
|
}
|
|
@@ -14,6 +14,7 @@ export type ApplyResult = {
|
|
|
14
14
|
};
|
|
15
15
|
export declare function splitSqlStatements(sqlText: string): readonly string[];
|
|
16
16
|
export declare function loadMigrationsFromDir(dir: string, preprocess?: (sql: string) => string): readonly Migration[];
|
|
17
|
+
export declare function readMigrationSqlTexts(dir: string): readonly string[];
|
|
17
18
|
export declare function fetchAppliedMigrations(db: DbConnection): Promise<readonly AppliedMigration[]>;
|
|
18
19
|
export declare class MigrationChecksumMismatchError extends Error {
|
|
19
20
|
readonly migrationId: string;
|