@cosmicdrift/kumiko-samples 0.353.0 → 0.354.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 (137) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +18 -2
  3. package/packages/bundled-features/src/agent-tools/agent-doc-lint.ts +66 -0
  4. package/packages/bundled-features/src/agent-tools/agent-manifest.ts +11 -5
  5. package/packages/bundled-features/src/agent-tools/feature.ts +13 -2
  6. package/packages/bundled-features/src/agent-tools/index.ts +6 -1
  7. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +1 -1
  8. package/packages/bundled-features/src/auth-email-password/changes.json +24 -0
  9. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +1 -0
  10. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -2
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +3 -2
  12. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +21 -0
  13. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -1
  14. package/packages/bundled-features/src/auth-email-password/handlers/login.write.ts +1 -0
  15. package/packages/bundled-features/src/auth-email-password/handlers/reset-password.write.ts +1 -0
  16. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +5 -0
  17. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +53 -96
  18. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  19. package/packages/bundled-features/src/auth-email-password/handlers/verify-email.write.ts +1 -0
  20. package/packages/bundled-features/src/auth-email-password/index.ts +12 -0
  21. package/packages/bundled-features/src/auth-email-password/seeding.ts +38 -7
  22. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +83 -0
  23. package/packages/bundled-features/src/auth-email-password/signup-token-store.ts +9 -3
  24. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +7 -7
  25. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +15 -0
  26. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +2 -2
  27. package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
  28. package/packages/bundled-features/src/auth-mfa/changes.json +7 -0
  29. package/packages/bundled-features/src/auth-mfa/feature.ts +5 -0
  30. package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -0
  31. package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -0
  32. package/packages/bundled-features/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +6 -8
  33. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +18 -21
  34. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +2 -2
  35. package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +4 -4
  36. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  37. package/packages/bundled-features/src/channel-in-app/changes.json +8 -1
  38. package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +1 -1
  39. package/packages/bundled-features/src/channel-in-app/tables.ts +18 -12
  40. package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
  41. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +154 -69
  42. package/packages/bundled-features/src/delivery/changes.json +6 -0
  43. package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +18 -12
  44. package/packages/bundled-features/src/delivery/delivery-service.ts +103 -16
  45. package/packages/bundled-features/src/delivery/unsubscribe.ts +81 -13
  46. package/packages/bundled-features/src/feature-toggles/feature.ts +2 -3
  47. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +34 -5
  48. package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +31 -8
  49. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +10 -1
  50. package/packages/bundled-features/src/files/changes.json +24 -0
  51. package/packages/bundled-features/src/files-tenant-data/changes.json +5 -0
  52. package/packages/bundled-features/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +4 -0
  53. package/packages/bundled-features/src/files-tenant-data/hooks.ts +72 -23
  54. package/packages/bundled-features/src/folders/changes.json +6 -0
  55. package/packages/bundled-features/src/folders/handlers/delete-folder.write.ts +41 -9
  56. package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
  57. package/packages/bundled-features/src/foundation-shared/mail-host-policy.ts +16 -4
  58. package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
  59. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -0
  60. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +3 -4
  61. package/packages/bundled-features/src/jobs/changes.json +30 -0
  62. package/packages/bundled-features/src/jobs/constants.ts +1 -0
  63. package/packages/bundled-features/src/jobs/db/queries/retention.ts +12 -0
  64. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +26 -2
  65. package/packages/bundled-features/src/jobs/feature.ts +7 -1
  66. package/packages/bundled-features/src/jobs/handlers/list.query.ts +1 -1
  67. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -1
  68. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +94 -0
  69. package/packages/bundled-features/src/jobs/index.ts +2 -0
  70. package/packages/bundled-features/src/jobs/job-run-logger.ts +177 -10
  71. package/packages/bundled-features/src/jobs/tenant-job-failure-table.ts +27 -8
  72. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +55 -0
  73. package/packages/bundled-features/src/ledger/changes.json +23 -0
  74. package/packages/bundled-features/src/ledger/entity.ts +13 -5
  75. package/packages/bundled-features/src/ledger/feature.ts +3 -0
  76. package/packages/bundled-features/src/ledger/handlers/confirm-schedule-period.write.ts +15 -3
  77. package/packages/bundled-features/src/ledger/handlers/create-transaction.write.ts +1 -0
  78. package/packages/bundled-features/src/ledger/handlers/reverse-transaction.write.ts +1 -0
  79. package/packages/bundled-features/src/ledger/schedule-subject-pair.ts +27 -0
  80. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +4 -15
  81. package/packages/bundled-features/src/notes-history/changes.json +13 -0
  82. package/packages/bundled-features/src/notes-history/entity.ts +4 -0
  83. package/packages/bundled-features/src/personal-access-tokens/handlers/create.write.ts +1 -0
  84. package/packages/bundled-features/src/sessions/feature.ts +21 -8
  85. package/packages/bundled-features/src/sessions/handlers/detail.query.ts +34 -33
  86. package/packages/bundled-features/src/sessions/handlers/list.query.ts +68 -65
  87. package/packages/bundled-features/src/sessions/screens.ts +10 -8
  88. package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +15 -8
  89. package/packages/bundled-features/src/shared/index.ts +2 -1
  90. package/packages/bundled-features/src/shared/parent-visibility.ts +31 -1
  91. package/packages/bundled-features/src/shared/row-bound-grant.ts +18 -2
  92. package/packages/bundled-features/src/shared/session-locale-field.ts +7 -0
  93. package/packages/bundled-features/src/template-resolver/api.ts +3 -3
  94. package/packages/bundled-features/src/template-resolver/changes.json +5 -0
  95. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +2 -2
  96. package/packages/bundled-features/src/tenant/changes.json +12 -0
  97. package/packages/bundled-features/src/tenant/feature.ts +8 -1
  98. package/packages/bundled-features/src/tenant/handlers/toggle-enabled.write.ts +2 -0
  99. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +1 -0
  100. package/packages/bundled-features/src/tenant/invitation-table.ts +6 -0
  101. package/packages/bundled-features/src/tenant/invited-membership.ts +45 -12
  102. package/packages/bundled-features/src/tenant-handover/changes.json +6 -0
  103. package/packages/bundled-features/src/tenant-handover/transfer-graph.ts +14 -96
  104. package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +16 -0
  105. package/packages/bundled-features/src/tenant-lifecycle/changes.json +22 -0
  106. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +8 -0
  107. package/packages/bundled-features/src/tenant-lifecycle/events.ts +6 -0
  108. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +13 -1
  109. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +100 -29
  110. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +41 -9
  111. package/packages/bundled-features/src/tenant-lifecycle/testing.ts +58 -0
  112. package/packages/bundled-features/src/tier-engine/changes.json +5 -0
  113. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -0
  114. package/packages/bundled-features/src/user/changes.json +15 -0
  115. package/packages/bundled-features/src/user/handlers/detail.query.ts +1 -0
  116. package/packages/bundled-features/src/user/handlers/list.query.ts +3 -0
  117. package/packages/bundled-features/src/user/handlers/update.write.ts +1 -0
  118. package/packages/bundled-features/src/user/seeding.ts +34 -0
  119. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  120. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -0
  121. package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
  122. package/packages/bundled-features/src/user-data-rights/schema/download-attempt.ts +17 -9
  123. package/packages/bundled-features/src/user-profile/handlers/change-email.write.ts +8 -2
  124. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +72 -14
  125. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +6 -1
  126. package/packages/bundled-features/src/workflow-runner/runner.ts +8 -2
  127. package/samples/apps/showcase/src/features/items/feature.ts +2 -1
  128. package/samples/apps/styleguide/src/features/widgets/feature.ts +83 -0
  129. package/samples/apps/styleguide/src/features/widgets/i18n.ts +12 -0
  130. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +4 -0
  131. package/samples/apps/ui-walkthrough/package.json +3 -0
  132. package/samples/apps/use-all-bundled/README.md +1 -2
  133. package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
  134. package/samples/apps/user-data-rights-demo/README.md +1 -3
  135. package/samples/recipes/legal-pages/README.md +3 -4
  136. package/samples/recipes/managed-pages/README.md +2 -2
  137. package/samples/recipes/seed-migration/README.md +0 -2
@@ -2,8 +2,11 @@ import {
2
2
  deleteMany,
3
3
  fetchOne,
4
4
  insertMany,
5
+ insertOnConflictDoNothing,
5
6
  insertOne,
7
+ transaction,
6
8
  updateMany,
9
+ upsertOnConflict,
7
10
  } from "@cosmicdrift/kumiko-framework/bun-db";
8
11
  import {
9
12
  configuredPiiSubjectKms,
@@ -12,12 +15,13 @@ import {
12
15
  type LocalKeyKmsAdapter,
13
16
  PII_ERASED_SENTINEL,
14
17
  } from "@cosmicdrift/kumiko-framework/crypto";
15
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
18
+ import { acquireNamespacedAdvisoryLock, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
16
19
  import { type Registry, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
17
20
  import type {
18
21
  JobLogEntry,
19
22
  JobMeta,
20
23
  JobOutcomeMeta,
24
+ JobQueuedMeta,
21
25
  JobRunnerOptions,
22
26
  } from "@cosmicdrift/kumiko-framework/jobs";
23
27
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
@@ -27,6 +31,7 @@ import { runCompletedSchema, runFailedSchema, runStartedSchema } from "./events.
27
31
  import { parseJobInstant } from "./job-instant.js";
28
32
  import { jobRunLogsTable, jobRunsTable } from "./job-run-table.js";
29
33
  import { tenantJobFailuresTable } from "./tenant-job-failure-table.js";
34
+ import { tenantJobRunsTable } from "./tenant-job-run-table.js";
30
35
 
31
36
  // Matches PgKmsAdapter's default pool size (see tenant/handlers/*.query.ts) —
32
37
  // bounds concurrent getOrCreateDek calls so a large log batch doesn't claim
@@ -51,7 +56,7 @@ export type JobRunLoggerOptions = {
51
56
 
52
57
  export type JobRunLoggerCallbacks = Pick<
53
58
  JobRunnerOptions,
54
- "onJobStart" | "onJobComplete" | "onJobFailed"
59
+ "onJobStart" | "onJobComplete" | "onJobFailed" | "onJobQueued" | "onJobDropped"
55
60
  >;
56
61
 
57
62
  // Default cap on the bullJobId → runId cache. A worker that starts jobs
@@ -151,6 +156,10 @@ async function encryptStartedPayload(
151
156
  return encryptOrSentinel(kms, triggeredById, payload, "payload");
152
157
  }
153
158
 
159
+ // pg_advisory_xact_lock namespace (int4): 'tjfl' as ASCII, disjoint from the
160
+ // framework's other fixed advisory-lock keys.
161
+ const TENANT_JOB_FAILURE_LOCK_NAMESPACE = 0x746a666c;
162
+
154
163
  // fw#3079 — which row the tenant-visible failure record lives in: one per
155
164
  // (tenant, job, subject), so the next outcome of the same work replaces or
156
165
  // clears it. `subject` is null for a job that declares no subjectFields.
@@ -177,14 +186,22 @@ async function recordTenantJobFailure(
177
186
  // non-final failure must not show the tenant a failure the next attempt
178
187
  // may still resolve.
179
188
  if (!where || !messageKey || outcome?.finalAttempt !== true) return;
180
- // ponytail: delete-then-insert instead of an upsert — two runs of the same
181
- // key finishing at once can leave two rows, and the query returns the
182
- // newest. Add a unique index + ON CONFLICT if that ever matters.
183
- await deleteMany(db, tenantJobFailuresTable, where);
184
- await insertOne(db, tenantJobFailuresTable, {
185
- ...where,
186
- messageKey,
187
- failedAt: Temporal.Now.instant(),
189
+ // Delete-then-insert under a per-key lock instead of an upsert: the two
190
+ // partial unique indexes (subject NULL vs. set) are no ON CONFLICT target.
191
+ // The lock makes the later of two racing final failures replace the earlier
192
+ // one; the indexes are the backstop against any writer that skips the lock.
193
+ await transaction(db, async (tx) => {
194
+ await acquireNamespacedAdvisoryLock(
195
+ tx,
196
+ TENANT_JOB_FAILURE_LOCK_NAMESPACE,
197
+ JSON.stringify([where["tenantId"], where["jobName"], where["subject"]]),
198
+ );
199
+ await deleteMany(tx, tenantJobFailuresTable, where);
200
+ await insertOne(tx, tenantJobFailuresTable, {
201
+ ...where,
202
+ messageKey,
203
+ failedAt: Temporal.Now.instant(),
204
+ });
188
205
  });
189
206
  }
190
207
 
@@ -199,8 +216,131 @@ async function clearTenantJobFailure(
199
216
  await deleteMany(db, tenantJobFailuresTable, where);
200
217
  }
201
218
 
219
+ // fw#3616 — tenant-visible run state. One row per BullMQ job in
220
+ // store_tenant_job_runs; the writes below only ever touch rows of the same
221
+ // bullJobId, except pruning older finished rows of the same key.
222
+ type TenantRunKey = {
223
+ readonly tenantId: string;
224
+ readonly jobName: string;
225
+ readonly subject: string | null;
226
+ };
227
+
228
+ function tenantRunKey(
229
+ jobName: string,
230
+ tenantId: string,
231
+ subject: string | null,
232
+ ): TenantRunKey | null {
233
+ // skip: tenant-less run (cron resolves to SYSTEM_TENANT_ID) — no tenant-scoped query could read it
234
+ if (tenantId === SYSTEM_TENANT_ID) return null;
235
+ return { tenantId, jobName, subject };
236
+ }
237
+
238
+ async function recordTenantRunQueued(
239
+ db: DbConnection,
240
+ jobName: string,
241
+ bullJobId: string,
242
+ meta: JobQueuedMeta,
243
+ ): Promise<void> {
244
+ const key = tenantRunKey(jobName, meta.tenantId, meta.subject);
245
+ // skip: system-tenant run, no tenant-scoped query reads it
246
+ if (!key) return;
247
+ const now = Temporal.Now.instant();
248
+ // DO NOTHING: a fast worker can report the start before this enqueue hook
249
+ // returns, and a repeated jobId (debounce, fan-out dedup) is the same job.
250
+ await insertOnConflictDoNothing(
251
+ db,
252
+ tenantJobRunsTable,
253
+ { ...key, bullJobId, status: "queued", queuedAt: now, updatedAt: now },
254
+ { conflictKeys: ["bullJobId"] },
255
+ );
256
+ }
257
+
258
+ async function recordTenantRunStarted(
259
+ db: DbConnection,
260
+ jobName: string,
261
+ bullJobId: string,
262
+ meta: JobMeta,
263
+ ): Promise<void> {
264
+ // skip: job did not opt in to tenantVisibleRun
265
+ if (!meta.tenantVisibleRun) return;
266
+ const key = tenantRunKey(jobName, meta.tenantVisibleRun.tenantId, meta.tenantVisibleRun.subject);
267
+ // skip: system-tenant run, no tenant-scoped query reads it
268
+ if (!key) return;
269
+ const now = Temporal.Now.instant();
270
+ await upsertOnConflict(
271
+ db,
272
+ tenantJobRunsTable,
273
+ { ...key, bullJobId, status: "running", startedAt: now, updatedAt: now },
274
+ {
275
+ conflictKeys: ["bullJobId"],
276
+ update: { status: "running", startedAt: now, finishedAt: null, updatedAt: now },
277
+ },
278
+ );
279
+ }
280
+
281
+ async function recordTenantRunOutcome(
282
+ db: DbConnection,
283
+ jobName: string,
284
+ bullJobId: string,
285
+ outcome: JobOutcomeMeta | undefined,
286
+ result: "completed" | "failed",
287
+ ): Promise<void> {
288
+ // skip: job did not opt in to tenantVisibleRun
289
+ if (!outcome?.tenantVisibleRun) return;
290
+ const key = tenantRunKey(jobName, outcome.tenantId, outcome.tenantVisibleRun.subject);
291
+ // skip: system-tenant run, no tenant-scoped query reads it
292
+ if (!key) return;
293
+ const now = Temporal.Now.instant();
294
+ // A failed attempt BullMQ will retry goes back to waiting: the tenant keeps
295
+ // seeing pending work, not a failure the next attempt may still resolve.
296
+ if (result === "failed" && !outcome.finalAttempt) {
297
+ await updateMany(
298
+ db,
299
+ tenantJobRunsTable,
300
+ { status: "queued", startedAt: null, finishedAt: null, updatedAt: now },
301
+ { bullJobId },
302
+ );
303
+ // skip: retry pending, the row stays active until the final attempt
304
+ return;
305
+ }
306
+ await upsertOnConflict(
307
+ db,
308
+ tenantJobRunsTable,
309
+ { ...key, bullJobId, status: result, finishedAt: now, updatedAt: now },
310
+ { conflictKeys: ["bullJobId"], update: { status: result, finishedAt: now, updatedAt: now } },
311
+ );
312
+ await deleteMany(db, tenantJobRunsTable, {
313
+ ...key,
314
+ status: { in: ["completed", "failed"] },
315
+ bullJobId: { ne: bullJobId },
316
+ });
317
+ }
318
+
319
+ async function dropTenantRunQueued(db: DbConnection, bullJobId: string): Promise<void> {
320
+ await deleteMany(db, tenantJobRunsTable, { bullJobId, status: "queued" });
321
+ }
322
+
202
323
  const log = createFallbackLogger("job-run-logger");
203
324
 
325
+ // The tenant run state is an optional side write: a throw here must not
326
+ // fail the enqueue, skip the run-row write or turn a completed run into a
327
+ // BullMQ retry.
328
+ async function isolateTenantRunWrite(
329
+ write: () => Promise<void>,
330
+ jobName: string,
331
+ bullJobId: string,
332
+ ): Promise<void> {
333
+ try {
334
+ await write();
335
+ } catch (e) {
336
+ log.error("tenant job run state write failed", {
337
+ jobName,
338
+ bullJobId,
339
+ error: e instanceof Error ? e.message : String(e),
340
+ });
341
+ }
342
+ }
343
+
204
344
  // The tenant failure record is an optional side write: a throw here must not
205
345
  // skip the run-row update or turn a completed run into a BullMQ retry.
206
346
  async function isolateTenantFailureWrite(
@@ -289,7 +429,24 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
289
429
  }
290
430
 
291
431
  return {
432
+ onJobQueued: async (jobName: string, bullJobId: string, meta: JobQueuedMeta) => {
433
+ await isolateTenantRunWrite(
434
+ () => recordTenantRunQueued(db, jobName, bullJobId, meta),
435
+ jobName,
436
+ bullJobId,
437
+ );
438
+ },
439
+
440
+ onJobDropped: async (jobName: string, bullJobId: string) => {
441
+ await isolateTenantRunWrite(() => dropTenantRunQueued(db, bullJobId), jobName, bullJobId);
442
+ },
443
+
292
444
  onJobStart: async (jobName: string, bullJobId: string, meta: JobMeta) => {
445
+ await isolateTenantRunWrite(
446
+ () => recordTenantRunStarted(db, jobName, bullJobId, meta),
447
+ jobName,
448
+ bullJobId,
449
+ );
293
450
  const runId = generateId();
294
451
  const triggeredById = meta.triggeredById ?? null;
295
452
  cachePut(bullJobId, runId, triggeredById);
@@ -336,6 +493,11 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
336
493
  jobName,
337
494
  bullJobId,
338
495
  );
496
+ await isolateTenantRunWrite(
497
+ () => recordTenantRunOutcome(db, jobName, bullJobId, outcome, "completed"),
498
+ jobName,
499
+ bullJobId,
500
+ );
339
501
  const resolved = await resolveRun(bullJobId);
340
502
  // skip: state loss between start + complete (worker restart, cache
341
503
  // evicted AND DB has no matching bull_job_id). Rare edge case; we
@@ -400,6 +562,11 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
400
562
  jobName,
401
563
  bullJobId,
402
564
  );
565
+ await isolateTenantRunWrite(
566
+ () => recordTenantRunOutcome(db, jobName, bullJobId, outcome, "failed"),
567
+ jobName,
568
+ bullJobId,
569
+ );
403
570
  const resolved = await resolveRun(bullJobId);
404
571
  // skip: same rare state-loss case as in onJobComplete — drop the
405
572
  // failure write rather than forge a run row from scratch.
@@ -1,11 +1,13 @@
1
1
  import { requireEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import {
3
3
  type EntityTableMeta,
4
+ index,
4
5
  instant,
5
6
  table as pgTable,
6
7
  serial,
7
8
  sql,
8
9
  text,
10
+ uniqueIndex,
9
11
  uuid,
10
12
  } from "@cosmicdrift/kumiko-framework/db";
11
13
 
@@ -23,14 +25,31 @@ import {
23
25
  // canonical JSON of the job's declared subjectFields, or NULL for a job that
24
26
  // declares none — stored in clear, so a job must not declare a PII field
25
27
  // as its subject.
26
- export const tenantJobFailuresTable = pgTable("store_tenant_job_failures", {
27
- id: serial("id").primaryKey(),
28
- tenantId: uuid("tenant_id").notNull(),
29
- jobName: text("job_name").notNull(),
30
- subject: text("subject"),
31
- messageKey: text("message_key").notNull(),
32
- failedAt: instant("failed_at").default(sql`now()`).notNull(),
33
- });
28
+ export const tenantJobFailuresTable = pgTable(
29
+ "store_tenant_job_failures",
30
+ {
31
+ id: serial("id").primaryKey(),
32
+ tenantId: uuid("tenant_id").notNull(),
33
+ jobName: text("job_name").notNull(),
34
+ subject: text("subject"),
35
+ messageKey: text("message_key").notNull(),
36
+ failedAt: instant("failed_at").default(sql`now()`).notNull(),
37
+ },
38
+ (t) => [
39
+ // One row per key, also the lookup for the delete before every write;
40
+ // subject NULL needs its own partial index because NULLs
41
+ // are distinct in a plain unique index.
42
+ uniqueIndex("store_tenant_job_failures_tenant_job_subject_uidx")
43
+ .on(t.tenantId, t.jobName, t.subject)
44
+ .where(sql`subject IS NOT NULL`),
45
+ uniqueIndex("store_tenant_job_failures_tenant_job_null_subject_uidx")
46
+ .on(t.tenantId, t.jobName)
47
+ .where(sql`subject IS NULL`),
48
+ // The tenant list sorts by failed_at; retention filters on it alone.
49
+ index("store_tenant_job_failures_tenant_failed_at_idx").on(t.tenantId, t.failedAt),
50
+ index("store_tenant_job_failures_failed_at_idx").on(t.failedAt),
51
+ ],
52
+ );
34
53
 
35
54
  export const tenantJobFailuresTableMeta: EntityTableMeta = requireEntityTableMeta(
36
55
  tenantJobFailuresTable,
@@ -0,0 +1,55 @@
1
+ import { requireEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import {
3
+ type EntityTableMeta,
4
+ index,
5
+ instant,
6
+ table as pgTable,
7
+ serial,
8
+ sql,
9
+ text,
10
+ uniqueIndex,
11
+ uuid,
12
+ } from "@cosmicdrift/kumiko-framework/db";
13
+
14
+ export type TenantJobRunStatus = "queued" | "running" | "completed" | "failed";
15
+
16
+ // The tenant's own view of a job's run state (fw#3616). Direct-write store
17
+ // like store_job_runs, written by job-run-logger.ts from the runner callbacks.
18
+ //
19
+ // store_job_runs cannot serve the tenant: it anchors every run on the system
20
+ // tenant, has no subject, and gets its row only when a run starts, so a queued
21
+ // job is invisible there. This table holds no payload, error text or logs by
22
+ // design — the tenant learns only that work is waiting, running or finished.
23
+ //
24
+ // One row per BullMQ job. Active rows (queued/running) are kept as they are;
25
+ // a finished row replaces earlier finished rows of the same (tenant, job,
26
+ // subject), so only the latest outcome stays. `subject` is the canonical JSON
27
+ // of the job's declared subjectFields, or NULL for none — stored in clear, so
28
+ // a job must not declare a PII field as its subject.
29
+ export const tenantJobRunsTable = pgTable(
30
+ "store_tenant_job_runs",
31
+ {
32
+ id: serial("id").primaryKey(),
33
+ tenantId: uuid("tenant_id").notNull(),
34
+ jobName: text("job_name").notNull(),
35
+ subject: text("subject"),
36
+ bullJobId: text("bull_job_id").notNull(),
37
+ status: text("status").notNull(),
38
+ queuedAt: instant("queued_at"),
39
+ startedAt: instant("started_at"),
40
+ finishedAt: instant("finished_at"),
41
+ updatedAt: instant("updated_at").default(sql`now()`).notNull(),
42
+ },
43
+ (t) => [
44
+ uniqueIndex("store_tenant_job_runs_bull_job_id_unique").on(t.bullJobId),
45
+ // Finishing a run prunes older outcomes by this key; the tenant query reads by it.
46
+ index("store_tenant_job_runs_tenant_job_subject_idx").on(t.tenantId, t.jobName, t.subject),
47
+ // Stale sweep and retention filter on age alone.
48
+ index("store_tenant_job_runs_updated_at_idx").on(t.updatedAt),
49
+ ],
50
+ );
51
+
52
+ export const tenantJobRunsTableMeta: EntityTableMeta = requireEntityTableMeta(
53
+ tenantJobRunsTable,
54
+ "tenantJobRunsTable",
55
+ );
@@ -1,4 +1,27 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "fix",
5
+ "title": "confirm-schedule-period serializes concurrent confirms of the same schedule period",
6
+ "detail": "ledger: `confirm-schedule-period` serializes concurrent confirms of the same (tenant, schedule period) with an advisory lock, so direct calls can no longer double-book."
7
+ },
8
+ {
9
+ "version": "0.354.0",
10
+ "type": "breaking",
11
+ "title": "Schedule sourceRef is unique per tenant (partial unique index read_ledger_schedules_tenant_id_source_ref_uidx)",
12
+ "migration": "sourceRef is now unique per tenant: a second createSchedule (or updateSchedule) with a sourceRef already used in the same tenant fails with unique_violation (HTTP 409). Schedules without a sourceRef and the same sourceRef in different tenants stay allowed. Before applying, check for duplicates: SELECT tenant_id, source_ref, count(*) FROM read_ledger_schedules WHERE source_ref IS NOT NULL GROUP BY tenant_id, source_ref HAVING count(*) > 1; and resolve them through real writes (updateSchedule with another sourceRef). kumiko schema generate treats the new unique index on a managed projection as destructive (DROP TABLE + CREATE TABLE + .rebuild.json); a rebuild would also hit the unique index on historical duplicates. Hand-edit the generated migration: replace the generated DROP/CREATE with exactly DROP INDEX IF EXISTS \"read_ledger_schedules_tenant_id_source_ref_idx\"; CREATE UNIQUE INDEX IF NOT EXISTS \"read_ledger_schedules_tenant_id_source_ref_uidx\" ON \"read_ledger_schedules\" (\"tenant_id\", \"source_ref\") WHERE \"source_ref\" IS NOT NULL; and discard the generated .rebuild.json (delete the file, do not commit it). Code that does find-then-create by sourceRef should expect unique_violation on create and re-read the schedule that won the race."
13
+ },
14
+ {
15
+ "version": "0.354.0",
16
+ "type": "fix",
17
+ "title": "ledger create-transaction and reverse-transaction resolve agent risk high"
18
+ },
19
+ {
20
+ "version": "0.354.0",
21
+ "type": "breaking",
22
+ "title": "A ledger schedule needs subjectType and subjectId together or neither",
23
+ "migration": "Create and update schedule calls that set only one of the two fields now fail validation. Send both fields or neither."
24
+ },
2
25
  {
3
26
  "version": "0.331.0",
4
27
  "type": "improvement",
@@ -108,6 +108,8 @@ export const transactionEntity = createEntity({
108
108
  filterable: true,
109
109
  }),
110
110
  },
111
+ // Subject ids are only unique within their type, so lookups filter on both
112
+ // columns; a subjectId-only filter would not be served by this index.
111
113
  indexes: [{ columns: ["tenantId", "subjectType", "subjectId"] }],
112
114
  });
113
115
 
@@ -163,8 +165,8 @@ export const scheduleEntity = createEntity({
163
165
  filterable: true,
164
166
  }),
165
167
  // Caller-assigned stable key (e.g. one id per lease position) to find the
166
- // schedule again. Not unique: callers keep their own find-then-create;
167
- // `description` stays pure display text instead of a lookup key.
168
+ // schedule again; unique per tenant when set. `description` stays pure
169
+ // display text instead of a lookup key.
168
170
  sourceRef: createTextField({
169
171
  maxLength: 128,
170
172
  personal: false,
@@ -172,10 +174,16 @@ export const scheduleEntity = createEntity({
172
174
  filterable: true,
173
175
  }),
174
176
  },
175
- // A separate non-unique index: changing the subject index would drop and
176
- // recreate it, an additional one is a plain CREATE INDEX.
177
+ // Find-then-create by sourceRef races across tabs/devices; only the DB closes
178
+ // that gap. Partial because sourceRef is optional. A new name (not the old
179
+ // non-unique default) keeps the migration an in-place CREATE UNIQUE INDEX.
177
180
  indexes: [
178
181
  { columns: ["tenantId", "subjectType", "subjectId"] },
179
- { columns: ["tenantId", "sourceRef"] },
182
+ {
183
+ columns: ["tenantId", "sourceRef"],
184
+ unique: true,
185
+ where: sql`"source_ref" IS NOT NULL`,
186
+ name: "read_ledger_schedules_tenant_id_source_ref_uidx",
187
+ },
180
188
  ],
181
189
  });
@@ -31,6 +31,7 @@ import {
31
31
  createIncomeStatementHandler,
32
32
  } from "./handlers/reports.query.js";
33
33
  import { createReverseTransactionHandler } from "./handlers/reverse-transaction.write.js";
34
+ import { requireScheduleSubjectPair } from "./schedule-subject-pair.js";
34
35
 
35
36
  // Opt-in tier-gating (mirrors folders): when set, the feature declares itself
36
37
  // r.toggleable so the dispatcher gate + tier-engine can switch the WHOLE ledger
@@ -125,6 +126,8 @@ function registerLedger(
125
126
  "Changes a recurring booking template's description, accounts, amount, interval or date window; use it when a standing order's terms change, already confirmed periods stay booked as they were.",
126
127
  }),
127
128
  );
129
+ r.hook("preSave", "schedule:create", requireScheduleSubjectPair);
130
+ r.hook("preSave", "schedule:update", requireScheduleSubjectPair);
128
131
  r.writeHandler(createConfirmSchedulePeriodHandler(access));
129
132
  r.queryHandler(
130
133
  defineEntityListHandler("schedule", scheduleEntity, {
@@ -1,3 +1,4 @@
1
+ import { acquireNamespacedAdvisoryLock } from "@cosmicdrift/kumiko-framework/db";
1
2
  import type {
2
3
  AccessRule,
3
4
  WriteHandlerDef,
@@ -19,6 +20,12 @@ import {
19
20
  } from "../schemas.js";
20
21
  import { readSubjectReference } from "../subject-reference.js";
21
22
 
23
+ // pg_advisory_xact_lock namespace (int4): 'lcsp' as ASCII, disjoint from the
24
+ // framework's other fixed advisory-lock keys.
25
+ const CONFIRM_PERIOD_LOCK_NAMESPACE = 0x6c637370;
26
+ const CONFIRM_PERIOD_LOCK_REASON =
27
+ "takes the per-(tenant, reference) advisory lock so parallel confirms cannot both pass the already-booked check; TenantDb has no lock API";
28
+
22
29
  // confirm-schedule-period — turn ONE projected period of a schedule into a posted,
23
30
  // balanced transaction (debit +amount / credit −amount), tagged with
24
31
  // scheduleReference(scheduleId, period) so the host can merge Soll vs. Ist.
@@ -35,6 +42,7 @@ export function createConfirmSchedulePeriodHandler(
35
42
  name: "confirm-schedule-period",
36
43
  schema: confirmSchedulePeriodPayloadSchema,
37
44
  access,
45
+ escapeHatch: { grants: ["unsafeRaw"], reason: CONFIRM_PERIOD_LOCK_REASON },
38
46
  description:
39
47
  "Turns one month of a recurring schedule into a posted balanced entry between its debit and credit account, returning the existing booking instead when that period is already booked and not reversed; use it to move a forecast period into the actual books.",
40
48
  handler: async (event, ctx) => {
@@ -68,9 +76,13 @@ export function createConfirmSchedulePeriodHandler(
68
76
  // normally 0 or 1 row, never O(tenant's-full-history); (2) any Storno
69
77
  // that mirrors one of those candidates (a tx is reversed when ANOTHER
70
78
  // tx's `reference` names its id).
71
- // ponytail: read-then-write, so two confirms racing the same period could
72
- // double-book; add a unique index on (tenantId, reference) when concurrent
73
- // confirms become real.
79
+ // No unique index on (tenantId, reference): a Storno makes re-confirming the
80
+ // same reference legitimate, so the check-then-write is serialized by a lock.
81
+ await acquireNamespacedAdvisoryLock(
82
+ ctx.db.unsafeRaw(),
83
+ CONFIRM_PERIOD_LOCK_NAMESPACE,
84
+ `${event.user.tenantId}:${reference}`,
85
+ );
74
86
  const candidates = await ctx.db.selectMany(transactionTable, {
75
87
  tenantId: event.user.tenantId,
76
88
  reference,
@@ -19,6 +19,7 @@ export function createCreateTransactionHandler(
19
19
  access,
20
20
  description:
21
21
  "Books one balanced journal entry from a date, description and at least two posting lines summing to zero, after verifying every named account exists; use it to record any financial movement in the tenant's books.",
22
+ agent: { risk: "high" },
22
23
  handler: async (event, ctx) => {
23
24
  const payload = event.payload as CreateTransactionPayload; // @cast-boundary engine-payload
24
25
 
@@ -26,6 +26,7 @@ export function createReverseTransactionHandler(
26
26
  access,
27
27
  description:
28
28
  "Books the mirror image of a posted journal entry as a new entry referencing the original, refusing entries that are not posted or already reversed; use it as the only way to correct a booking, since entries themselves are immutable.",
29
+ agent: { risk: "high" },
29
30
  handler: async (event, ctx) => {
30
31
  const payload = event.payload as ReverseTransactionPayload; // @cast-boundary engine-payload
31
32
 
@@ -0,0 +1,27 @@
1
+ import type { PreSaveHookFn } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { ValidationError } from "@cosmicdrift/kumiko-framework/errors";
3
+
4
+ function isSet(value: unknown): boolean {
5
+ return value !== undefined && value !== null && value !== "";
6
+ }
7
+
8
+ // subjectId is only unique within its subjectType, so a schedule with one of
9
+ // the two would be found by a lone-subjectId filter among other object types.
10
+ // The effective pair is checked (changes over the stored row) because an
11
+ // update may touch only one of the two fields.
12
+ export const requireScheduleSubjectPair: PreSaveHookFn = async (changes, ctx) => {
13
+ const effective = (key: "subjectType" | "subjectId"): unknown =>
14
+ key in changes ? changes[key] : ctx.previous[key];
15
+ const hasType = isSet(effective("subjectType"));
16
+ const hasId = isSet(effective("subjectId"));
17
+ if (hasType === hasId) return changes;
18
+ throw new ValidationError({
19
+ fields: [
20
+ {
21
+ path: hasType ? "subjectId" : "subjectType",
22
+ code: "required",
23
+ i18nKey: "errors.validation.required",
24
+ },
25
+ ],
26
+ });
27
+ };
@@ -33,8 +33,8 @@ import {
33
33
  import {
34
34
  BlockedHostError,
35
35
  HostResolutionError,
36
- MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
37
36
  type MailConnectTarget,
37
+ mailAllowedPrivateHostsEnvSchema,
38
38
  readAllowedPrivateMailHostsFromEnv,
39
39
  requireDefined,
40
40
  requireNonEmpty,
@@ -50,7 +50,6 @@ import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secr
50
50
  import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
51
51
  import { UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
52
52
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
53
- import * as z from "zod";
54
53
 
55
54
  const FEATURE_NAME = "mail-transport-smtp";
56
55
 
@@ -59,19 +58,9 @@ const log = createFallbackLogger(FEATURE_NAME);
59
58
  // Operator escape hatch for an internal relay or a dev/test SMTP server
60
59
  // (mailpit, MailHog): KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS, an operator env
61
60
  // var never a tenant-config value, so a tenant can never grant themselves
62
- // the private-host bypass. Declared here (not duplicated in
63
- // inbound-provider-imap's envSchema) since both features read the same
64
- // var and composeEnvSchema rejects two features declaring the same key —
65
- // see foundation-shared/mail-host-policy.ts for the shared reader + the
66
- // guard this feeds.
67
- export const mailTransportSmtpEnvSchema = z.object({
68
- [MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR]: z
69
- .string()
70
- .optional()
71
- .describe(
72
- "Comma-separated operator allowlist of private/internal hosts (e.g. a local mailpit/greenmail dev server) that bypass the public-address check for SMTP and IMAP host config. Shared with inbound-provider-imap — never a tenant-config value.",
73
- ),
74
- });
61
+ // the private-host bypass. The schema instance is shared with
62
+ // inbound-provider-imap (see foundation-shared/mail-host-policy.ts).
63
+ export const mailTransportSmtpEnvSchema = mailAllowedPrivateHostsEnvSchema;
75
64
 
76
65
  // Test-only DNS seam — production never calls this, resolveMailConnectTarget
77
66
  // defaults to the real resolver. Lets tests pin deterministic, network-free
@@ -1,4 +1,17 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "breaking",
5
+ "title": "notes-history adds a composite index on (tenantId, entityType, entityId) for note entries, so the schema-drift check fails until the app regenerates",
6
+ "detail": "Review batch H2: framework parts of consumer-app findings.\n- `file-derivatives`: the `isPublic` predicate args carry `fileRefId`, so a predicate can reject a client-spoofed FileRef that claims another entity's `entityId`/`fieldName`.\n- `sessions`: `createSessionsFeature({ adminAccess: \"systemAdmin\" })` narrows the admin list/detail queries and both admin screens together (default `\"admin\"`, unchanged).\n- `user-profile`: `change-email` is open to every signed-in user (`openToAll`), so a tenant member whose only role is `TenantAdmin` can change their own email; the handler still re-checks the password.\n- `notes-history`: composite index on `(tenantId, entityType, entityId)` for note entries. Apps run `kumiko-schema generate` after the bump.\n- guards: `// kumiko-lint-ignore a,b reason` suppresses several guards on one line (`lineHasIgnoreTag`); `primitives-discipline` and `no-custom-primitives` honour it.\n- dev-server: `kumiko-build --check` verifies `.kumiko/` is current without writing (exit 1 on drift); `runCodegen` takes `checkOnly`.",
7
+ "migration": "Run `kumiko-schema generate` in apps that mount notes-history and commit the generated migration."
8
+ },
9
+ {
10
+ "version": "0.354.0",
11
+ "type": "fix",
12
+ "title": "read_note_mentions gets a tenant, subject index",
13
+ "migration": "Run `kumiko schema generate`; it emits an in-place CREATE INDEX with no DROP and no rebuild."
14
+ },
2
15
  {
3
16
  "version": "0.338.0",
4
17
  "type": "improvement",
@@ -91,6 +91,8 @@ export function createNoteEntryEntity(
91
91
  find: "none",
92
92
  }),
93
93
  },
94
+ // Every host detail screen reads its notes by tenant + host entity.
95
+ indexes: [{ columns: ["tenantId", "entityType", "entityId"] }],
94
96
  });
95
97
  }
96
98
 
@@ -122,6 +124,8 @@ export function createNoteMentionEntity() {
122
124
  }),
123
125
  subjectId: createTextField({ required: true, maxLength: 64, personal: "ref" }),
124
126
  },
127
+ // The forget cascade looks mentions up by subject within one tenant.
128
+ indexes: [{ columns: ["tenantId", "subjectId"] }],
125
129
  });
126
130
  }
127
131
 
@@ -66,6 +66,7 @@ export function createPatCreateHandler(opts: CreatePatOptions = {}) {
66
66
  },
67
67
  },
68
68
  escapeHatch: {
69
+ grants: ["systemIdentity"],
69
70
  reason:
70
71
  "Reads the caller's own passwordHash (privileged-only field) via ctx.queryAs(SYSTEM, " +
71
72
  "user:findForAuth) to re-verify the current password before minting the token. Also " +