@cosmicdrift/kumiko-samples 0.351.0 → 0.352.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 (49) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +2 -2
  3. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +95 -54
  4. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +247 -0
  5. package/packages/bundled-features/src/cap-counter/changes.json +28 -0
  6. package/packages/bundled-features/src/cap-counter/constants.ts +8 -0
  7. package/packages/bundled-features/src/cap-counter/feature.ts +7 -0
  8. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +5 -0
  9. package/packages/bundled-features/src/cap-counter/index.ts +9 -0
  10. package/packages/bundled-features/src/cap-counter/payload-field.ts +10 -0
  11. package/packages/bundled-features/src/cap-counter/reservation-chain.ts +51 -0
  12. package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +3 -0
  13. package/packages/bundled-features/src/cap-counter/tables.ts +53 -0
  14. package/packages/bundled-features/src/cap-counter/value-cap-guard.ts +70 -0
  15. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +63 -70
  16. package/packages/bundled-features/src/channel-discord/feature.ts +1 -0
  17. package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
  18. package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
  19. package/packages/bundled-features/src/delivery/attempt-log-retention.ts +40 -0
  20. package/packages/bundled-features/src/delivery/attempt-log.ts +26 -3
  21. package/packages/bundled-features/src/delivery/changes.json +12 -0
  22. package/packages/bundled-features/src/delivery/constants.ts +1 -0
  23. package/packages/bundled-features/src/delivery/events.ts +3 -0
  24. package/packages/bundled-features/src/delivery/feature.ts +50 -2
  25. package/packages/bundled-features/src/delivery/index.ts +6 -0
  26. package/packages/bundled-features/src/delivery/mask-recipient-address.ts +40 -0
  27. package/packages/bundled-features/src/delivery/public-names.ts +5 -0
  28. package/packages/bundled-features/src/delivery/types.ts +12 -2
  29. package/packages/bundled-features/src/derivatives-sharp/changes.json +6 -0
  30. package/packages/bundled-features/src/legal-pages/README.md +3 -3
  31. package/packages/bundled-features/src/legal-pages/changes.json +6 -0
  32. package/packages/bundled-features/src/renderer-foundation/types.ts +1 -1
  33. package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
  34. package/packages/bundled-features/src/renderer-simple/feature.ts +4 -6
  35. package/packages/bundled-features/src/renderer-simple/index.ts +1 -0
  36. package/packages/bundled-features/src/renderer-simple/mail-blocks.ts +138 -0
  37. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +33 -4
  38. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  39. package/packages/bundled-features/src/tier-engine/index.ts +7 -0
  40. package/packages/bundled-features/src/tier-engine/tier-option-gate.ts +148 -0
  41. package/packages/bundled-features/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +36 -6
  42. package/samples/apps/marketing-demo/src/app/seed-data.ts +2 -2
  43. package/samples/apps/styleguide/src/app/shell.tsx +1 -1
  44. package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +9 -3
  45. package/samples/apps/ui-walkthrough/src/app/assistant-action.tsx +6 -5
  46. package/samples/apps/ui-walkthrough/src/app/shell.tsx +3 -2
  47. package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
  48. package/samples/recipes/files-post-processing/package.json +1 -1
  49. package/samples/recipes/seed-migration/README.md +13 -1
@@ -0,0 +1,51 @@
1
+ import type {
2
+ PreTransactionReservation,
3
+ ReservationHandle,
4
+ } from "@cosmicdrift/kumiko-types/handlers";
5
+
6
+ type ReservationOutcome = Awaited<ReturnType<PreTransactionReservation>>;
7
+
8
+ function toHandle(outcome: ReservationOutcome): ReservationHandle {
9
+ if (outcome === undefined)
10
+ return { release: async () => {}, confirmInTransaction: async () => {} };
11
+ if (typeof outcome === "function") {
12
+ return { release: outcome, confirmInTransaction: async () => {} };
13
+ }
14
+ return outcome;
15
+ }
16
+
17
+ // Runs `inner` first, then `own`; a failing `own` gives the inner reservation back before it
18
+ // throws. Release goes in reverse order, confirm covers both.
19
+ export function chainReservations(
20
+ inner: PreTransactionReservation | undefined,
21
+ own: PreTransactionReservation,
22
+ ): PreTransactionReservation {
23
+ if (!inner) return own;
24
+ return async (event, ctx) => {
25
+ const innerHandle = toHandle(await inner(event, ctx));
26
+ let ownHandle: ReservationHandle;
27
+ try {
28
+ ownHandle = toHandle(await own(event, ctx));
29
+ } catch (error) {
30
+ try {
31
+ await innerHandle.release();
32
+ } catch (releaseError) {
33
+ ctx.log?.error("releasing the inner cap reservation failed", { error: releaseError });
34
+ }
35
+ throw error;
36
+ }
37
+ return {
38
+ release: async () => {
39
+ try {
40
+ await ownHandle.release();
41
+ } finally {
42
+ await innerHandle.release();
43
+ }
44
+ },
45
+ confirmInTransaction: async (confirm) => {
46
+ await innerHandle.confirmInTransaction(confirm);
47
+ await ownHandle.confirmInTransaction(confirm);
48
+ },
49
+ };
50
+ };
51
+ }
@@ -24,6 +24,9 @@ export type CapLimitContext = {
24
24
  readonly config?: ConfigAccessor;
25
25
  };
26
26
 
27
+ // What the cap guards need from a write handler's ctx.
28
+ export type CapGuardContext = CapLimitContext & { readonly db: TenantDb };
29
+
27
30
  export type StockCapSpec<TCaps> = {
28
31
  readonly table: SchemaTable | EntityTableMeta;
29
32
  readonly limit: (caps: TCaps) => number;
@@ -0,0 +1,53 @@
1
+ import {
2
+ defineUnmanagedTable,
3
+ type EntityTableMeta,
4
+ index,
5
+ instant,
6
+ integer,
7
+ table as pgTable,
8
+ text,
9
+ uuid,
10
+ } from "@cosmicdrift/kumiko-framework/db";
11
+
12
+ const CAP_RESERVATIONS_TENANT_CAP_EXPIRES_INDEX =
13
+ "store_cap_reservations_tenant_id_cap_name_expires_at_idx";
14
+
15
+ // One row per capacity that was booked before a handler transaction and is not settled yet. The
16
+ // handler transaction deletes it on its own commit (confirm); a release or the TTL sweep deletes it
17
+ // together with the counter decrement. Unmanaged: the rows are a coordination aid, not state with
18
+ // an event history.
19
+ export const capReservationsTable = pgTable(
20
+ "store_cap_reservations",
21
+ {
22
+ id: uuid("id").primaryKey(),
23
+ tenantId: uuid("tenant_id").notNull(),
24
+ capName: text("cap_name").notNull(),
25
+ kind: text("kind").notNull().$type<"calendar" | "rolling">(),
26
+ // Calendar only: raw string the counter's aggregate id was derived from; a normalized instant could point at another counter.
27
+ periodStartIso: text("period_start_iso"),
28
+ amount: integer("amount").notNull(),
29
+ expiresAt: instant("expires_at").notNull(),
30
+ createdAt: instant("created_at").notNull(),
31
+ },
32
+ (t) => [index(CAP_RESERVATIONS_TENANT_CAP_EXPIRES_INDEX).on(t.tenantId, t.capName, t.expiresAt)],
33
+ );
34
+
35
+ export const capReservationsTableMeta: EntityTableMeta = defineUnmanagedTable({
36
+ tableName: "store_cap_reservations",
37
+ indexes: [
38
+ {
39
+ name: CAP_RESERVATIONS_TENANT_CAP_EXPIRES_INDEX,
40
+ columns: ["tenant_id", "cap_name", "expires_at"],
41
+ },
42
+ ],
43
+ columns: [
44
+ { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
45
+ { name: "tenant_id", pgType: "uuid", notNull: true },
46
+ { name: "cap_name", pgType: "text", notNull: true },
47
+ { name: "kind", pgType: "text", notNull: true },
48
+ { name: "period_start_iso", pgType: "text", notNull: false },
49
+ { name: "amount", pgType: "integer", notNull: true },
50
+ { name: "expires_at", pgType: "timestamptz", notNull: true },
51
+ { name: "created_at", pgType: "timestamptz", notNull: true },
52
+ ],
53
+ });
@@ -0,0 +1,70 @@
1
+ // Per-tier bounds on a single written number (e.g. a retention period or a page size),
2
+ // next to the stock-cap guard, which bounds how many rows may exist.
3
+
4
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
5
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
6
+ import {
7
+ UnprocessableError,
8
+ type WriteFailure,
9
+ writeFailure,
10
+ } from "@cosmicdrift/kumiko-framework/errors";
11
+ import { readPayloadField } from "./payload-field.js";
12
+ import type { CapGuardContext, CapLimitContext } from "./stock-cap-guard.js";
13
+
14
+ export type ValueCapSpec<TCaps> = {
15
+ readonly field: string;
16
+ /** `undefined` = no lower bound for this tier. */
17
+ readonly min?: (caps: TCaps) => number | undefined;
18
+ /** `undefined` = no upper bound for this tier. */
19
+ readonly max?: (caps: TCaps) => number | undefined;
20
+ readonly code: string;
21
+ readonly i18nKey: string;
22
+ };
23
+
24
+ export type ValueCapGuard<TCaps> = {
25
+ readonly checkValueCap: (
26
+ ctx: CapGuardContext,
27
+ value: unknown,
28
+ spec: ValueCapSpec<TCaps>,
29
+ ) => Promise<WriteFailure | null>;
30
+ readonly withValueCap: (handler: WriteHandlerDef, spec: ValueCapSpec<TCaps>) => WriteHandlerDef;
31
+ };
32
+
33
+ function isOutOfBounds(value: number, min: number | undefined, max: number | undefined): boolean {
34
+ return (min !== undefined && value < min) || (max !== undefined && value > max);
35
+ }
36
+
37
+ export function createValueCapGuard<TCaps>(
38
+ resolveTierCaps: (db: TenantDb, context: CapLimitContext) => Promise<TCaps>,
39
+ ): ValueCapGuard<TCaps> {
40
+ async function checkValueCap(
41
+ ctx: CapGuardContext,
42
+ value: unknown,
43
+ spec: ValueCapSpec<TCaps>,
44
+ ): Promise<WriteFailure | null> {
45
+ // skip: an absent or non-numeric value is the schema's business, not a cap violation.
46
+ if (typeof value !== "number") return null;
47
+ const caps = await resolveTierCaps(ctx.db, ctx.config ? { config: ctx.config } : {});
48
+ const min = spec.min?.(caps);
49
+ const max = spec.max?.(caps);
50
+ if (!isOutOfBounds(value, min, max)) return null;
51
+ return writeFailure(
52
+ new UnprocessableError(spec.code, {
53
+ i18nKey: spec.i18nKey,
54
+ details: { field: spec.field, value, min, max },
55
+ }),
56
+ );
57
+ }
58
+
59
+ function withValueCap(handler: WriteHandlerDef, spec: ValueCapSpec<TCaps>): WriteHandlerDef {
60
+ return {
61
+ ...handler,
62
+ handler: async (event, ctx) => {
63
+ const failure = await checkValueCap(ctx, readPayloadField(event.payload, spec.field), spec);
64
+ return failure ?? handler.handler(event, ctx);
65
+ },
66
+ };
67
+ }
68
+
69
+ return { checkValueCap, withValueCap };
70
+ }
@@ -6,19 +6,20 @@
6
6
  // the wrapper keeps the pattern explicit and co-located.
7
7
  //
8
8
  // **Calendar reservation:** the wrapper only declares the cap; the dispatcher runs the
9
- // reservation (reserveBeforeTransaction) before the handler transaction opens, in its own short
10
- // committed write, and releases it after that transaction ended without committing (rollback,
11
- // failure result, failed COMMIT). No connection is held across the handler, so capped requests
12
- // cannot exhaust the pool, and the counter stream is not locked while the handler runs. If a
13
- // COMMIT fails with an unknown outcome the release can under-count; a wrapped handler must not
14
- // book the same counter itself. Reached through a nested ctx.write the cap is not reserved, so
15
- // the dispatcher rejects that call unless the top-level batch reserved the same handler.
9
+ // reservation (reserveBeforeTransaction) after all pre-handler gates and before the handler
10
+ // transaction opens, in its own short committed write (counter increment plus a
11
+ // store_cap_reservations row). The handler transaction deletes the row on its own commit; a release
12
+ // after a rollback, failure result or failed COMMIT only gives back what is still booked, so an
13
+ // unknown COMMIT outcome cannot under-count. No connection is held across the handler. Rows a
14
+ // crashed process left behind expire after CAP_RESERVATION_TTL_MINUTES and are given back before the
15
+ // next reserve of the same cap. Reached through a nested ctx.write the cap is not reserved, so the
16
+ // dispatcher rejects that call unless the top-level batch reserved the same handler.
16
17
  //
17
- // Rolling booking still dispatches the SystemAdmin-only increment-rolling
18
- // handler and has no reservation: it would need a compensating event type,
19
- // a changed readRollingCapUsage and a version-guarded append behind the
20
- // SystemAdmin dispatch. Rolling callers need a SystemAdmin identity until
21
- // cap-counter declares an explicit foreign-booking opt-in.
18
+ // The wrapper spreads the wrapped handler and chains its reserveBeforeTransaction: inner reserves
19
+ // first, release runs in reverse, and an outer failure gives the inner reservation back.
20
+ //
21
+ // Rolling caps reserve the same way: a version-guarded `rolling-incremented` append plus a
22
+ // reservation row, undone by a `rolling-released` event. The window sum is incremented minus released.
22
23
  //
23
24
  // No automatic markSoftWarned here — that's inside enforceCapAndMaybeNotify
24
25
  // (enforce-cap.ts).
@@ -28,9 +29,11 @@ import type {
28
29
  WriteEvent,
29
30
  WriteHandlerDef,
30
31
  } from "@cosmicdrift/kumiko-framework/engine";
31
- import { reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
32
- import { bookCapUsage, releaseCapUsage } from "./book-cap-usage.js";
33
- import { CapCounterHandlers } from "./constants.js";
32
+ import {
33
+ releaseExpiredCapReservations,
34
+ reserveCalendarCap,
35
+ reserveRollingCap,
36
+ } from "./cap-reservation.js";
34
37
  import {
35
38
  assertBelowHardCap,
36
39
  type CapToleranceProfileName,
@@ -38,6 +41,7 @@ import {
38
41
  enforceRollingCapAndMaybeNotify,
39
42
  type SoftHitNotifier,
40
43
  } from "./enforce-cap.js";
44
+ import { chainReservations } from "./reservation-chain.js";
41
45
 
42
46
  // =============================================================================
43
47
  // Calendar-Period-Wrapper
@@ -85,39 +89,31 @@ export function withCapEnforcement(
85
89
  ): WriteHandlerDef {
86
90
  return {
87
91
  ...handler,
88
- reserveBeforeTransaction: async (event, ctx) => {
89
- const cap = await capResolver(event, ctx);
90
-
91
- await enforceCapAndMaybeNotify(ctx, {
92
- capName: cap.capName,
93
- periodStartIso: cap.periodStartIso,
94
- limit: cap.limit,
95
- profile: cap.profile,
96
- notify: cap.notify,
97
- markSoftWarnedOutsideTransaction: true,
98
- ...(cap.amount !== undefined && { amount: cap.amount }),
99
- });
92
+ reserveBeforeTransaction: chainReservations(
93
+ handler.reserveBeforeTransaction,
94
+ async (event, ctx) => {
95
+ const cap = await capResolver(event, ctx);
100
96
 
101
- const amount = cap.amount ?? 1;
102
- const reserved = await bookCapUsage(ctx, {
103
- capName: cap.capName,
104
- amount,
105
- periodStartIso: cap.periodStartIso,
106
- outsideTransaction: true,
107
- guardCurrentValue: (currentValue) => assertBelowHardCap(currentValue + amount - 1, cap),
108
- });
109
- if (!reserved.isSuccess) throw reraiseAsKumikoError(reserved.error);
97
+ await releaseExpiredCapReservations(ctx, cap.capName);
98
+ await enforceCapAndMaybeNotify(ctx, {
99
+ capName: cap.capName,
100
+ periodStartIso: cap.periodStartIso,
101
+ limit: cap.limit,
102
+ profile: cap.profile,
103
+ notify: cap.notify,
104
+ markSoftWarnedOutsideTransaction: true,
105
+ ...(cap.amount !== undefined && { amount: cap.amount }),
106
+ });
110
107
 
111
- return async () => {
112
- const released = await releaseCapUsage(ctx, {
108
+ const amount = cap.amount ?? 1;
109
+ return reserveCalendarCap(ctx, {
113
110
  capName: cap.capName,
114
111
  amount,
115
112
  periodStartIso: cap.periodStartIso,
116
- outsideTransaction: true,
113
+ guardCurrentValue: (currentValue) => assertBelowHardCap(currentValue + amount - 1, cap),
117
114
  });
118
- if (!released.isSuccess) throw reraiseAsKumikoError(released.error);
119
- };
120
- },
115
+ },
116
+ ),
121
117
  };
122
118
  }
123
119
 
@@ -142,44 +138,41 @@ export type RollingCapResolver = (
142
138
  /**
143
139
  * Wrap a write-handler with rolling-window cap-enforcement.
144
140
  *
145
- * Same flow as `withCapEnforcement` but uses
146
- * `enforceRollingCapAndMaybeNotify` + dispatches
147
- * `cap-counter:write:increment-rolling` post-success.
141
+ * Same flow as `withCapEnforcement` but uses `enforceRollingCapAndMaybeNotify` and reserves by
142
+ * appending a `rolling-incremented` event before the handler transaction; a release appends
143
+ * `rolling-released`.
148
144
  *
149
- * **Notification-Storm-Caveat:** rolling-counter trackt KEIN
150
- * lastSoftWarnedAt — der Notifier feuert bei JEDEM Call solange
151
- * der counter im soft-Bereich ist. Caller sollte einen TTL-Cache
152
- * (`Map<capName, lastNotifiedAt>`) im notify-callback einbauen.
145
+ * Rolling counters track no lastSoftWarnedAt, so the notifier fires on every call while usage
146
+ * sits in the soft range; throttle inside the notify callback (e.g. a per-capName TTL cache).
153
147
  */
154
148
  export function withRollingCapEnforcement(
155
149
  handler: WriteHandlerDef,
156
150
  capResolver: RollingCapResolver,
157
151
  ): WriteHandlerDef {
158
152
  return {
159
- name: handler.name,
160
- schema: handler.schema,
161
- access: handler.access,
162
- handler: async (event, ctx) => {
163
- const cap = await capResolver(event, ctx);
164
-
165
- await enforceRollingCapAndMaybeNotify(ctx, {
166
- capName: cap.capName,
167
- windowDays: cap.windowDays,
168
- limit: cap.limit,
169
- profile: cap.profile,
170
- notify: cap.notify,
171
- });
172
-
173
- const result = await handler.handler(event, ctx);
153
+ ...handler,
154
+ reserveBeforeTransaction: chainReservations(
155
+ handler.reserveBeforeTransaction,
156
+ async (event, ctx) => {
157
+ const cap = await capResolver(event, ctx);
174
158
 
175
- if (result.isSuccess) {
176
- await ctx.write(CapCounterHandlers.incrementRolling, {
159
+ await releaseExpiredCapReservations(ctx, cap.capName);
160
+ await enforceRollingCapAndMaybeNotify(ctx, {
177
161
  capName: cap.capName,
178
- amount: cap.amount ?? 1,
162
+ windowDays: cap.windowDays,
163
+ limit: cap.limit,
164
+ profile: cap.profile,
165
+ notify: cap.notify,
179
166
  });
180
- }
181
167
 
182
- return result;
183
- },
168
+ const amount = cap.amount ?? 1;
169
+ return reserveRollingCap(ctx, {
170
+ capName: cap.capName,
171
+ windowDays: cap.windowDays,
172
+ amount,
173
+ guardCurrentValue: (usage) => assertBelowHardCap(usage + amount - 1, cap),
174
+ });
175
+ },
176
+ ),
184
177
  };
185
178
  }
@@ -50,6 +50,7 @@ export function createChannelDiscordFeature(
50
50
  r.translations({ keys: { "delivery.channel.discord": { en: "Discord" } } });
51
51
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "discord", {
52
52
  mode: channel.mode,
53
+ addressKind: "connection-name",
53
54
  send: channel.send,
54
55
  });
55
56
  });
@@ -44,6 +44,7 @@ export function createChannelSlackFeature(options: SlackChannelOptions = {}): Fe
44
44
  r.translations({ keys: { "delivery.channel.slack": { en: "Slack" } } });
45
45
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "slack", {
46
46
  mode: channel.mode,
47
+ addressKind: "connection-name",
47
48
  send: channel.send,
48
49
  });
49
50
  });
@@ -48,6 +48,7 @@ export function createChannelTeamsFeature(options: TeamsChannelOptions = {}): Fe
48
48
  r.translations({ keys: { "delivery.channel.teams": { en: "Microsoft Teams" } } });
49
49
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "teams", {
50
50
  mode: channel.mode,
51
+ addressKind: "connection-name",
51
52
  send: channel.send,
52
53
  });
53
54
  });
@@ -0,0 +1,40 @@
1
+ import { deleteManyBatched } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
+ import { type PruneEventsOptions, pruneEvents } from "@cosmicdrift/kumiko-framework/pipeline";
4
+ import { deliveryAttemptsTable } from "./tables.js";
5
+
6
+ export const DEFAULT_ATTEMPT_LOG_RETENTION_DAYS = 90;
7
+ const ROW_DELETE_BATCH_SIZE = 1000;
8
+
9
+ export type AttemptLogRetentionOptions = Pick<PruneEventsOptions, "olderThan" | "olderThanDays">;
10
+
11
+ export function resolveAttemptLogRetentionDays(
12
+ option: number | false | undefined,
13
+ ): number | undefined {
14
+ if (option === false) return undefined;
15
+ const days = option ?? DEFAULT_ATTEMPT_LOG_RETENTION_DAYS;
16
+ if (!Number.isInteger(days) || days <= 0) {
17
+ throw new Error(
18
+ `delivery: attemptLogRetentionDays must be a positive integer or false, got ${String(option)}`,
19
+ );
20
+ }
21
+ return days;
22
+ }
23
+
24
+ // Prunes the attempt events first and then the projection rows older than the same cutoff: a
25
+ // rebuild would not recreate rows whose events are gone, so they would otherwise stay forever.
26
+ // A lagging event consumer makes pruneEvents throw ConsumerLagError before anything is deleted;
27
+ // the caller retries on its next run.
28
+ export async function runAttemptLogRetention(
29
+ db: DbConnection,
30
+ options: AttemptLogRetentionOptions,
31
+ ): Promise<{ readonly deletedEvents: number; readonly deletedRows: number }> {
32
+ const pruned = await pruneEvents(db, { ...options, aggregateTypes: ["deliveryAttempt"] });
33
+ const rows = await deleteManyBatched(
34
+ db,
35
+ deliveryAttemptsTable,
36
+ { createdAt: { lt: pruned.cutoff } },
37
+ { limit: ROW_DELETE_BATCH_SIZE },
38
+ );
39
+ return { deletedEvents: pruned.deletedCount, deletedRows: rows.deleted };
40
+ }
@@ -8,9 +8,28 @@ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { append, getStreamVersion } from "@cosmicdrift/kumiko-framework/event-store";
9
9
  import { runProjectionsForEvent } from "@cosmicdrift/kumiko-framework/pipeline";
10
10
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
11
- import { DELIVERY_ATTEMPT_EVENT } from "./constants.js";
11
+ import { DELIVERY_ATTEMPT_EVENT, DELIVERY_CHANNEL_EXTENSION } from "./constants.js";
12
12
  import { deliveryAttemptSchema } from "./events.js";
13
- import type { DeliveryLogEntry } from "./types.js";
13
+ import { maskRecipientAddress } from "./mask-recipient-address.js";
14
+ import { type DeliveryLogEntry, isDeliveryChannelPlugin } from "./types.js";
15
+
16
+ // Unknown channels count as personal: masking is the fail-closed default.
17
+ function addressIsConnectionName(registry: Registry, channelName: string): boolean {
18
+ return registry
19
+ .getExtensionUsages(DELIVERY_CHANNEL_EXTENSION)
20
+ .some(
21
+ (usage) =>
22
+ usage.entityName === channelName &&
23
+ isDeliveryChannelPlugin(usage.options) &&
24
+ usage.options.addressKind === "connection-name",
25
+ );
26
+ }
27
+
28
+ function loggedAddress(registry: Registry, entry: DeliveryLogEntry): string | null {
29
+ return addressIsConnectionName(registry, entry.channel)
30
+ ? entry.recipientAddress
31
+ : maskRecipientAddress(entry.recipientAddress);
32
+ }
14
33
 
15
34
  // Shared append + inline-projection write (low-level append() does not
16
35
  // auto-fire projections — only the dispatcher/executor paths do).
@@ -22,10 +41,14 @@ async function writeAttemptEvent(
22
41
  entry: DeliveryLogEntry,
23
42
  ): Promise<void> {
24
43
  const { tenantId, ...rest } = entry;
44
+ // Masked here, the only place attempt events are written, so no event or projection row ever holds the full address.
25
45
  // Schema-parse to match ctx.appendEvent's guarantee: a payload drift between
26
46
  // service/job + feature-registration fails loudly here instead of landing on
27
47
  // the events-table and crashing a consumer later.
28
- const payload = deliveryAttemptSchema.parse(rest);
48
+ const payload = deliveryAttemptSchema.parse({
49
+ ...rest,
50
+ recipientAddress: loggedAddress(registry, entry),
51
+ });
29
52
  const stored = await append(db, {
30
53
  aggregateId: attemptId,
31
54
  aggregateType: "deliveryAttempt",
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.352.0",
4
+ "type": "breaking",
5
+ "title": "Delivery attempt log stores masked recipient addresses; attempts older than 90 days are pruned",
6
+ "detail": "The log query, the export and the stored events show masked addresses, and the feature registers the daily attempt-log-retention job.",
7
+ "migration": "Read recipients from your own data, not from the attempt log, because it only shows masked addresses now. Set `attemptLogRetentionDays` on createDeliveryFeature to another number of days, or to `false` to keep the log, if 90 days does not fit. Schedule nothing extra, the feature registers the cron job itself. Existing full-address entries are removed by the retention job once they are older than the window, or erased when their user is forgotten."
8
+ },
9
+ {
10
+ "version": "0.352.0",
11
+ "type": "improvement",
12
+ "title": "Forgetting a user erases the recipient address of their delivery attempts in plaintext mode too, through an event that survives projection rebuilds"
13
+ },
2
14
  {
3
15
  "version": "0.343.0",
4
16
  "type": "improvement",
@@ -7,6 +7,7 @@ import { DELIVERY_FEATURE, DeliveryJobNames } from "./public-names.js";
7
7
  export const DELIVERY_CHANNEL_EXTENSION = "deliveryChannel" as const;
8
8
 
9
9
  export {
10
+ DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT,
10
11
  DELIVERY_ATTEMPT_EVENT,
11
12
  DELIVERY_CHANNEL_CELL_COMPONENT,
12
13
  DELIVERY_ERROR_CELL_COMPONENT,
@@ -36,3 +36,6 @@ export const deliveryAttemptSchema = z.object({
36
36
  });
37
37
 
38
38
  export type DeliveryAttemptPayload = z.infer<typeof deliveryAttemptSchema>;
39
+
40
+ // No payload: the event only marks the attempt row's address as erased.
41
+ export const deliveryAttemptAddressErasedSchema = z.object({});
@@ -1,12 +1,16 @@
1
- import { upsertByPk } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { updateMany, upsertByPk } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
3
  import {
3
4
  access,
4
5
  defineFeature,
5
6
  type FeatureDefinition,
6
7
  i18nKey,
7
8
  } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { ConsumerLagError } from "@cosmicdrift/kumiko-framework/pipeline";
8
10
  import type * as z from "zod";
11
+ import { resolveAttemptLogRetentionDays, runAttemptLogRetention } from "./attempt-log-retention.js";
9
12
  import {
13
+ DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT,
10
14
  DELIVERY_ATTEMPT_EVENT,
11
15
  DELIVERY_CHANNEL_CELL_COMPONENT,
12
16
  DELIVERY_CHANNEL_EXTENSION,
@@ -18,7 +22,7 @@ import {
18
22
  DeliveryJobNames,
19
23
  DeliveryQueries,
20
24
  } from "./constants.js";
21
- import { deliveryAttemptSchema } from "./events.js";
25
+ import { deliveryAttemptAddressErasedSchema, deliveryAttemptSchema } from "./events.js";
22
26
  import { logQuery } from "./handlers/log.query.js";
23
27
  import { preferencesQuery } from "./handlers/preferences.query.js";
24
28
  import { resubscribeAddressWrite } from "./handlers/resubscribe-address.write.js";
@@ -44,10 +48,17 @@ export type DeliveryFeatureOptions = {
44
48
  // who in a line-of-business app is the operator, not the platform — pass
45
49
  // access.systemAdmin to keep this platform-only and out of their nav.
46
50
  readonly access?: readonly string[];
51
+ // Attempt events and log rows older than this many days are pruned by a daily job. Default 90;
52
+ // `false` keeps the log forever.
53
+ readonly attemptLogRetentionDays?: number | false;
47
54
  };
48
55
 
56
+ const ATTEMPT_LOG_RETENTION_ESCAPE_HATCH_REASON =
57
+ "prunes delivery attempt events and log rows of every tenant";
58
+
49
59
  export function createDeliveryFeature(options?: DeliveryFeatureOptions): FeatureDefinition {
50
60
  const resolvedAccess = options?.access ?? access.admin;
61
+ const attemptLogRetentionDays = resolveAttemptLogRetentionDays(options?.attemptLogRetentionDays);
51
62
  return defineFeature("delivery", (r) => {
52
63
  r.describe(
53
64
  "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route.",
@@ -92,6 +103,12 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
92
103
  },
93
104
  });
94
105
 
106
+ // Written when the recipient is forgotten: clears the address on the projection row and, being
107
+ // an event, survives a projection rebuild (a plain UPDATE would be undone by the replay).
108
+ r.defineEvent("attempt-address-erased", deliveryAttemptAddressErasedSchema, {
109
+ piiFields: "none",
110
+ });
111
+
95
112
  // Inline projection that materialises every delivery attempt into
96
113
  // deliveryAttemptsTable. Runs in the SAME transaction as the low-level
97
114
  // append(), so callers see their write immediately — no dispatcher
@@ -132,6 +149,14 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
132
149
  },
133
150
  );
134
151
  },
152
+ [DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT]: async (event, tx) => {
153
+ await updateMany(
154
+ tx,
155
+ deliveryAttemptsTable,
156
+ { recipientAddress: null },
157
+ { id: event.aggregateId },
158
+ );
159
+ },
135
160
  },
136
161
  });
137
162
 
@@ -160,6 +185,29 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
160
185
  deliverySendJob,
161
186
  );
162
187
 
188
+ if (attemptLogRetentionDays !== undefined) {
189
+ r.job({
190
+ name: DeliveryJobNames.attemptLogRetention,
191
+ trigger: { cron: "15 3 * * *" },
192
+ concurrency: "skip",
193
+ escapeHatch: { reason: ATTEMPT_LOG_RETENTION_ESCAPE_HATCH_REASON },
194
+ handler: async (_payload, ctx) => {
195
+ const db = ctx.db.unsafeRaw() as DbConnection; // @cast-boundary db-operator — jobs never run inside a DbTx
196
+ try {
197
+ const pruned = await runAttemptLogRetention(db, {
198
+ olderThanDays: attemptLogRetentionDays,
199
+ });
200
+ ctx.log.info("delivery attempt log pruned", pruned);
201
+ } catch (error) {
202
+ if (!(error instanceof ConsumerLagError)) throw error;
203
+ ctx.log.warn("delivery attempt log retention postponed: an event consumer lags", {
204
+ consumer: error.laggingConsumer,
205
+ });
206
+ }
207
+ },
208
+ });
209
+ }
210
+
163
211
  const handlers = {
164
212
  setPreference: r.writeHandler(setPreferenceWrite),
165
213
  unsubscribeAddress: r.writeHandler(unsubscribeAddressWrite),
@@ -1,4 +1,9 @@
1
1
  export { hashUnsubscribeAddress } from "./address-opt-out.js";
2
+ export {
3
+ type AttemptLogRetentionOptions,
4
+ DEFAULT_ATTEMPT_LOG_RETENTION_DAYS,
5
+ runAttemptLogRetention,
6
+ } from "./attempt-log-retention.js";
2
7
  export {
3
8
  type ChatWebhookChannelOptions,
4
9
  chatMessageText,
@@ -21,6 +26,7 @@ export {
21
26
  } from "./chat-webhook-sender.js";
22
27
  export type { DeliveryStatusValue } from "./constants.js";
23
28
  export {
29
+ DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT,
24
30
  DELIVERY_CHANNEL_EXTENSION,
25
31
  DELIVERY_FEATURE,
26
32
  DELIVERY_LOG_SCREEN_ID,