@cosmicdrift/kumiko-framework 0.388.0 → 0.389.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/AGENTS.md +3 -0
- package/agents/examples/golden-path.md +13 -1
- package/agents/examples/lifecycle-hooks.md +55 -2
- package/agents/examples/list-table-options.md +466 -0
- package/agents/examples/pdf-audit-export.md +4 -2
- package/agents/examples/state-machine.md +3 -2
- package/agents/examples/tenant-isolation.md +73 -1
- package/agents/examples.md +1 -0
- package/agents/widgets.md +1 -0
- package/dist/api/api-constants.d.ts +1 -0
- package/dist/api/api-constants.js +2 -0
- package/dist/api/auth-routes-shared.d.ts +2 -0
- package/dist/api/auth-routes-shared.js +5 -0
- package/dist/api/client-error-route.js +2 -3
- package/dist/api/entity-read-access.d.ts +2 -1
- package/dist/api/entity-read-access.js +14 -0
- package/dist/api/export/export-cells-reference.d.ts +9 -0
- package/dist/api/export/export-cells-reference.js +21 -0
- package/dist/api/export/export-cells.d.ts +11 -0
- package/dist/api/export/export-cells.js +136 -0
- package/dist/api/export/export-ciphertext-guard.d.ts +2 -0
- package/dist/api/export/export-ciphertext-guard.js +27 -0
- package/dist/api/export/export-columns.d.ts +29 -0
- package/dist/api/export/export-columns.js +85 -0
- package/dist/api/export/export-errors.d.ts +4 -0
- package/dist/api/export/export-errors.js +14 -0
- package/dist/api/export/export-route.d.ts +4 -0
- package/dist/api/export/export-route.js +74 -0
- package/dist/api/export/export-rows.d.ts +13 -0
- package/dist/api/export/export-rows.js +62 -0
- package/dist/api/export/export-screen.d.ts +12 -0
- package/dist/api/export/export-screen.js +47 -0
- package/dist/api/export/export-xlsx-writer.d.ts +4 -0
- package/dist/api/export/export-xlsx-writer.js +21 -0
- package/dist/api/extra-route.d.ts +4 -0
- package/dist/api/pii-leak-guard.d.ts +3 -0
- package/dist/api/pii-leak-guard.js +3 -2
- package/dist/api/routes.d.ts +16 -0
- package/dist/api/routes.js +2 -2
- package/dist/api/server-extra-routes.js +3 -0
- package/dist/api/server-routes.d.ts +1 -1
- package/dist/api/server-routes.js +8 -2
- package/dist/api/server-wiring.js +2 -0
- package/dist/api/sse-route.d.ts +2 -1
- package/dist/api/sse-route.js +11 -1
- package/dist/config/index.d.ts +3 -1
- package/dist/crypto/pg-kms-adapter.d.ts +1 -0
- package/dist/crypto/pg-kms-adapter.js +12 -18
- package/dist/db/aggregate-sql.d.ts +2 -0
- package/dist/db/aggregate-sql.js +10 -0
- package/dist/db/closed-connection.js +12 -19
- package/dist/db/connection.d.ts +4 -5
- package/dist/db/connection.js +29 -20
- package/dist/db/driver-row-coercion.js +4 -4
- package/dist/db/driver-value-preparation.js +6 -4
- package/dist/db/event-store-executor-read-aggregate-currencies.d.ts +17 -0
- package/dist/db/event-store-executor-read-aggregate-currencies.js +40 -0
- package/dist/db/event-store-executor-read-aggregate.js +36 -5
- package/dist/db/event-store-executor-read-filter.js +6 -3
- package/dist/db/event-store-executor-write-atomic.d.ts +4 -0
- package/dist/db/event-store-executor-write-atomic.js +41 -0
- package/dist/db/event-store-executor-write-steps.d.ts +0 -3
- package/dist/db/event-store-executor-write-steps.js +0 -5
- package/dist/db/event-store-executor-write.js +15 -15
- package/dist/db/event-store-executor.js +2 -1
- package/dist/db/executor-managed-tables.js +1 -1
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/migrate-runner.js +3 -3
- package/dist/db/pg-error.js +26 -21
- package/dist/db/queries/backfill-pii.js +3 -6
- package/dist/db/queries/database-now.js +2 -1
- package/dist/db/queries/shadow-swap.js +2 -3
- package/dist/db/query.d.ts +14 -0
- package/dist/db/query.js +68 -43
- package/dist/db/reference-data.js +14 -2
- package/dist/db/statement-timeout.js +2 -1
- package/dist/db/tenant-db-upsert.d.ts +5 -0
- package/dist/db/tenant-db-upsert.js +91 -0
- package/dist/db/tenant-db.js +11 -1
- package/dist/engine/boot-validator/bundled-lists-table.d.ts +2 -0
- package/dist/engine/boot-validator/bundled-lists-table.js +36 -0
- package/dist/engine/boot-validator/entity-list-aggregate-options.d.ts +3 -0
- package/dist/engine/boot-validator/entity-list-aggregate-options.js +23 -0
- package/dist/engine/boot-validator/list-bulk-actions.d.ts +3 -0
- package/dist/engine/boot-validator/list-bulk-actions.js +49 -0
- package/dist/engine/boot-validator/list-column-layout.d.ts +8 -0
- package/dist/engine/boot-validator/list-column-layout.js +41 -0
- package/dist/engine/boot-validator/list-table-aggregate-options.d.ts +24 -0
- package/dist/engine/boot-validator/list-table-aggregate-options.js +145 -0
- package/dist/engine/boot-validator/list-table-aggregate-query.d.ts +10 -0
- package/dist/engine/boot-validator/list-table-aggregate-query.js +31 -0
- package/dist/engine/boot-validator/list-table-export-options.d.ts +1 -0
- package/dist/engine/boot-validator/list-table-export-options.js +27 -0
- package/dist/engine/boot-validator/list-table-options.d.ts +3 -0
- package/dist/engine/boot-validator/list-table-options.js +77 -0
- package/dist/engine/boot-validator/list-table-reject-unknown-keys.d.ts +1 -0
- package/dist/engine/boot-validator/list-table-reject-unknown-keys.js +7 -0
- package/dist/engine/boot-validator/projection-list-screens.d.ts +1 -1
- package/dist/engine/boot-validator/projection-list-screens.js +3 -13
- package/dist/engine/boot-validator/related-list-table-rules.d.ts +4 -0
- package/dist/engine/boot-validator/related-list-table-rules.js +42 -0
- package/dist/engine/boot-validator/screens-kind-entity-list.js +7 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.js +4 -0
- package/dist/engine/boot-validator/screens-lists.js +12 -2
- package/dist/engine/build-app-schema.d.ts +2 -1
- package/dist/engine/build-app-schema.js +9 -1
- package/dist/engine/build-config-feature-schema.js +6 -1
- package/dist/engine/define-feature.js +4 -0
- package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
- package/dist/engine/feature-ast/extractors/index.js +1 -1
- package/dist/engine/feature-ast/extractors/round1.d.ts +2 -1
- package/dist/engine/feature-ast/extractors/round1.js +6 -0
- package/dist/engine/feature-ast/index.d.ts +1 -1
- package/dist/engine/feature-ast/parse.js +3 -1
- package/dist/engine/feature-ast/patch.d.ts +2 -0
- package/dist/engine/feature-ast/patch.js +1 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +7 -245
- package/dist/engine/feature-ast/pattern-change-schema.js +9 -163
- package/dist/engine/feature-ast/pattern-id-matchers.js +1 -0
- package/dist/engine/feature-ast/pattern-id-schema.d.ts +251 -0
- package/dist/engine/feature-ast/pattern-id-schema.js +166 -0
- package/dist/engine/feature-ast/patterns.d.ts +5 -1
- package/dist/engine/feature-ast/patterns.js +1 -0
- package/dist/engine/feature-ast/render.js +4 -0
- package/dist/engine/feature-builder-state.d.ts +1 -0
- package/dist/engine/feature-builder-state.js +1 -0
- package/dist/engine/feature-manifest.d.ts +1 -0
- package/dist/engine/feature-manifest.js +1 -0
- package/dist/engine/field-access.d.ts +1 -0
- package/dist/engine/field-access.js +12 -0
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.js +1 -1
- package/dist/engine/pattern-library/library.js +2 -1
- package/dist/engine/pattern-library/static-schemas.d.ts +1 -0
- package/dist/engine/pattern-library/static-schemas.js +9 -0
- package/dist/engine/screen-filter-ops.js +2 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +7 -3
- package/dist/engine/types/feature.d.ts +1 -0
- package/dist/engine/types/index.d.ts +4 -3
- package/dist/engine/types/screen.d.ts +1 -1
- package/dist/engine/types/screen.js +1 -1
- package/dist/entrypoint/index.d.ts +2 -0
- package/dist/entrypoint/index.js +3 -0
- package/dist/event-store/event-store.d.ts +1 -0
- package/dist/event-store/event-store.js +7 -4
- package/dist/event-store/provenance-append.js +1 -1
- package/dist/files/create-file-ref-from-bytes.js +3 -0
- package/dist/files/create-file-ref.d.ts +3 -0
- package/dist/files/create-file-ref.js +11 -2
- package/dist/files/file-routes.d.ts +2 -0
- package/dist/files/file-routes.js +29 -2
- package/dist/files/provider-resolver.d.ts +1 -0
- package/dist/files/provider-resolver.js +1 -1
- package/dist/files/record-write-check.d.ts +6 -0
- package/dist/files/record-write-check.js +13 -0
- package/dist/files/upload-ledger.d.ts +2 -2
- package/dist/files/upload-ledger.js +4 -4
- package/dist/i18n/required-surface-keys.js +7 -2
- package/dist/internal/testing.js +1 -1
- package/dist/jobs/job-runner-scope.js +6 -6
- package/dist/jobs/job-runner.js +3 -0
- package/dist/observability/escape-hatch-report.js +9 -2
- package/dist/pipeline/after-rollback-sink.d.ts +6 -0
- package/dist/pipeline/after-rollback-sink.js +32 -0
- package/dist/pipeline/dispatch-batch.js +8 -1
- package/dist/pipeline/dispatch-shared.js +4 -0
- package/dist/pipeline/event-dispatcher-delivery.js +3 -1
- package/dist/pipeline/event-dispatcher.d.ts +1 -0
- package/dist/pipeline/event-dispatcher.js +6 -9
- package/dist/pipeline/handler-context-dispatch.d.ts +2 -0
- package/dist/pipeline/handler-context-dispatch.js +13 -1
- package/dist/pipeline/handler-context-events.d.ts +2 -4
- package/dist/pipeline/handler-context-events.js +6 -4
- package/dist/pipeline/system-hooks.d.ts +3 -1
- package/dist/pipeline/system-hooks.js +55 -6
- package/dist/pipeline/write-result-masking.js +13 -10
- package/dist/schema-cli/baseline-command.js +1 -1
- package/dist/schema-cli/status-command.js +1 -1
- package/dist/search/reindex-entity.js +15 -5
- package/dist/ui-types/index.d.ts +2 -2
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-changelog.d.ts +2 -0
- package/dist/upgrade-changelog.js +14 -0
- package/dist/upgrade-cli.d.ts +3 -12
- package/dist/upgrade-cli.js +22 -76
- package/dist/upgrade-marker-parse.d.ts +32 -0
- package/dist/upgrade-marker-parse.js +73 -0
- package/package.json +9 -6
- package/src/changes.json +245 -0
- package/src/scripts/codemod/codemod-support.ts +1 -1
- package/src/scripts/codemod/db-factory.ts +1 -1
- package/dist/engine/provider-plugin-context.d.ts +0 -3
- package/dist/engine/provider-plugin-context.js +0 -28
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { toKumikoError } from "../../errors/index.js";
|
|
4
|
+
import { createI18n, DEFAULT_LOCALE } from "../../i18n/index.js";
|
|
5
|
+
import { isValidIanaTimeZone } from "../../time/index.js";
|
|
6
|
+
import { Routes } from "../api-constants.js";
|
|
7
|
+
import { getUser } from "../auth-middleware.js";
|
|
8
|
+
import { requestContext } from "../request-context.js";
|
|
9
|
+
import { assertPayloadDepthAllowed, queryErrorResponse } from "../routes.js";
|
|
10
|
+
import { EXPORT_TOO_MANY_I18N_KEY, exportRejection } from "./export-errors.js";
|
|
11
|
+
import { readExportRows } from "./export-rows.js";
|
|
12
|
+
import { resolveExportPlan } from "./export-screen.js";
|
|
13
|
+
import { writeExportWorkbook } from "./export-xlsx-writer.js";
|
|
14
|
+
const XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
15
|
+
const MAX_ID_LENGTH = 200;
|
|
16
|
+
const exportBodySchema = z.object({
|
|
17
|
+
screen: z.string().max(MAX_ID_LENGTH),
|
|
18
|
+
payload: z.record(z.string(), z.unknown()).default({}),
|
|
19
|
+
columns: z.array(z.string().max(MAX_ID_LENGTH)).min(1).max(MAX_ID_LENGTH),
|
|
20
|
+
timeZone: z.string().max(64).optional(),
|
|
21
|
+
});
|
|
22
|
+
// Browser zone first (what the user saw in the list), then the profile zone, else UTC.
|
|
23
|
+
function exportTimeZone(requested, profile) {
|
|
24
|
+
for (const candidate of [requested, profile]) {
|
|
25
|
+
if (candidate !== undefined && isValidIanaTimeZone(candidate))
|
|
26
|
+
return candidate;
|
|
27
|
+
}
|
|
28
|
+
return "UTC";
|
|
29
|
+
}
|
|
30
|
+
function downloadFileName(screenShortId, now) {
|
|
31
|
+
const safeId = screenShortId.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
32
|
+
return `${safeId}-${now.toISOString().slice(0, 10)}.xlsx`;
|
|
33
|
+
}
|
|
34
|
+
export function createExportRoutes(dispatcher, registry) {
|
|
35
|
+
const api = new Hono();
|
|
36
|
+
const i18n = createI18n(registry, { defaultLocale: DEFAULT_LOCALE });
|
|
37
|
+
api.post(Routes.exportXlsx, async (c) => {
|
|
38
|
+
const user = getUser(c);
|
|
39
|
+
try {
|
|
40
|
+
const parsed = exportBodySchema.safeParse(await c.req.json().catch(() => undefined));
|
|
41
|
+
if (!parsed.success)
|
|
42
|
+
throw exportRejection("export_invalid_request", "body");
|
|
43
|
+
const body = parsed.data;
|
|
44
|
+
assertPayloadDepthAllowed(body.payload);
|
|
45
|
+
const plan = resolveExportPlan(registry, body.screen, body.columns, user);
|
|
46
|
+
const result = await readExportRows(dispatcher, plan, body.payload, user);
|
|
47
|
+
if (result.tooMany) {
|
|
48
|
+
return c.json({
|
|
49
|
+
error: {
|
|
50
|
+
code: "export_too_many_rows",
|
|
51
|
+
httpStatus: 413,
|
|
52
|
+
message: `export exceeds the limit of ${plan.maxRows} rows; narrow the filter`,
|
|
53
|
+
i18nKey: EXPORT_TOO_MANY_I18N_KEY,
|
|
54
|
+
details: { maxRows: plan.maxRows },
|
|
55
|
+
},
|
|
56
|
+
}, 413);
|
|
57
|
+
}
|
|
58
|
+
const locale = requestContext.get()?.locale ?? DEFAULT_LOCALE;
|
|
59
|
+
const bytes = await writeExportWorkbook(plan.columns, result.rows, (key) => i18n.t(key, locale), exportTimeZone(body.timeZone, user.timezone));
|
|
60
|
+
return new Response(bytes, {
|
|
61
|
+
status: 200,
|
|
62
|
+
headers: {
|
|
63
|
+
"Content-Type": XLSX_CONTENT_TYPE,
|
|
64
|
+
"Cache-Control": "no-store",
|
|
65
|
+
"Content-Disposition": `attachment; filename="${downloadFileName(plan.screenShortId, new Date())}"`,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
return queryErrorResponse(c, toKumikoError(e), Routes.exportXlsx);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
return api;
|
|
74
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SessionUser } from "../../engine/types/index.ts";
|
|
2
|
+
import type { Dispatcher } from "../../pipeline/dispatcher.ts";
|
|
3
|
+
import type { ExportPlan } from "./export-screen.ts";
|
|
4
|
+
export type ExportRow = Readonly<Record<string, unknown>>;
|
|
5
|
+
export type ExportRowsResult = {
|
|
6
|
+
readonly tooMany: false;
|
|
7
|
+
readonly rows: readonly ExportRow[];
|
|
8
|
+
} | {
|
|
9
|
+
readonly tooMany: true;
|
|
10
|
+
};
|
|
11
|
+
export declare function sanitizeExportPayload(clientPayload: Readonly<Record<string, unknown>>, plan: Pick<ExportPlan, "screenFilter">): Record<string, unknown>;
|
|
12
|
+
/** Reads the whole filtered list page by page; gives up as soon as more than `maxRows` exist. */
|
|
13
|
+
export declare function readExportRows(dispatcher: Pick<Dispatcher, "query">, plan: ExportPlan, clientPayload: Readonly<Record<string, unknown>>, user: SessionUser): Promise<ExportRowsResult>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { MAX_LIST_LIMIT } from "../../engine/entity-handlers.js";
|
|
2
|
+
import { InternalError } from "../../errors/index.js";
|
|
3
|
+
import { isPlainObject } from "../../utils/is-plain-object.js";
|
|
4
|
+
// Paging, sizing and aggregation belong to the server here; `filter` is replaced from the screen
|
|
5
|
+
// below, so a client can neither widen the fixed screen filter nor smuggle another one in.
|
|
6
|
+
const CLIENT_CONTROLLED_PAGING_KEYS = [
|
|
7
|
+
"limit",
|
|
8
|
+
"offset",
|
|
9
|
+
"cursor",
|
|
10
|
+
"totalCount",
|
|
11
|
+
"aggregate",
|
|
12
|
+
"includeDeleted",
|
|
13
|
+
"filter",
|
|
14
|
+
];
|
|
15
|
+
export function sanitizeExportPayload(clientPayload, plan) {
|
|
16
|
+
const payload = { ...clientPayload };
|
|
17
|
+
for (const key of CLIENT_CONTROLLED_PAGING_KEYS)
|
|
18
|
+
delete payload[key];
|
|
19
|
+
if (plan.screenFilter !== undefined)
|
|
20
|
+
payload["filter"] = plan.screenFilter;
|
|
21
|
+
return payload;
|
|
22
|
+
}
|
|
23
|
+
function parsePage(result, queryType) {
|
|
24
|
+
if (!isPlainObject(result) || !Array.isArray(result["rows"])) {
|
|
25
|
+
throw new InternalError({
|
|
26
|
+
message: `export: query ${queryType} did not return a { rows } envelope`,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const nextCursor = result["nextCursor"];
|
|
30
|
+
return {
|
|
31
|
+
rows: result["rows"].filter(isPlainObject),
|
|
32
|
+
nextCursor: typeof nextCursor === "string" && nextCursor !== "" ? nextCursor : undefined,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Reads the whole filtered list page by page; gives up as soon as more than `maxRows` exist. */
|
|
36
|
+
export async function readExportRows(dispatcher, plan, clientPayload, user) {
|
|
37
|
+
const base = sanitizeExportPayload(clientPayload, plan);
|
|
38
|
+
const rows = [];
|
|
39
|
+
const seenCursors = new Set();
|
|
40
|
+
let cursor;
|
|
41
|
+
for (;;) {
|
|
42
|
+
const limit = Math.min(MAX_LIST_LIMIT, plan.maxRows + 1 - rows.length);
|
|
43
|
+
const pagingPayload = cursor !== undefined
|
|
44
|
+
? { ...base, limit, cursor }
|
|
45
|
+
: rows.length > 0
|
|
46
|
+
? { ...base, limit, offset: rows.length }
|
|
47
|
+
: { ...base, limit };
|
|
48
|
+
const page = parsePage(await dispatcher.query(plan.queryType, pagingPayload, user), plan.queryType);
|
|
49
|
+
rows.push(...page.rows);
|
|
50
|
+
if (rows.length > plan.maxRows)
|
|
51
|
+
return { tooMany: true };
|
|
52
|
+
// A short or empty page is the end of the list; a repeated cursor would loop forever.
|
|
53
|
+
if (page.rows.length < limit || page.rows.length === 0)
|
|
54
|
+
return { tooMany: false, rows };
|
|
55
|
+
if (page.nextCursor !== undefined) {
|
|
56
|
+
if (seenCursors.has(page.nextCursor))
|
|
57
|
+
return { tooMany: false, rows };
|
|
58
|
+
seenCursors.add(page.nextCursor);
|
|
59
|
+
}
|
|
60
|
+
cursor = page.nextCursor;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Registry, ScreenFilter, SessionUser } from "../../engine/types/index.ts";
|
|
2
|
+
import { type ExportColumn } from "./export-columns.ts";
|
|
3
|
+
export type ExportPlan = {
|
|
4
|
+
readonly screenShortId: string;
|
|
5
|
+
readonly queryType: string;
|
|
6
|
+
readonly screenFilter: ScreenFilter | undefined;
|
|
7
|
+
readonly columns: readonly ExportColumn[];
|
|
8
|
+
readonly maxRows: number;
|
|
9
|
+
};
|
|
10
|
+
type ExportScreenRegistry = Pick<Registry, "getScreen" | "getScreenFeature" | "getEntity">;
|
|
11
|
+
export declare function resolveExportPlan(registry: ExportScreenRegistry, screenQn: string, requestedColumns: readonly string[], user: Pick<SessionUser, "roles">): ExportPlan;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { hasAccess } from "../../engine/access.js";
|
|
2
|
+
import { toKebab } from "../../engine/qualified-name.js";
|
|
3
|
+
import { DEFAULT_EXPORT_MAX_ROWS } from "../../engine/types/screen.js";
|
|
4
|
+
import { AccessDeniedError } from "../../errors/index.js";
|
|
5
|
+
import { resolveExportColumns } from "./export-columns.js";
|
|
6
|
+
import { exportRejection } from "./export-errors.js";
|
|
7
|
+
const SCREEN_QN = /^[A-Za-z0-9_-]+:screen:[A-Za-z0-9_-]+$/;
|
|
8
|
+
function findExportScreen(registry, screenQn) {
|
|
9
|
+
// The client sends the feature name as authored (camelCase allowed); the registry keys are kebab.
|
|
10
|
+
const registryQn = toKebab(screenQn);
|
|
11
|
+
const screen = SCREEN_QN.test(screenQn) ? registry.getScreen(registryQn) : undefined;
|
|
12
|
+
if (screen === undefined || (screen.type !== "entityList" && screen.type !== "projectionList")) {
|
|
13
|
+
throw exportRejection("export_unknown_screen", "screen");
|
|
14
|
+
}
|
|
15
|
+
return { registryQn, screen };
|
|
16
|
+
}
|
|
17
|
+
export function resolveExportPlan(registry, screenQn, requestedColumns, user) {
|
|
18
|
+
const { registryQn, screen } = findExportScreen(registry, screenQn);
|
|
19
|
+
if (screen.table?.export?.xlsx !== true)
|
|
20
|
+
throw exportRejection("export_not_enabled", "screen");
|
|
21
|
+
if (screen.access !== undefined && !hasAccess(user, screen.access)) {
|
|
22
|
+
throw new AccessDeniedError({ details: { screen: screenQn } });
|
|
23
|
+
}
|
|
24
|
+
// Label keys use the feature name as authored, query types the kebab form.
|
|
25
|
+
const featureName = registry.getScreenFeature(registryQn) ?? "";
|
|
26
|
+
const screenShortId = registryQn.split(":")[2] ?? "";
|
|
27
|
+
const maxRows = screen.table.export.maxRows ?? DEFAULT_EXPORT_MAX_ROWS;
|
|
28
|
+
if (screen.type === "projectionList") {
|
|
29
|
+
return {
|
|
30
|
+
screenShortId,
|
|
31
|
+
queryType: screen.query,
|
|
32
|
+
screenFilter: screen.filter,
|
|
33
|
+
columns: resolveExportColumns({ featureName, columns: screen.columns, entity: undefined, entityName: undefined, user }, requestedColumns),
|
|
34
|
+
maxRows,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const entity = registry.getEntity(screen.entity);
|
|
38
|
+
if (entity === undefined)
|
|
39
|
+
throw exportRejection("export_unknown_screen", "screen");
|
|
40
|
+
return {
|
|
41
|
+
screenShortId,
|
|
42
|
+
queryType: `${toKebab(featureName)}:query:${toKebab(screen.entity)}:list`,
|
|
43
|
+
screenFilter: screen.filter,
|
|
44
|
+
columns: resolveExportColumns({ featureName, columns: screen.columns, entity, entityName: screen.entity, user }, requestedColumns),
|
|
45
|
+
maxRows,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type Translate } from "./export-cells.ts";
|
|
2
|
+
import type { ExportColumn } from "./export-columns.ts";
|
|
3
|
+
import type { ExportRow } from "./export-rows.ts";
|
|
4
|
+
export declare function writeExportWorkbook(columns: readonly ExportColumn[], rows: readonly ExportRow[], translate: Translate, timeZone: string): Promise<Uint8Array<ArrayBuffer>>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import writeXlsxFile, {} from "write-excel-file/node";
|
|
2
|
+
import { toExportCell } from "./export-cells.js";
|
|
3
|
+
import { guardSheetAgainstCiphertext } from "./export-ciphertext-guard.js";
|
|
4
|
+
function headerLabel(column, translate) {
|
|
5
|
+
const translated = translate(column.headerKey);
|
|
6
|
+
return translated === column.headerKey ? column.headerFallback : translated;
|
|
7
|
+
}
|
|
8
|
+
export async function writeExportWorkbook(columns, rows, translate, timeZone) {
|
|
9
|
+
const header = columns.map((column) => ({
|
|
10
|
+
value: headerLabel(column, translate),
|
|
11
|
+
type: String,
|
|
12
|
+
fontWeight: "bold",
|
|
13
|
+
}));
|
|
14
|
+
const body = rows.map((row) => columns.map((column) => toExportCell(column, { row, translate, timeZone })));
|
|
15
|
+
const sheetData = guardSheetAgainstCiphertext([header, ...body]);
|
|
16
|
+
const buffer = await writeXlsxFile(sheetData, {}).toBuffer();
|
|
17
|
+
// Buffer is typed over ArrayBufferLike; a plain copy gives Response a BodyInit-compatible view.
|
|
18
|
+
const bytes = new Uint8Array(buffer.byteLength);
|
|
19
|
+
bytes.set(buffer);
|
|
20
|
+
return bytes;
|
|
21
|
+
}
|
|
@@ -70,6 +70,10 @@ export type SignatureExtraRouteDeps = {
|
|
|
70
70
|
* caller's authenticity (signature, HMAC state, etc.). */
|
|
71
71
|
readonly dispatchSystemWrite: (args: SystemDispatchArgs) => Promise<WriteResult>;
|
|
72
72
|
readonly dispatchSystemQuery: (args: SystemDispatchArgs) => Promise<unknown>;
|
|
73
|
+
/** Same trust as dispatchSystemWrite, but runs as the tenant's system user
|
|
74
|
+
* with the "system" role plus exactly `roles` (no SystemAdmin), so the write
|
|
75
|
+
* passes app-role access checks. */
|
|
76
|
+
readonly dispatchWriteAs: (args: RoleDispatchArgs) => Promise<WriteResult>;
|
|
73
77
|
};
|
|
74
78
|
export type SignatureExtraRoute<TVerified> = {
|
|
75
79
|
readonly method: HttpRouteMethod;
|
|
@@ -1,2 +1,5 @@
|
|
|
1
1
|
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
export declare const CIPHERTEXT_RE: RegExp;
|
|
3
|
+
export declare const CIPHERTEXT_REDACT_RE: RegExp;
|
|
4
|
+
export declare const CIPHERTEXT_REDACTION = "[pii-redacted]";
|
|
2
5
|
export declare function piiCiphertextResponseGuard(): MiddlewareHandler;
|
|
@@ -8,8 +8,9 @@ const isProductionEnv = () => process.env["NODE_ENV"] === "production";
|
|
|
8
8
|
// Bound to the full ciphertext shape (prefix, "<kind>:<id>" subject key, base64
|
|
9
9
|
// blob of at least IV + GCM tag = 28 bytes) so a user-typed "kumiko-pii:v" in a
|
|
10
10
|
// note or name field cannot turn every read of that record into a 500.
|
|
11
|
-
const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^:"\s<>\\]+:[^:"\s<>\\]+:[A-Za-z0-9+/=]{40,}/;
|
|
12
|
-
const CIPHERTEXT_REDACT_RE = new RegExp(CIPHERTEXT_RE.source, "g");
|
|
11
|
+
export const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^:"\s<>\\]+:[^:"\s<>\\]+:[A-Za-z0-9+/=]{40,}/;
|
|
12
|
+
export const CIPHERTEXT_REDACT_RE = new RegExp(CIPHERTEXT_RE.source, "g");
|
|
13
|
+
export const CIPHERTEXT_REDACTION = "[pii-redacted]";
|
|
13
14
|
// A PII subject ciphertext never belongs in an API response — its presence
|
|
14
15
|
// means a raw DB read (fetchOne/selectMany) leaked to the surface. Dev/test
|
|
15
16
|
// fail loud (500) so a forgotten decrypt turns the first integration test
|
package/dist/api/routes.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Context, Hono } from "hono";
|
|
2
|
+
import { type KumikoError } from "../errors/index.ts";
|
|
2
3
|
import type { Dispatcher } from "../pipeline/dispatcher.ts";
|
|
3
4
|
export declare const StreamFrame: {
|
|
4
5
|
readonly chunk: "chunk";
|
|
@@ -18,4 +19,19 @@ export type SseWriter = {
|
|
|
18
19
|
};
|
|
19
20
|
export declare function pumpStream(stream: SseWriter, generator: AsyncGenerator<unknown>, heartbeatMs: number, firstPull?: Promise<IteratorResult<unknown>>): Promise<void>;
|
|
20
21
|
export declare const MAX_PAYLOAD_DEPTH = 20;
|
|
22
|
+
export declare function assertPayloadDepthAllowed(payload: unknown): void;
|
|
23
|
+
export declare function queryErrorResponse(c: Context, err: KumikoError, type?: unknown): (Response & import("hono").TypedResponse<{
|
|
24
|
+
readonly error: {
|
|
25
|
+
readonly code: string;
|
|
26
|
+
readonly i18nKey: string;
|
|
27
|
+
readonly i18nParams?: {
|
|
28
|
+
readonly [x: string]: import("hono/utils/types").JSONValue;
|
|
29
|
+
} | undefined;
|
|
30
|
+
readonly message: string;
|
|
31
|
+
readonly details?: import("hono/utils/types").JSONValue | undefined;
|
|
32
|
+
readonly docsUrl?: string;
|
|
33
|
+
readonly requestId?: string;
|
|
34
|
+
readonly timestamp: string;
|
|
35
|
+
};
|
|
36
|
+
}, -1 | 100 | 102 | 103 | 200 | 201 | 202 | 203 | 206 | 207 | 208 | 226 | 300 | 301 | 302 | 303 | 305 | 306 | 307 | 308 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 421 | 422 | 423 | 424 | 425 | 426 | 428 | 429 | 431 | 451 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511, "json">) | (Response & import("hono").TypedResponse<null, -1 | 100 | 102 | 103 | 200 | 201 | 202 | 203 | 206 | 207 | 208 | 226 | 300 | 301 | 302 | 303 | 305 | 306 | 307 | 308 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 421 | 422 | 423 | 424 | 425 | 426 | 428 | 429 | 431 | 451 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511, "body">);
|
|
21
37
|
export declare function handleUncaughtRouteError(err: Error, c: Context): Response | Promise<Response>;
|
package/dist/api/routes.js
CHANGED
|
@@ -312,7 +312,7 @@ async function readDispatchBody(c) {
|
|
|
312
312
|
// jsonb serialization of custom-fields payloads) walks the same object
|
|
313
313
|
// graph an attacker controls, so an uncapped depth is a stack-overflow /
|
|
314
314
|
// CPU-DoS vector even under the 1MB body-byte limit.
|
|
315
|
-
function assertPayloadDepthAllowed(payload) {
|
|
315
|
+
export function assertPayloadDepthAllowed(payload) {
|
|
316
316
|
if (payloadDepth(payload, 0) > MAX_PAYLOAD_DEPTH) {
|
|
317
317
|
throw new ValidationError({
|
|
318
318
|
fields: [
|
|
@@ -428,7 +428,7 @@ function clientAbortResponse(c, requestId, type) {
|
|
|
428
428
|
}
|
|
429
429
|
// For /query + /command: no isSuccess on success (just { data } / {ok}), so we
|
|
430
430
|
// keep the same lean shape on failure — only the `error` key.
|
|
431
|
-
function queryErrorResponse(c, err, type) {
|
|
431
|
+
export function queryErrorResponse(c, err, type) {
|
|
432
432
|
const requestId = requestContext.get()?.requestId;
|
|
433
433
|
if (isClientAbort(err))
|
|
434
434
|
return clientAbortResponse(c, requestId, type);
|
|
@@ -34,6 +34,7 @@ export function mountExtraRoutes(app, wiring) {
|
|
|
34
34
|
}
|
|
35
35
|
const dispatchSystemWrite = makeDispatchSystemWrite(dispatcher);
|
|
36
36
|
const dispatchSystemQuery = makeDispatchSystemQuery(dispatcher);
|
|
37
|
+
const dispatchWriteAs = makeDispatchWriteAs(dispatcher);
|
|
37
38
|
for (const route of options.extraRoutes) {
|
|
38
39
|
const honoHandler = buildExtraRouteHonoHandler(route, {
|
|
39
40
|
app,
|
|
@@ -42,6 +43,7 @@ export function mountExtraRoutes(app, wiring) {
|
|
|
42
43
|
secrets: context.secrets,
|
|
43
44
|
dispatchSystemWrite,
|
|
44
45
|
dispatchSystemQuery,
|
|
46
|
+
dispatchWriteAs,
|
|
45
47
|
clientIpResolver,
|
|
46
48
|
});
|
|
47
49
|
mountHonoRoute(app, route.method, route.path, honoHandler, bodyLimitForRoutePath(route.path, options.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES));
|
|
@@ -195,6 +197,7 @@ function buildExtraRouteHonoHandler(route, shared) {
|
|
|
195
197
|
systemQuery: makeSystemQuery(c, shared.dispatcher, shared.clientIpResolver),
|
|
196
198
|
dispatchSystemWrite: shared.dispatchSystemWrite,
|
|
197
199
|
dispatchSystemQuery: shared.dispatchSystemQuery,
|
|
200
|
+
dispatchWriteAs: shared.dispatchWriteAs,
|
|
198
201
|
});
|
|
199
202
|
};
|
|
200
203
|
default:
|
|
@@ -10,7 +10,7 @@ export declare function mountAuthRoutes(app: Hono, wiring: Pick<ServerWiring, "o
|
|
|
10
10
|
export declare function mountClientErrorRoute(app: Hono, wiring: Pick<ServerWiring, "rateLimitResolver" | "tracedRedis" | "clientIpResolver" | "observability" | "jwt">): void;
|
|
11
11
|
export declare function mountDispatcherRoutes(app: Hono, wiring: Pick<ServerWiring, "options" | "dispatcher" | "sseBroker">): void;
|
|
12
12
|
export declare function mountSchemaRoute(app: Hono, options: Pick<ServerOptions, "registry" | "dispatcherOptions" | "context">): void;
|
|
13
|
-
export declare function mountFileRoutes(app: Hono, wiring: Pick<ServerWiring, "options" | "dispatcher" | "fileProviderResolver">): void;
|
|
13
|
+
export declare function mountFileRoutes(app: Hono, wiring: Pick<ServerWiring, "options" | "dispatcher" | "fileProviderResolver" | "rateLimitResolver" | "tracedRedis">): void;
|
|
14
14
|
export declare function mountFeatureHttpRoutes(app: Hono, wiring: Pick<ServerWiring, "options" | "dispatcher" | "clientIpResolver" | "rateLimitResolver" | "observability">, sessionOnlyHttpRouteGuards: readonly MiddlewareHandler[]): void;
|
|
15
15
|
export declare function installNoRouteMatchMarker(app: Hono): void;
|
|
16
16
|
export declare function mountHonoRoute(app: Hono, method: HttpRouteMethod, path: string, handler: (c: import("hono").Context<any, any>) => Response | Promise<Response>, middlewares?: readonly MiddlewareHandler[]): void;
|
|
@@ -14,7 +14,8 @@ import { getUser, identifyOptionalSession } from "./auth-middleware.js";
|
|
|
14
14
|
import { createAuthRoutes } from "./auth-routes.js";
|
|
15
15
|
import { createClientErrorRoute } from "./client-error-route.js";
|
|
16
16
|
import { clientIpSourceFromHonoContext } from "./client-ip.js";
|
|
17
|
-
import { collectEntityReadAccess } from "./entity-read-access.js";
|
|
17
|
+
import { collectEntityReadAccess, collectEntityRowReadMaps } from "./entity-read-access.js";
|
|
18
|
+
import { createExportRoutes } from "./export/export-route.js";
|
|
18
19
|
import { computeStrongEtag, etagMatches } from "./http-cache.js";
|
|
19
20
|
import { requestContext } from "./request-context.js";
|
|
20
21
|
import { buildRequestContextData } from "./request-id-middleware.js";
|
|
@@ -67,9 +68,11 @@ export function mountDispatcherRoutes(app, wiring) {
|
|
|
67
68
|
app.route("/api", createApiRoutes(dispatcher, {
|
|
68
69
|
...(options.sseHeartbeatMs !== undefined ? { sseHeartbeatMs: options.sseHeartbeatMs } : {}),
|
|
69
70
|
}));
|
|
71
|
+
app.route("/api", createExportRoutes(dispatcher, options.registry));
|
|
70
72
|
app.route("/api", createSseRoute(sseBroker, {
|
|
71
73
|
anonymousLiveEntities: collectAnonymousLiveEntities(options.registry),
|
|
72
74
|
entityReadAccess: collectEntityReadAccess(options.registry),
|
|
75
|
+
entityRowReadMaps: collectEntityRowReadMaps(options.registry),
|
|
73
76
|
}));
|
|
74
77
|
}
|
|
75
78
|
export function mountSchemaRoute(app, options) {
|
|
@@ -122,7 +125,9 @@ export function mountSchemaRoute(app, options) {
|
|
|
122
125
|
});
|
|
123
126
|
}
|
|
124
127
|
export function mountFileRoutes(app, wiring) {
|
|
125
|
-
const { options, dispatcher, fileProviderResolver } = wiring;
|
|
128
|
+
const { options, dispatcher, fileProviderResolver, rateLimitResolver, tracedRedis } = wiring;
|
|
129
|
+
const metaBatchRateLimitResolver = rateLimitResolver ??
|
|
130
|
+
(tracedRedis ? createRateLimitResolver({ redis: tracedRedis }) : undefined);
|
|
126
131
|
// Mount upload/download routes whenever a file provider is resolvable (a
|
|
127
132
|
// file-provider plugin is mounted, or a resolver was injected). They resolve
|
|
128
133
|
// the provider per-tenant through file-foundation — no `files` option; route
|
|
@@ -139,6 +144,7 @@ export function mountFileRoutes(app, wiring) {
|
|
|
139
144
|
db: fileDb,
|
|
140
145
|
registry: options.registry,
|
|
141
146
|
resolveProvider: fileProviderResolver,
|
|
147
|
+
...(metaBatchRateLimitResolver ? { metaBatchRateLimitResolver } : {}),
|
|
142
148
|
canReadRecord,
|
|
143
149
|
canWriteRecord: createRecordWriteCheck({
|
|
144
150
|
registry: options.registry,
|
|
@@ -23,6 +23,7 @@ export function withFileProviderResolver(registry, context, cacheSync) {
|
|
|
23
23
|
return context;
|
|
24
24
|
if (registry.getExtensionUsages(EXT_FILE_PROVIDER).length === 0)
|
|
25
25
|
return context;
|
|
26
|
+
const { clock } = context;
|
|
26
27
|
return {
|
|
27
28
|
...context,
|
|
28
29
|
_fileProviderResolver: makeFileProviderResolver({
|
|
@@ -30,6 +31,7 @@ export function withFileProviderResolver(registry, context, cacheSync) {
|
|
|
30
31
|
_configAccessorFactory: configAccessorFactoryOf(context),
|
|
31
32
|
secrets: context.secrets,
|
|
32
33
|
db: context.db,
|
|
34
|
+
...(clock ? { now: () => clock.now() } : {}),
|
|
33
35
|
...(cacheSync ? { cacheSync } : {}),
|
|
34
36
|
}),
|
|
35
37
|
};
|
package/dist/api/sse-route.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
-
import type { AccessRule } from "../engine/types/index.ts";
|
|
2
|
+
import type { AccessRule, OwnershipMap } from "../engine/types/index.ts";
|
|
3
3
|
import { type SseBroker } from "./sse-broker.ts";
|
|
4
4
|
/**
|
|
5
5
|
* Heartbeat cadence for SSE streams.
|
|
@@ -27,6 +27,7 @@ export declare const SSE_MAX_PENDING_WRITES_PER_CLIENT = 10000;
|
|
|
27
27
|
export type SseRouteOptions = {
|
|
28
28
|
readonly anonymousLiveEntities: ReadonlySet<string>;
|
|
29
29
|
readonly entityReadAccess: ReadonlyMap<string, readonly AccessRule[]>;
|
|
30
|
+
readonly entityRowReadMaps?: ReadonlyMap<string, OwnershipMap>;
|
|
30
31
|
readonly now?: () => number;
|
|
31
32
|
};
|
|
32
33
|
export declare function createSseRoute(broker: SseBroker, options: SseRouteOptions): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
|
package/dist/api/sse-route.js
CHANGED
|
@@ -84,7 +84,17 @@ function entitySignalDetail(user, aggregateType, options) {
|
|
|
84
84
|
const granting = (options.entityReadAccess.get(aggregateType) ?? []).filter((rule) => hasAccess(user, rule));
|
|
85
85
|
if (granting.length === 0)
|
|
86
86
|
return "none";
|
|
87
|
-
|
|
87
|
+
if (!granting.some((rule) => "roles" in rule))
|
|
88
|
+
return "anonymous";
|
|
89
|
+
return readsEveryRow(user, options.entityRowReadMaps?.get(aggregateType))
|
|
90
|
+
? "identified"
|
|
91
|
+
: "anonymous";
|
|
92
|
+
}
|
|
93
|
+
// Mirrors buildOwnershipClause: without a map every row is visible, otherwise only a role mapped to "all" sees them all.
|
|
94
|
+
function readsEveryRow(user, rowReadMap) {
|
|
95
|
+
if (rowReadMap === undefined)
|
|
96
|
+
return true;
|
|
97
|
+
return user.roles.some((role) => rowReadMap[role] === "all");
|
|
88
98
|
}
|
|
89
99
|
function decideWireFrame(event, user, options) {
|
|
90
100
|
const data = event.data;
|
package/dist/config/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ValidateBootOptions } from "../engine/boot-validator.ts";
|
|
2
|
-
import type { FeatureDefinition } from "../engine/types/index.ts";
|
|
2
|
+
import type { BundledListsOptions, FeatureDefinition } from "../engine/types/index.ts";
|
|
3
3
|
export type AppInfraPorts = {
|
|
4
4
|
readonly postgres?: number;
|
|
5
5
|
readonly redis?: number;
|
|
@@ -35,6 +35,8 @@ export type AppConfig = {
|
|
|
35
35
|
readonly ports: AppPorts;
|
|
36
36
|
/** Passed to validateBoot by `kumiko check` and, via the app's bin files, by the runners. */
|
|
37
37
|
readonly validateBootOptions?: AppValidateBootOptions;
|
|
38
|
+
/** One table block for every list of the bundled features. kumiko/schema.ts passes it to composeFeatures; the app's bin files pass it to the runners, like validateBootOptions. */
|
|
39
|
+
readonly bundledLists?: BundledListsOptions;
|
|
38
40
|
};
|
|
39
41
|
export declare const APP_CONFIG_FILE = "kumiko.config.ts";
|
|
40
42
|
declare const INFRA_PORT_NAMES: readonly ["postgres", "redis", "meilisearch", "s3"];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
2
|
+
import { createDbConnection } from "../db/connection.js";
|
|
3
|
+
import { isUniqueViolation } from "../db/pg-error.js";
|
|
4
4
|
import { KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, subjectIdToKey, } from "./kms-adapter.js";
|
|
5
5
|
const IV_LENGTH = 12;
|
|
6
6
|
const TAG_LENGTH = 16;
|
|
@@ -28,21 +28,15 @@ function decodePlatformKek(base64) {
|
|
|
28
28
|
}
|
|
29
29
|
return kek;
|
|
30
30
|
}
|
|
31
|
-
const PG_UNIQUE_VIOLATION = "23505";
|
|
32
31
|
// pg_advisory_xact_lock key for createSchema — serializes concurrent cold-start
|
|
33
32
|
// DDL across processes sharing one subject-keys DB.
|
|
34
33
|
const SCHEMA_ADVISORY_LOCK_KEY = 0x6b_6d_73_31; // 'kms1'
|
|
35
|
-
function isPgUniqueViolation(error) {
|
|
36
|
-
return (typeof error === "object" &&
|
|
37
|
-
error !== null &&
|
|
38
|
-
"code" in error &&
|
|
39
|
-
error.code === PG_UNIQUE_VIOLATION);
|
|
40
|
-
}
|
|
41
34
|
// Default production adapter: DEKs live in a separate Postgres cluster,
|
|
42
35
|
// KEK-wrapped at rest. Schema is created lazily on first use — the
|
|
43
36
|
// subject-keys cluster has no drizzle migration pipeline of its own.
|
|
44
37
|
export class PgKmsAdapter {
|
|
45
38
|
capabilities = { mode: "local-key" };
|
|
39
|
+
connection;
|
|
46
40
|
sql;
|
|
47
41
|
kek;
|
|
48
42
|
kekVersion;
|
|
@@ -60,12 +54,11 @@ export class PgKmsAdapter {
|
|
|
60
54
|
previous.set(v, decodePlatformKek(kek));
|
|
61
55
|
}
|
|
62
56
|
this.previousKeks = previous;
|
|
63
|
-
this.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
// idempotent IF NOT EXISTS — notices are pure boot-log noise.
|
|
67
|
-
onnotice: () => { },
|
|
57
|
+
this.connection = createDbConnection({
|
|
58
|
+
url: options.databaseUrl,
|
|
59
|
+
maxConnections: options.maxConnections ?? 4,
|
|
68
60
|
});
|
|
61
|
+
this.sql = this.connection.client;
|
|
69
62
|
}
|
|
70
63
|
async createKey(subject, ctx) {
|
|
71
64
|
await this.ensureSchema();
|
|
@@ -76,7 +69,7 @@ export class PgKmsAdapter {
|
|
|
76
69
|
VALUES (${subjectIdToKey(subject)}, ${wrapped}, ${this.kekVersion}, ${ctx.userId ?? null})`;
|
|
77
70
|
}
|
|
78
71
|
catch (error) {
|
|
79
|
-
if (
|
|
72
|
+
if (isUniqueViolation(error))
|
|
80
73
|
throw new KeyAlreadyExistsError(subject);
|
|
81
74
|
throw error;
|
|
82
75
|
}
|
|
@@ -128,7 +121,7 @@ export class PgKmsAdapter {
|
|
|
128
121
|
return { ok: true, latencyMs: Date.now() - start };
|
|
129
122
|
}
|
|
130
123
|
async close() {
|
|
131
|
-
await this.
|
|
124
|
+
await this.connection.close();
|
|
132
125
|
}
|
|
133
126
|
ensureSchema() {
|
|
134
127
|
// A failed attempt resets the memo so a transient DB outage does not
|
|
@@ -178,7 +171,8 @@ export async function rewrapSubjectKeys(options) {
|
|
|
178
171
|
fromKeks.set(Number(version), decodePlatformKek(kek));
|
|
179
172
|
}
|
|
180
173
|
const batchSize = options.batchSize ?? 200;
|
|
181
|
-
const
|
|
174
|
+
const connection = createDbConnection({ url: options.databaseUrl, maxConnections: 2 });
|
|
175
|
+
const sql = connection.client;
|
|
182
176
|
const failures = [];
|
|
183
177
|
const result = { scanned: 0, rewrapped: 0, skippedErased: 0, failures };
|
|
184
178
|
try {
|
|
@@ -226,7 +220,7 @@ export async function rewrapSubjectKeys(options) {
|
|
|
226
220
|
}
|
|
227
221
|
}
|
|
228
222
|
finally {
|
|
229
|
-
await
|
|
223
|
+
await connection.close();
|
|
230
224
|
}
|
|
231
225
|
return result;
|
|
232
226
|
}
|
|
@@ -12,6 +12,8 @@ export declare function isListAggregateMeasureSupported(kind: ListAggregateMeasu
|
|
|
12
12
|
export declare function isListAggregateGroupable(fieldType: FieldType, multiple: boolean): boolean;
|
|
13
13
|
export declare function listAggregateKeySql(fieldType: FieldType, columnSql: string, alias: string): string;
|
|
14
14
|
export declare function listAggregateMeasureSql(kind: ListAggregateMeasureKind, fieldType: FieldType, columnSql: string, filterSql?: string): string;
|
|
15
|
+
export declare function isPerCurrencyMeasure(kind: ListAggregateMeasureKind, fieldType: FieldType): boolean;
|
|
16
|
+
export declare function listCurrencyMeasureSql(kind: ListAggregateMeasureKind, columnSql: string, filterSql: string): string;
|
|
15
17
|
export declare function groupedKeyOrderLimitSql(limit: number): string;
|
|
16
18
|
export declare function decodeListAggregateValue(raw: unknown): ListAggregateValue;
|
|
17
19
|
export declare function emptyListAggregateValue(kind: ListAggregateMeasureKind): ListAggregateValue;
|
package/dist/db/aggregate-sql.js
CHANGED
|
@@ -95,6 +95,16 @@ export function listAggregateMeasureSql(kind, fieldType, columnSql, filterSql) {
|
|
|
95
95
|
return `ROUND(${fn})::float8`;
|
|
96
96
|
return `(${fn})::float8`;
|
|
97
97
|
}
|
|
98
|
+
export function isPerCurrencyMeasure(kind, fieldType) {
|
|
99
|
+
return fieldType === "money" && kind !== "count";
|
|
100
|
+
}
|
|
101
|
+
// No COALESCE on SUM: a currency absent from a group must read NULL so it can be left out.
|
|
102
|
+
export function listCurrencyMeasureSql(kind, columnSql, filterSql) {
|
|
103
|
+
const fn = kind === "sum"
|
|
104
|
+
? `SUM(${columnSql}) FILTER (WHERE ${filterSql})`
|
|
105
|
+
: aggregateFunctionSql(kind, columnSql, filterSql);
|
|
106
|
+
return kind === "avg" ? `ROUND(${fn})::float8` : `(${fn})::float8`;
|
|
107
|
+
}
|
|
98
108
|
export function groupedKeyOrderLimitSql(limit) {
|
|
99
109
|
return ` GROUP BY 1 ORDER BY 1 ASC NULLS LAST LIMIT ${limit}`;
|
|
100
110
|
}
|