@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.
Files changed (51) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +9 -2
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/handlers/details.query.ts +2 -1
  5. package/packages/bundled-features/src/audit/handlers/list.query.ts +4 -5
  6. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +6 -0
  7. package/packages/bundled-features/src/auth-email-password/web/session.tsx +39 -3
  8. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +4 -4
  9. package/packages/bundled-features/src/cap-counter/changes.json +6 -0
  10. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  11. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +11 -6
  12. package/packages/bundled-features/src/folders-user-data/hooks.ts +4 -2
  13. package/packages/bundled-features/src/form-draft-user-data/hooks.ts +4 -1
  14. package/packages/bundled-features/src/jobs/changes.json +6 -0
  15. package/packages/bundled-features/src/jobs/job-run-logger.ts +23 -10
  16. package/packages/bundled-features/src/renderer-pdf/README.md +27 -0
  17. package/packages/bundled-features/src/renderer-pdf/changes.json +8 -0
  18. package/packages/bundled-features/src/renderer-pdf/feature.ts +48 -0
  19. package/packages/bundled-features/src/renderer-pdf/index.ts +9 -0
  20. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +433 -0
  21. package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +2 -2
  22. package/packages/bundled-features/src/shared/index.ts +1 -0
  23. package/packages/bundled-features/src/shared/redact-pii-ciphertext.ts +16 -0
  24. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +7 -2
  25. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +2 -1
  26. package/packages/bundled-features/src/user/changes.json +6 -0
  27. package/packages/bundled-features/src/user/handlers/me.query.ts +5 -1
  28. package/packages/bundled-features/src/user-data-rights/handlers/my-audit-log.query.ts +2 -1
  29. package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +6 -1
  30. package/packages/bundled-features/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +3 -2
  31. package/packages/bundled-features/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +3 -3
  32. package/packages/bundled-features/src/user-profile/changes.json +6 -0
  33. package/packages/bundled-features/src/user-profile/feature.ts +21 -11
  34. package/packages/bundled-features/src/user-profile/index.ts +1 -1
  35. package/samples/apps/styleguide/src/features/examples/feature.ts +52 -3
  36. package/samples/apps/styleguide/src/features/examples/i18n.ts +3 -0
  37. package/samples/apps/ui-walkthrough/src/app/seed-rental.ts +3 -2
  38. package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +11 -2
  39. package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +17 -5
  40. package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +12 -1
  41. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +46 -1
  42. package/samples/apps/ui-walkthrough/src/features/vehicles/feature.ts +2 -0
  43. package/samples/apps/ui-walkthrough/src/features/vehicles/i18n.ts +3 -1
  44. package/samples/apps/ui-walkthrough/src/features/vehicles/screens.ts +18 -0
  45. package/samples/apps/use-all-bundled/feature-manifest.json +28 -2
  46. package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
  47. package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
  48. package/samples/recipes/files-post-processing/src/feature.ts +11 -1
  49. package/samples/recipes/pdf-audit-export/README.md +22 -0
  50. package/samples/recipes/pdf-audit-export/package.json +12 -0
  51. 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.359.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.359.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
- // Sensitive field-values are ciphertext inside the event payload (the log
13
- // carries them encrypted); stripSensitive only strips the event echo. This
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 { useSessionEndedSignal } from "@cosmicdrift/kumiko-renderer";
14
- import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
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
- return <SessionContext.Provider value={api}>{children}</SessionContext.Provider>;
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 {
@@ -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 { AnonymousExtraRoute } from "@cosmicdrift/kumiko-framework/api";
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 tenantCookie = getCookie(c, TENANT_COOKIE);
300
+ const tenantCookieName = getSessionCookies(c).names.tenant;
301
+ const tenantCookie = getCookie(c, tenantCookieName);
302
302
  if (tenantCookie !== undefined) {
303
- headers.set("cookie", `${TENANT_COOKIE}=${encodeURIComponent(tenantCookie)}`);
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",
@@ -49,6 +49,7 @@ export {
49
49
  type StockCapGuard,
50
50
  type StockCapSpec,
51
51
  } from "./stock-cap-guard.js";
52
+ export { capReservationsTable, capReservationsTableMeta } from "./tables.js";
52
53
  export {
53
54
  createValueCapGuard,
54
55
  type ValueCapGuard,
@@ -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 — no entity column, resolved via file_refs. */
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, no column, resolved via file_refs. Same set
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 have no column and
289
- // resolve via file_refs (tenantId, entityType, entityId, fieldName).
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) => executor.delete({ id }, systemUser, tdb),
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, { skipOptimisticLock: true }),
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 { EventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
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
- await upsertOnConflict(
315
- db,
316
- tenantJobRunsTable,
317
- { ...key, bullJobId, status: result, finishedAt: now, updatedAt: now },
318
- { conflictKeys: ["bullJobId"], update: { status: result, finishedAt: now, updatedAt: now } },
319
- );
320
- await deleteMany(db, tenantJobRunsTable, {
321
- ...key,
322
- status: { in: ["completed", "failed"] },
323
- bullJobId: { ne: bullJobId },
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
+ }
@@ -0,0 +1,9 @@
1
+ export { createRendererPdfFeature } from "./feature.js";
2
+ export {
3
+ type PdfTableColumn,
4
+ type PdfTableDocument,
5
+ type PdfTableOptions,
6
+ type PdfTableResult,
7
+ pdfTableDocumentSchema,
8
+ renderTablePdf,
9
+ } from "./render-table-pdf.js";