@cosmicdrift/kumiko-samples 0.359.0 → 0.361.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 +1 -1
- package/packages/bundled-features/package.json +9 -2
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/handlers/details.query.ts +2 -1
- package/packages/bundled-features/src/audit/handlers/list.query.ts +4 -5
- package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +6 -0
- package/packages/bundled-features/src/auth-email-password/web/session.tsx +39 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +4 -4
- package/packages/bundled-features/src/cap-counter/changes.json +6 -0
- package/packages/bundled-features/src/cap-counter/index.ts +1 -0
- package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +11 -6
- package/packages/bundled-features/src/folders-user-data/hooks.ts +4 -2
- package/packages/bundled-features/src/form-draft-user-data/hooks.ts +4 -1
- package/packages/bundled-features/src/jobs/changes.json +6 -0
- package/packages/bundled-features/src/jobs/job-run-logger.ts +23 -10
- package/packages/bundled-features/src/renderer-pdf/README.md +27 -0
- package/packages/bundled-features/src/renderer-pdf/changes.json +8 -0
- package/packages/bundled-features/src/renderer-pdf/feature.ts +48 -0
- package/packages/bundled-features/src/renderer-pdf/index.ts +9 -0
- package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +433 -0
- package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +2 -2
- package/packages/bundled-features/src/shared/index.ts +1 -0
- package/packages/bundled-features/src/shared/redact-pii-ciphertext.ts +16 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +7 -2
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +2 -1
- package/packages/bundled-features/src/user/changes.json +6 -0
- package/packages/bundled-features/src/user/handlers/me.query.ts +5 -1
- package/packages/bundled-features/src/user-data-rights/handlers/my-audit-log.query.ts +2 -1
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +6 -1
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +3 -2
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +3 -3
- package/packages/bundled-features/src/user-profile/changes.json +6 -0
- package/packages/bundled-features/src/user-profile/feature.ts +21 -11
- package/packages/bundled-features/src/user-profile/index.ts +1 -1
- package/samples/apps/styleguide/src/features/examples/feature.ts +52 -3
- package/samples/apps/styleguide/src/features/examples/i18n.ts +3 -0
- package/samples/apps/ui-walkthrough/src/app/seed-rental.ts +3 -2
- package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +11 -2
- package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +17 -5
- package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +12 -1
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +46 -1
- package/samples/apps/ui-walkthrough/src/features/vehicles/feature.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/vehicles/i18n.ts +3 -1
- package/samples/apps/ui-walkthrough/src/features/vehicles/screens.ts +18 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +28 -2
- package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
- package/samples/recipes/files-post-processing/src/feature.ts +11 -1
- package/samples/recipes/pdf-audit-export/README.md +22 -0
- package/samples/recipes/pdf-audit-export/package.json +12 -0
- package/samples/recipes/pdf-audit-export/src/feature.ts +114 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.361.0",
|
|
4
4
|
"description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.361.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -116,6 +116,7 @@
|
|
|
116
116
|
"./channel-teams": "./src/channel-teams/index.ts",
|
|
117
117
|
"./channel-telegram": "./src/channel-telegram/index.ts",
|
|
118
118
|
"./renderer-simple": "./src/renderer-simple/index.ts",
|
|
119
|
+
"./renderer-pdf": "./src/renderer-pdf/index.ts",
|
|
119
120
|
"./files-provider-s3": "./src/files-provider-s3/index.ts",
|
|
120
121
|
"./rate-limiting": "./src/rate-limiting/index.ts",
|
|
121
122
|
"./presets": "./src/presets/index.ts",
|
|
@@ -166,6 +167,7 @@
|
|
|
166
167
|
"mailparser": "^3.9.32",
|
|
167
168
|
"marked": "^18.0.3",
|
|
168
169
|
"nodemailer": "^10.0.13",
|
|
170
|
+
"pdf-lib": "^1.17.1",
|
|
169
171
|
"qrcode": "^1.5.4",
|
|
170
172
|
"react": "^19.2.6",
|
|
171
173
|
"sharp": "^0.35.5",
|
|
@@ -555,6 +557,10 @@
|
|
|
555
557
|
"types": "./dist/renderer-simple/index.d.ts",
|
|
556
558
|
"default": "./dist/renderer-simple/index.js"
|
|
557
559
|
},
|
|
560
|
+
"./renderer-pdf": {
|
|
561
|
+
"types": "./dist/renderer-pdf/index.d.ts",
|
|
562
|
+
"default": "./dist/renderer-pdf/index.js"
|
|
563
|
+
},
|
|
558
564
|
"./files-provider-s3": {
|
|
559
565
|
"types": "./dist/files-provider-s3/index.d.ts",
|
|
560
566
|
"default": "./dist/files-provider-s3/index.js"
|
|
@@ -683,6 +689,7 @@
|
|
|
683
689
|
"@testing-library/user-event": "^14.6.1",
|
|
684
690
|
"@cosmicdrift/kumiko-locale-de": "workspace:*",
|
|
685
691
|
"@cosmicdrift/kumiko-locale-es": "workspace:*",
|
|
686
|
-
"jsqr": "^1.4.0"
|
|
692
|
+
"jsqr": "^1.4.0",
|
|
693
|
+
"unpdf": "^1.8.1"
|
|
687
694
|
}
|
|
688
695
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.361.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Audit log works under an active subject KMS",
|
|
6
|
+
"detail": "audit list, audit details and user-data-rights my-audit-log returned event payloads as stored. With a subject KMS configured the payload carried kumiko-pii ciphertext and the API leak guard answered 500 pii_ciphertext_leak. Every PII ciphertext string in the payload is now replaced by REDACTED_PII_PLACEHOLDER (\"[[personal data]]\"); the audit log still never shows plaintext PII."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.353.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -7,6 +7,7 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
7
7
|
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
9
9
|
import * as z from "zod";
|
|
10
|
+
import { redactPiiCiphertext } from "../../shared/index.js";
|
|
10
11
|
import { AUDIT_SCOPE_VALUES } from "../constants.js";
|
|
11
12
|
import { resolveAuditScopeFilter } from "./resolve-audit-tenant.js";
|
|
12
13
|
|
|
@@ -51,7 +52,7 @@ export const detailsQuery = defineQueryHandler({
|
|
|
51
52
|
aggregateType: row.aggregateType,
|
|
52
53
|
version: row.version,
|
|
53
54
|
type: row.type,
|
|
54
|
-
payload: row.payload,
|
|
55
|
+
payload: redactPiiCiphertext(row.payload),
|
|
55
56
|
metadata: row.metadata,
|
|
56
57
|
createdAt: row.createdAt,
|
|
57
58
|
createdBy: row.createdBy,
|
|
@@ -9,16 +9,15 @@
|
|
|
9
9
|
// A SystemAdmin can pass scope "system" to read the app-instance system events
|
|
10
10
|
// (e.g. app.started); no other cross-tenant read exists.
|
|
11
11
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// query returns payloads as stored, so a sensitive value surfaces as ciphertext,
|
|
15
|
-
// never as plaintext.
|
|
12
|
+
// Event payloads hold PII as subject-KMS ciphertext; it is replaced by
|
|
13
|
+
// REDACTED_PII_PLACEHOLDER before returning, since the API leak guard refuses ciphertext.
|
|
16
14
|
|
|
17
15
|
import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
18
16
|
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
19
17
|
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
20
18
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
21
19
|
import * as z from "zod";
|
|
20
|
+
import { redactPiiCiphertext } from "../../shared/index.js";
|
|
22
21
|
import { AUDIT_SCOPE_VALUES } from "../constants.js";
|
|
23
22
|
import { resolveAuditScopeFilter } from "./resolve-audit-tenant.js";
|
|
24
23
|
|
|
@@ -132,7 +131,7 @@ export const listQuery = defineQueryHandler({
|
|
|
132
131
|
aggregateType: r.aggregateType,
|
|
133
132
|
version: r.version,
|
|
134
133
|
type: r.type,
|
|
135
|
-
payload: r.payload,
|
|
134
|
+
payload: redactPiiCiphertext(r.payload),
|
|
136
135
|
metadata: r.metadata,
|
|
137
136
|
createdAt: r.createdAt,
|
|
138
137
|
createdBy: r.createdBy,
|
|
@@ -452,6 +452,8 @@ export type CurrentUserProfile = {
|
|
|
452
452
|
readonly email: string;
|
|
453
453
|
readonly displayName: string;
|
|
454
454
|
readonly locale?: string;
|
|
455
|
+
/** Effective IANA zone (user override, else tenant, else UTC). */
|
|
456
|
+
readonly effectiveTimeZone?: string;
|
|
455
457
|
readonly globalRoles: readonly string[];
|
|
456
458
|
};
|
|
457
459
|
|
|
@@ -477,6 +479,7 @@ export async function fetchCurrentUser(): Promise<CurrentUserProfile | null> {
|
|
|
477
479
|
email: string;
|
|
478
480
|
displayName: string;
|
|
479
481
|
locale?: string;
|
|
482
|
+
effectiveTimeZone?: string;
|
|
480
483
|
// JSON-encoded string[] — siehe userEntity.roles. Default "[]" wenn
|
|
481
484
|
// keine globalen Rollen.
|
|
482
485
|
roles?: string;
|
|
@@ -488,6 +491,9 @@ export async function fetchCurrentUser(): Promise<CurrentUserProfile | null> {
|
|
|
488
491
|
email: body.data.email,
|
|
489
492
|
displayName: body.data.displayName,
|
|
490
493
|
...(body.data.locale !== undefined && { locale: body.data.locale }),
|
|
494
|
+
...(body.data.effectiveTimeZone !== undefined && {
|
|
495
|
+
effectiveTimeZone: body.data.effectiveTimeZone,
|
|
496
|
+
}),
|
|
491
497
|
globalRoles: parseRoles(body.data.roles),
|
|
492
498
|
};
|
|
493
499
|
}
|
|
@@ -10,8 +10,20 @@
|
|
|
10
10
|
// und die Transitions.
|
|
11
11
|
|
|
12
12
|
import { readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
13
|
-
import {
|
|
14
|
-
|
|
13
|
+
import {
|
|
14
|
+
type CurrentUser,
|
|
15
|
+
CurrentUserProvider,
|
|
16
|
+
useSessionEndedSignal,
|
|
17
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
18
|
+
import {
|
|
19
|
+
createContext,
|
|
20
|
+
type ReactNode,
|
|
21
|
+
useCallback,
|
|
22
|
+
useContext,
|
|
23
|
+
useEffect,
|
|
24
|
+
useMemo,
|
|
25
|
+
useState,
|
|
26
|
+
} from "react";
|
|
15
27
|
import {
|
|
16
28
|
AuthRequestError,
|
|
17
29
|
type CurrentUserProfile,
|
|
@@ -232,7 +244,31 @@ export function SessionProvider({
|
|
|
232
244
|
}, []);
|
|
233
245
|
|
|
234
246
|
const api: SessionApi = { ...state, login, logout, switchTenant, refresh: doRefresh };
|
|
235
|
-
|
|
247
|
+
// Keyed on the primitives, not the profile object: every refresh (tenant
|
|
248
|
+
// switch, session bootstrap retry) builds a new profile object, and a new
|
|
249
|
+
// CurrentUser reference would re-seed a create form and drop typed input.
|
|
250
|
+
const profile = state.user;
|
|
251
|
+
const userId = profile?.id;
|
|
252
|
+
const userEmail = profile?.email;
|
|
253
|
+
const userDisplayName = profile?.displayName;
|
|
254
|
+
const userTimeZone = profile?.effectiveTimeZone;
|
|
255
|
+
const currentUser = useMemo<CurrentUser | undefined>(
|
|
256
|
+
() =>
|
|
257
|
+
userId === undefined || userEmail === undefined || userDisplayName === undefined
|
|
258
|
+
? undefined
|
|
259
|
+
: {
|
|
260
|
+
id: userId,
|
|
261
|
+
email: userEmail,
|
|
262
|
+
displayName: userDisplayName,
|
|
263
|
+
...(userTimeZone !== undefined && { timeZone: userTimeZone }),
|
|
264
|
+
},
|
|
265
|
+
[userId, userEmail, userDisplayName, userTimeZone],
|
|
266
|
+
);
|
|
267
|
+
return (
|
|
268
|
+
<SessionContext.Provider value={api}>
|
|
269
|
+
<CurrentUserProvider user={currentUser}>{children}</CurrentUserProvider>
|
|
270
|
+
</SessionContext.Provider>
|
|
271
|
+
);
|
|
236
272
|
}
|
|
237
273
|
|
|
238
274
|
export function useSession(): SessionApi {
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Public § 312k pages (/legal/kuendigen, /legal/cancel): form → review → confirm,
|
|
2
2
|
// no JavaScript. Every echoed value goes through escapeHtml/escapeHtmlAttr.
|
|
3
3
|
|
|
4
|
-
import type
|
|
4
|
+
import { type AnonymousExtraRoute, getSessionCookies } from "@cosmicdrift/kumiko-framework/api";
|
|
5
5
|
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
6
6
|
import type { Context } from "hono";
|
|
7
7
|
import { getCookie } from "hono/cookie";
|
|
@@ -279,7 +279,6 @@ type WriteResponseBody = {
|
|
|
279
279
|
const RATE_LIMITED_CODE = "rate_limited";
|
|
280
280
|
|
|
281
281
|
const TENANT_HEADER = "x-tenant";
|
|
282
|
-
const TENANT_COOKIE = "kumiko_tenant";
|
|
283
282
|
// Exactly what the anonymous tenant resolution reads. The resolver and its
|
|
284
283
|
// trust rules still decide; the re-entry only sees what a direct /api/write
|
|
285
284
|
// from this client would see. Never the session cookie or Authorization (the
|
|
@@ -298,9 +297,10 @@ function reentryHeaders(c: Context): Headers {
|
|
|
298
297
|
const value = c.req.header(name);
|
|
299
298
|
if (value !== undefined) headers.set(name, value);
|
|
300
299
|
}
|
|
301
|
-
const
|
|
300
|
+
const tenantCookieName = getSessionCookies(c).names.tenant;
|
|
301
|
+
const tenantCookie = getCookie(c, tenantCookieName);
|
|
302
302
|
if (tenantCookie !== undefined) {
|
|
303
|
-
headers.set("cookie", `${
|
|
303
|
+
headers.set("cookie", `${tenantCookieName}=${encodeURIComponent(tenantCookie)}`);
|
|
304
304
|
}
|
|
305
305
|
return headers;
|
|
306
306
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.361.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "cap-counter exports capReservationsTable and capReservationsTableMeta",
|
|
6
|
+
"detail": "The reservation table store_cap_reservations was only reachable through internal imports, so kumiko-enterprise redeclared it in a test. The cap-counter entry now exports capReservationsTable and capReservationsTableMeta, following the feature-toggles pattern."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.358.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
nullBlindIndexesForSubject,
|
|
56
56
|
type TenantDb,
|
|
57
57
|
type WhereObject,
|
|
58
|
+
WritePurposes,
|
|
58
59
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
59
60
|
import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
|
|
60
61
|
import {
|
|
@@ -259,14 +260,14 @@ async function forgetRowAndEraseKeys(args: {
|
|
|
259
260
|
type FileFieldPlan = {
|
|
260
261
|
/** type file|image — stores the fileRef-id directly in the entity column. */
|
|
261
262
|
readonly singleFields: readonly string[];
|
|
262
|
-
/** type files|images —
|
|
263
|
+
/** type files|images — resolved via file_refs (bound by entityType/entityId/fieldName). */
|
|
263
264
|
readonly multiFields: readonly string[];
|
|
264
265
|
};
|
|
265
266
|
|
|
266
267
|
const NO_FILE_FIELDS: FileFieldPlan = { singleFields: [], multiFields: [] };
|
|
267
268
|
|
|
268
269
|
// file/image = single-column fields (fileRef-id lives directly in the entity
|
|
269
|
-
// row); files/images = plural,
|
|
270
|
+
// row); files/images = plural, resolved via file_refs. Same set
|
|
270
271
|
// as boot-validator/entity-handler.ts FILE_FIELD_TYPES and engine's
|
|
271
272
|
// isFileField — inlined here rather than importing across the package
|
|
272
273
|
// barrel since this module already needs the single/multi split, not just
|
|
@@ -285,8 +286,8 @@ function planFileFields(fields: Readonly<Record<string, unknown>>): FileFieldPla
|
|
|
285
286
|
}
|
|
286
287
|
|
|
287
288
|
// Every fileRef-id a row's file/image/files/images-fields reference: single
|
|
288
|
-
// fields hold the id in their own column, plural fields
|
|
289
|
-
//
|
|
289
|
+
// fields hold the id in their own column, plural fields resolve
|
|
290
|
+
// via file_refs (tenantId, entityType, entityId, fieldName).
|
|
290
291
|
async function collectFileRefIds(
|
|
291
292
|
db: DbRunner,
|
|
292
293
|
tenantId: TenantId,
|
|
@@ -771,7 +772,8 @@ export async function runRetentionCleanup(
|
|
|
771
772
|
proj.table,
|
|
772
773
|
{ ...where, isDeleted: false },
|
|
773
774
|
batchLimit,
|
|
774
|
-
(id) =>
|
|
775
|
+
(id) =>
|
|
776
|
+
executor.delete({ id }, systemUser, tdb, { writePurpose: WritePurposes.retention }),
|
|
775
777
|
);
|
|
776
778
|
break;
|
|
777
779
|
}
|
|
@@ -803,7 +805,10 @@ export async function runRetentionCleanup(
|
|
|
803
805
|
batchLimit,
|
|
804
806
|
targets,
|
|
805
807
|
(id, changes) =>
|
|
806
|
-
executor.update({ id, changes }, systemUser, tdb, {
|
|
808
|
+
executor.update({ id, changes }, systemUser, tdb, {
|
|
809
|
+
skipOptimisticLock: true,
|
|
810
|
+
writePurpose: WritePurposes.retention,
|
|
811
|
+
}),
|
|
807
812
|
);
|
|
808
813
|
break;
|
|
809
814
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Mirrors credit-user-data — standard tenant-scoped pattern, no name-stripping
|
|
5
5
|
// (a folder name is tenant data, not per-user PII).
|
|
6
6
|
|
|
7
|
-
import type
|
|
7
|
+
import { type EventStoreExecutor, WritePurposes } from "@cosmicdrift/kumiko-framework/db";
|
|
8
8
|
import {
|
|
9
9
|
createEntityExecutor,
|
|
10
10
|
createSystemUser,
|
|
@@ -64,7 +64,9 @@ function tenantScopedDelete(
|
|
|
64
64
|
const systemUser = createSystemUser(ctx.tenantId);
|
|
65
65
|
const rows = await ctx.db.selectMany<{ id: string }>(table, { tenantId: ctx.tenantId });
|
|
66
66
|
for (const row of rows) {
|
|
67
|
-
await executor.delete({ id: row.id }, systemUser, ctx.db
|
|
67
|
+
await executor.delete({ id: row.id }, systemUser, ctx.db, {
|
|
68
|
+
writePurpose: WritePurposes.erasure,
|
|
69
|
+
});
|
|
68
70
|
}
|
|
69
71
|
};
|
|
70
72
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// consumers without the user-data-rights pipeline don't pull a hard
|
|
4
4
|
// dependency.
|
|
5
5
|
|
|
6
|
+
import { WritePurposes } from "@cosmicdrift/kumiko-framework/db";
|
|
6
7
|
import {
|
|
7
8
|
createSystemUser,
|
|
8
9
|
type UserDataDeleteHook,
|
|
@@ -42,7 +43,9 @@ export const formDraftDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
|
42
43
|
const systemUser = createSystemUser(ctx.tenantId);
|
|
43
44
|
const failures: string[] = [];
|
|
44
45
|
for (const row of rows) {
|
|
45
|
-
const result = await formDraftExecutor.delete({ id: row.id }, systemUser, ctx.db
|
|
46
|
+
const result = await formDraftExecutor.delete({ id: row.id }, systemUser, ctx.db, {
|
|
47
|
+
writePurpose: WritePurposes.erasure,
|
|
48
|
+
});
|
|
46
49
|
if (!result.isSuccess) {
|
|
47
50
|
failures.push(`${row.id}: ${result.error.message}`);
|
|
48
51
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.360.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Finished tenant job run replaces the older one atomically",
|
|
6
|
+
"detail": "recordTenantRunOutcome wrote the new completed or failed row and pruned older finished rows of the same tenant, job and subject in two separate statements, so the store briefly held two finished rows per subject, and two runs of one subject finishing together could each miss the other's row and leave both. Both writes now run in one transaction under a per-key advisory lock (namespace tjrl). jobs:query:tenant-runs already showed only the newest finished row, so its result does not change."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.354.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -167,6 +167,8 @@ async function encryptStartedPayload(
|
|
|
167
167
|
// pg_advisory_xact_lock namespace (int4): 'tjfl' as ASCII, disjoint from the
|
|
168
168
|
// framework's other fixed advisory-lock keys.
|
|
169
169
|
const TENANT_JOB_FAILURE_LOCK_NAMESPACE = 0x746a666c;
|
|
170
|
+
// 'tjrl': serialises the finished-row write + prune per tenant run key.
|
|
171
|
+
const TENANT_JOB_RUN_LOCK_NAMESPACE = 0x746a726c;
|
|
170
172
|
|
|
171
173
|
// fw#3079 — which row the tenant-visible failure record lives in: one per
|
|
172
174
|
// (tenant, job, subject), so the next outcome of the same work replaces or
|
|
@@ -311,16 +313,27 @@ async function recordTenantRunOutcome(
|
|
|
311
313
|
// skip: retry pending, the row stays active until the final attempt
|
|
312
314
|
return;
|
|
313
315
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
316
|
+
// One transaction: a reader never sees the new finished row next to the
|
|
317
|
+
// one it replaces. The per-key lock keeps two runs of the same subject that
|
|
318
|
+
// finish together from each missing the other's uncommitted row and leaving
|
|
319
|
+
// both behind.
|
|
320
|
+
await transaction(db, async (tx) => {
|
|
321
|
+
await acquireNamespacedAdvisoryLock(
|
|
322
|
+
tx,
|
|
323
|
+
TENANT_JOB_RUN_LOCK_NAMESPACE,
|
|
324
|
+
JSON.stringify([key.tenantId, key.jobName, key.subject]),
|
|
325
|
+
);
|
|
326
|
+
await upsertOnConflict(
|
|
327
|
+
tx,
|
|
328
|
+
tenantJobRunsTable,
|
|
329
|
+
{ ...key, bullJobId, status: result, finishedAt: now, updatedAt: now },
|
|
330
|
+
{ conflictKeys: ["bullJobId"], update: { status: result, finishedAt: now, updatedAt: now } },
|
|
331
|
+
);
|
|
332
|
+
await deleteMany(tx, tenantJobRunsTable, {
|
|
333
|
+
...key,
|
|
334
|
+
status: { in: ["completed", "failed"] },
|
|
335
|
+
bullJobId: { ne: bullJobId },
|
|
336
|
+
});
|
|
324
337
|
});
|
|
325
338
|
}
|
|
326
339
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# renderer-pdf
|
|
2
|
+
|
|
3
|
+
Public `document-pdf` renderer plugin: draws a table document as PDF in pure JavaScript (`pdf-lib`, standard Helvetica fonts), no browser and no Puppeteer service. Same input gives byte-identical output.
|
|
4
|
+
|
|
5
|
+
## Pure function
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { renderTablePdf } from "@cosmicdrift/kumiko-bundled-features/renderer-pdf";
|
|
9
|
+
|
|
10
|
+
const { pdfBytes, pageCount } = await renderTablePdf(
|
|
11
|
+
{
|
|
12
|
+
title: "Prüfprotokoll",
|
|
13
|
+
meta: [{ label: "Anlage", value: "Kessel 3" }],
|
|
14
|
+
columns: [{ header: "Datum" }, { header: "Prüfer", weight: 2 }, { header: "Kosten", align: "right" }],
|
|
15
|
+
rows: [["03.02.2026", "Müller", "120,00 €"]],
|
|
16
|
+
footerText: "Kumiko GmbH",
|
|
17
|
+
pageLabel: "Seite {page} von {pages}",
|
|
18
|
+
},
|
|
19
|
+
{ format: "A4" },
|
|
20
|
+
);
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The caller passes ready-formatted strings (dates, numbers); the renderer never reads the clock. Characters Helvetica (WinAnsi) cannot encode are drawn as `?`; umlauts, `ß` and `€` are kept.
|
|
24
|
+
|
|
25
|
+
## Plugin
|
|
26
|
+
|
|
27
|
+
Mount `createRendererPdfFeature()` next to `renderer-foundation`. It serves `kind: "document-pdf"` with a `PdfTableDocument` in `payload.variables`; `options.format` and `options.marginMm` apply. Invalid variables raise `RendererError("invalid_payload")`. A Puppeteer plugin keeps precedence (`DEFAULT_PLUGIN_BY_KIND`) when mounted.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.360.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Pure-JS PDF renderer for table documents",
|
|
6
|
+
"detail": "renderTablePdf(doc, options?) renders a PdfTableDocument (title, subtitle, meta lines, weighted columns, wrapped cells, header row repeated per page, footerText and a pageLabel template with {page} and {pages}) to PDF bytes with pdf-lib. Characters outside WinAnsi are replaced by \"?\". Identical input yields identical bytes. createRendererPdfFeature() registers it as plugin \"pdf\" for kind \"document-pdf\"; payload.variables is validated against pdfTableDocumentSchema, anything else fails with RendererError invalid_payload."
|
|
7
|
+
}
|
|
8
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import {
|
|
3
|
+
RENDERER_EXTENSION,
|
|
4
|
+
RendererError,
|
|
5
|
+
type RenderRequest,
|
|
6
|
+
type RenderResponse,
|
|
7
|
+
} from "../renderer-foundation/index.js";
|
|
8
|
+
import { pdfTableDocumentSchema, renderTablePdf } from "./render-table-pdf.js";
|
|
9
|
+
|
|
10
|
+
export async function renderTablePdfRequest(req: RenderRequest): Promise<RenderResponse> {
|
|
11
|
+
if (req.kind !== "document-pdf") {
|
|
12
|
+
throw new RendererError(
|
|
13
|
+
`renderer-pdf supports only kind="document-pdf", got "${req.kind}"`,
|
|
14
|
+
"invalid_payload",
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
const parsed = pdfTableDocumentSchema.safeParse(req.payload.variables);
|
|
18
|
+
if (!parsed.success) {
|
|
19
|
+
throw new RendererError(
|
|
20
|
+
`renderer-pdf: payload.variables is not a table document: ${parsed.error.message}`,
|
|
21
|
+
"invalid_payload",
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
const { pdfBytes, pageCount } = await renderTablePdf(parsed.data, {
|
|
25
|
+
...(req.options?.format && { format: req.options.format }),
|
|
26
|
+
...(req.options?.marginMm && { marginMm: req.options.marginMm }),
|
|
27
|
+
});
|
|
28
|
+
return { kind: "document-pdf", pdfBytes, pageCount, sizeBytes: pdfBytes.byteLength };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createRendererPdfFeature(): FeatureDefinition {
|
|
32
|
+
return defineFeature("renderer-pdf", (r) => {
|
|
33
|
+
r.describe(
|
|
34
|
+
'Renderer plugin for `kind="document-pdf"` that draws a table document (title, meta lines, weighted columns, repeating header, page footer) as a PDF in pure JavaScript, without a browser or Puppeteer service. `payload.variables` must be a `PdfTableDocument`. Requires `renderer-foundation`; mount a Puppeteer-based plugin instead when you need HTML/CSS layouts.',
|
|
35
|
+
);
|
|
36
|
+
r.uiHints({
|
|
37
|
+
displayLabel: "Renderer · PDF tables",
|
|
38
|
+
category: "notifications",
|
|
39
|
+
recommended: false,
|
|
40
|
+
});
|
|
41
|
+
r.requires("renderer-foundation");
|
|
42
|
+
|
|
43
|
+
r.useExtension(RENDERER_EXTENSION, "pdf", {
|
|
44
|
+
kinds: ["document-pdf"] as const,
|
|
45
|
+
render: (req) => renderTablePdfRequest(req),
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|