@cosmicdrift/kumiko-bundled-features 0.365.0 → 0.366.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 (112) hide show
  1. package/dist/admin-shell/feature.js +9 -9
  2. package/dist/agent-tools/tool-dispatch.d.ts +9 -1
  3. package/dist/agent-tools/tool-dispatch.js +55 -5
  4. package/dist/audit/feature.js +1 -1
  5. package/dist/auth-email-password/constants.d.ts +0 -1
  6. package/dist/auth-email-password/constants.js +0 -2
  7. package/dist/auth-email-password/feature.js +3 -3
  8. package/dist/auth-email-password/handlers/invite-create.write.js +1 -0
  9. package/dist/auth-mfa/feature.js +4 -4
  10. package/dist/auth-mfa-user-data/index.js +1 -1
  11. package/dist/billing-foundation/consumer-protection/register-consent.js +1 -1
  12. package/dist/billing-foundation/feature.js +1 -1
  13. package/dist/cap-counter/cap-reservation.js +73 -25
  14. package/dist/cap-counter/constants.d.ts +1 -0
  15. package/dist/cap-counter/constants.js +2 -0
  16. package/dist/cap-counter/feature.js +2 -2
  17. package/dist/cap-counter/tables.js +5 -2
  18. package/dist/cap-overview/feature.js +3 -3
  19. package/dist/channel-discord/feature.js +2 -2
  20. package/dist/channel-email/feature.js +1 -1
  21. package/dist/channel-in-app/feature.js +1 -1
  22. package/dist/channel-push/feature.js +1 -1
  23. package/dist/channel-slack/feature.js +2 -2
  24. package/dist/channel-teams/feature.js +2 -2
  25. package/dist/channel-telegram/feature.js +2 -2
  26. package/dist/compliance-profiles/feature.js +1 -1
  27. package/dist/compliance-profiles-ops/index.js +3 -3
  28. package/dist/config/feature.js +1 -1
  29. package/dist/custom-fields/feature.js +2 -2
  30. package/dist/custom-fields/wire-for-entity.js +17 -13
  31. package/dist/data-retention/feature.js +1 -1
  32. package/dist/data-retention/presets.d.ts +4 -4
  33. package/dist/delivery/db/queries/address-opt-outs.d.ts +1 -1
  34. package/dist/delivery/db/queries/address-opt-outs.js +6 -2
  35. package/dist/delivery/db/queries/preferences.d.ts +1 -1
  36. package/dist/delivery/db/queries/preferences.js +4 -4
  37. package/dist/delivery/delivery-service.js +9 -2
  38. package/dist/delivery/feature.js +9 -5
  39. package/dist/delivery/handlers/resubscribe-address.write.js +13 -9
  40. package/dist/delivery/handlers/resubscribe-user.write.js +3 -1
  41. package/dist/delivery/handlers/set-preference.write.js +3 -1
  42. package/dist/delivery/handlers/unsubscribe-address.write.js +3 -1
  43. package/dist/delivery/handlers/unsubscribe-user.write.js +3 -1
  44. package/dist/delivery/notification-type-aliases.d.ts +3 -0
  45. package/dist/delivery/notification-type-aliases.js +11 -0
  46. package/dist/derivatives-sharp/feature.js +1 -1
  47. package/dist/document-ingest-foundation/feature.js +2 -2
  48. package/dist/file-derivatives/feature.js +3 -3
  49. package/dist/file-foundation/feature.js +1 -1
  50. package/dist/file-provider-inmemory/feature.js +2 -2
  51. package/dist/file-provider-s3/feature.js +3 -3
  52. package/dist/file-provider-s3-env/feature.js +2 -2
  53. package/dist/files-tenant-data/index.js +1 -1
  54. package/dist/folders/feature.js +2 -2
  55. package/dist/folders-user-data/index.js +1 -1
  56. package/dist/form-draft/feature.js +2 -2
  57. package/dist/form-draft-user-data/index.js +1 -1
  58. package/dist/inbound-mail-foundation/connect-routes.d.ts +3 -0
  59. package/dist/inbound-mail-foundation/connect-routes.js +6 -1
  60. package/dist/inbound-mail-foundation/feature.js +1 -1
  61. package/dist/inbound-mail-foundation/types.d.ts +17 -7
  62. package/dist/inbound-mail-foundation/watch-supervisor.js +16 -2
  63. package/dist/inbound-provider-imap/feature.js +2 -2
  64. package/dist/inbound-provider-inmemory/feature.js +1 -1
  65. package/dist/ledger/feature.js +5 -5
  66. package/dist/legal-pages/feature.js +1 -1
  67. package/dist/mail-foundation/feature.js +1 -1
  68. package/dist/mail-transport-inmemory/feature.js +2 -2
  69. package/dist/mail-transport-smtp/feature.js +3 -3
  70. package/dist/managed-pages/css-gate.js +1 -1
  71. package/dist/managed-pages/feature.js +2 -2
  72. package/dist/metrics/feature.js +2 -2
  73. package/dist/notes-history/feature.js +2 -2
  74. package/dist/notes-history-user-data/index.js +1 -1
  75. package/dist/personal-access-tokens/feature.js +17 -13
  76. package/dist/readiness/feature.js +2 -2
  77. package/dist/renderer-foundation/feature.js +1 -1
  78. package/dist/renderer-pdf/feature.js +1 -1
  79. package/dist/secrets/feature.js +1 -1
  80. package/dist/seo/feature.js +1 -1
  81. package/dist/sessions/feature.js +20 -16
  82. package/dist/step-dispatcher/constants.d.ts +10 -0
  83. package/dist/step-dispatcher/constants.js +17 -0
  84. package/dist/step-dispatcher/deliver-dispatch-request.d.ts +14 -0
  85. package/dist/step-dispatcher/deliver-dispatch-request.js +163 -0
  86. package/dist/step-dispatcher/feature.js +48 -101
  87. package/dist/step-dispatcher/webhook-runner.d.ts +7 -4
  88. package/dist/step-dispatcher/webhook-runner.js +26 -11
  89. package/dist/subscription-mollie/feature.js +1 -1
  90. package/dist/subscription-stripe/feature.js +3 -3
  91. package/dist/tags/feature.js +2 -2
  92. package/dist/template-resolver/feature.js +2 -2
  93. package/dist/template-resolver-user-data/index.js +1 -1
  94. package/dist/tenant/feature.js +4 -4
  95. package/dist/tenant/seeding.js +1 -1
  96. package/dist/tenant-lifecycle/feature.js +2 -3
  97. package/dist/tenant-lifecycle/stages.js +4 -1
  98. package/dist/tenant-settings/feature.js +1 -1
  99. package/dist/tier-engine/feature.js +86 -72
  100. package/dist/user/feature.js +1 -1
  101. package/dist/user-data-rights/feature.js +4 -4
  102. package/dist/user-data-rights-defaults/feature.js +2 -2
  103. package/dist/user-profile/feature.js +4 -4
  104. package/dist/waitlist/feature.js +2 -2
  105. package/package.json +10 -10
  106. package/src/agent-tools/changes.json +6 -0
  107. package/src/auth-email-password/changes.json +13 -0
  108. package/src/cap-counter/changes.json +7 -0
  109. package/src/crypto-shredding/changes.json +13 -0
  110. package/src/delivery/changes.json +6 -0
  111. package/src/inbound-mail-foundation/changes.json +13 -0
  112. package/src/step-dispatcher/changes.json +8 -1
@@ -21,18 +21,18 @@ export function createAdminShellFeature(options = {}) {
21
21
  category: "operations",
22
22
  recommended: false,
23
23
  });
24
- r.requires("user");
25
- r.requires("tenant");
26
- r.requires("audit");
27
- r.requires("jobs");
28
- r.requires("metrics");
29
- r.requires("metrics-system");
24
+ r.requires({ features: ["user"] });
25
+ r.requires({ features: ["tenant"] });
26
+ r.requires({ features: ["audit"] });
27
+ r.requires({ features: ["jobs"] });
28
+ r.requires({ features: ["metrics"] });
29
+ r.requires({ features: ["metrics-system"] });
30
30
  if (includeTierAdmin)
31
- r.requires("tier-engine");
31
+ r.requires({ features: ["tier-engine"] });
32
32
  if (includeCapOverview)
33
- r.requires("cap-overview");
33
+ r.requires({ features: ["cap-overview"] });
34
34
  if (includeDocumentDesigner)
35
- r.requires("document-designer");
35
+ r.requires({ features: ["document-designer"] });
36
36
  r.translations({ keys: ADMIN_SHELL_I18N });
37
37
  const tenantNav = [
38
38
  "admin-shell:nav:tenant-overview",
@@ -1,4 +1,5 @@
1
1
  import type { SessionUser, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type Logger } from "@cosmicdrift/kumiko-framework/logging";
2
3
  import type { ToolDispatchDescriptor } from "./types.js";
3
4
  /** Narrow view of `Dispatcher` (packages/framework/src/pipeline/dispatcher.ts) — kept out of
4
5
  * `ToolDispatchDescriptor`'s reach and small enough for tests to build a plain object instead
@@ -17,6 +18,9 @@ export type ToolCallResult = {
17
18
  } | {
18
19
  readonly ok: false;
19
20
  readonly error: string;
21
+ readonly code?: string;
22
+ readonly retryable?: boolean;
23
+ readonly retryAfterSeconds?: number;
20
24
  };
21
25
  export type ToolCallRequest = {
22
26
  readonly dispatcher: ToolDispatcher;
@@ -30,10 +34,14 @@ export type ToolCallRequest = {
30
34
  /** Idempotency scope: `${runId}:${toolCallId}` becomes the write requestId. */
31
35
  readonly runId: string;
32
36
  readonly toolCallId: string;
37
+ /** Receives the cause of unexpected (5xx) failures, which the tool result never carries. */
38
+ readonly log?: Pick<Logger, "error">;
33
39
  };
34
40
  /** Executes one LLM-issued tool call. Never throws — a missing tool, a missing argument, a
35
41
  * disallowed filter field, or a rejected handler call (cap check, validation, ...) all come
36
42
  * back as `{ ok: false }` so the agent loop can feed the error back to the model instead of
37
- * crashing. `request.user` must be the caller's real identity — every server-kind tool runs
43
+ * crashing. Client errors (< 500) keep their message and carry a `code`; 429 adds
44
+ * `retryable`. Server-side failures (>= 500, unknown throws) are logged and returned as a
45
+ * generic message so internals never reach the model. `request.user` must be the caller's real identity — every server-kind tool runs
38
46
  * through the same permission-checked dispatcher pipeline as a normal HTTP request. */
39
47
  export declare function dispatchToolCall(request: ToolCallRequest): Promise<ToolCallResult>;
@@ -1,6 +1,48 @@
1
+ import { isKumikoError } from "@cosmicdrift/kumiko-framework/errors";
1
2
  import { runAsDirectCallEntry } from "@cosmicdrift/kumiko-framework/internal/request-context";
3
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
2
4
  import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
3
5
  import { FILTER_OPS, isRecord } from "./filter-ops.js";
6
+ const HTTP_TOO_MANY_REQUESTS = 429;
7
+ const HTTP_SERVER_ERROR = 500;
8
+ const UNEXPECTED_FAILURE_MESSAGE = "Tool failed unexpectedly";
9
+ const fallbackLog = createFallbackLogger("agent-tools");
10
+ function toolFailureOf(err) {
11
+ if (isKumikoError(err)) {
12
+ return {
13
+ code: err.code,
14
+ httpStatus: err.httpStatus,
15
+ message: err.message,
16
+ retryAfterSeconds: err.retryAfterSeconds,
17
+ };
18
+ }
19
+ return {
20
+ code: "internal_error",
21
+ httpStatus: HTTP_SERVER_ERROR,
22
+ message: err instanceof Error ? err.message : String(err),
23
+ retryAfterSeconds: undefined,
24
+ };
25
+ }
26
+ function createFailureClassifier(toolName, log) {
27
+ return (failure, cause) => {
28
+ if (failure.httpStatus === HTTP_TOO_MANY_REQUESTS) {
29
+ return {
30
+ ok: false,
31
+ error: failure.message,
32
+ code: failure.code,
33
+ retryable: true,
34
+ ...(failure.retryAfterSeconds !== undefined && {
35
+ retryAfterSeconds: failure.retryAfterSeconds,
36
+ }),
37
+ };
38
+ }
39
+ if (failure.httpStatus < HTTP_SERVER_ERROR) {
40
+ return { ok: false, error: failure.message, code: failure.code };
41
+ }
42
+ log.error("agent tool call failed unexpectedly", { toolName, code: failure.code, cause });
43
+ return { ok: false, error: UNEXPECTED_FAILURE_MESSAGE, code: failure.code };
44
+ };
45
+ }
4
46
  const SEARCH_RESULT_LIMIT = 10;
5
47
  const DEFAULT_LIST_LIMIT = 10;
6
48
  const MAX_LIST_LIMIT = 200;
@@ -124,7 +166,7 @@ async function dispatchServerQuery(descriptor, dispatcher, user, input) {
124
166
  * paired detail handler right before the optimistic-lock write), dispatches with a
125
167
  * run-scoped idempotency key, and — on success — re-reads through the detail handler so the
126
168
  * returned data goes through the field-level read filter (the raw write result does not). */
127
- async function dispatchServerWrite(descriptor, dispatcher, user, input, runId, toolCallId) {
169
+ async function dispatchServerWrite(descriptor, dispatcher, user, input, runId, toolCallId, classifyFailure) {
128
170
  let payload = input;
129
171
  if (descriptor.injectsVersion) {
130
172
  const id = input["id"];
@@ -144,7 +186,12 @@ async function dispatchServerWrite(descriptor, dispatcher, user, input, runId, t
144
186
  const requestId = `${runId}:${toolCallId}`;
145
187
  const result = await dispatcher.write(descriptor.qn, payload, user, requestId);
146
188
  if (!result.isSuccess) {
147
- return { ok: false, error: result.error.message };
189
+ return classifyFailure({
190
+ code: result.error.code,
191
+ httpStatus: result.error.httpStatus,
192
+ message: result.error.message,
193
+ retryAfterSeconds: result.error.retryAfterSeconds,
194
+ }, result.error);
148
195
  }
149
196
  const { id, version } = extractIdAndVersion(result.data);
150
197
  const scalars = { ...(id !== undefined && { id }), ...(version !== undefined && { version }) };
@@ -230,10 +277,13 @@ function dispatchClient(descriptor, input) {
230
277
  /** Executes one LLM-issued tool call. Never throws — a missing tool, a missing argument, a
231
278
  * disallowed filter field, or a rejected handler call (cap check, validation, ...) all come
232
279
  * back as `{ ok: false }` so the agent loop can feed the error back to the model instead of
233
- * crashing. `request.user` must be the caller's real identity — every server-kind tool runs
280
+ * crashing. Client errors (< 500) keep their message and carry a `code`; 429 adds
281
+ * `retryable`. Server-side failures (>= 500, unknown throws) are logged and returned as a
282
+ * generic message so internals never reach the model. `request.user` must be the caller's real identity — every server-kind tool runs
234
283
  * through the same permission-checked dispatcher pipeline as a normal HTTP request. */
235
284
  export async function dispatchToolCall(request) {
236
285
  const { dispatcher, user, toolName, input, dispatchTable, runId, toolCallId } = request;
286
+ const classifyFailure = createFailureClassifier(toolName, request.log ?? fallbackLog);
237
287
  const descriptor = dispatchTable.get(toolName);
238
288
  if (!descriptor) {
239
289
  return { ok: false, error: `Unknown tool "${toolName}"` };
@@ -252,11 +302,11 @@ export async function dispatchToolCall(request) {
252
302
  case "server":
253
303
  return descriptor.op === "query"
254
304
  ? await dispatchServerQuery(descriptor, dispatcher, user, input)
255
- : await dispatchServerWrite(descriptor, dispatcher, user, input, runId, toolCallId);
305
+ : await dispatchServerWrite(descriptor, dispatcher, user, input, runId, toolCallId, classifyFailure);
256
306
  }
257
307
  });
258
308
  }
259
309
  catch (err) {
260
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
310
+ return classifyFailure(toolFailureOf(err), err);
261
311
  }
262
312
  }
@@ -27,7 +27,7 @@ export function createAuditFeature() {
27
27
  recommended: false,
28
28
  });
29
29
  r.translations({ keys: AUDIT_I18N });
30
- r.requires("tenant", "user", "config");
30
+ r.requires({ features: ["tenant", "user", "config"] });
31
31
  // Registered so ops tools/MSPs can discover the type — escape-hatch-audit-sink.ts appends it via the low-level event-store API.
32
32
  r.defineEvent("escape-hatch-used", escapeHatchUsedSchema, { piiFields: "none" });
33
33
  r.config("escapeHatchRetentionDays", createSystemConfig("number", {
@@ -18,7 +18,6 @@ export declare const AuthHandlers: {
18
18
  readonly inviteAcceptWithLogin: "auth-email-password:write:invite-accept-with-login";
19
19
  readonly inviteSignupComplete: "auth-email-password:write:invite-signup-complete";
20
20
  readonly switchTenantMfaGate: "auth-email-password:write:switch-tenant-mfa-gate";
21
- readonly inviteCancel: "auth-email-password:write:invite-cancel";
22
21
  };
23
22
  export declare const AuthQueries: {
24
23
  readonly signupRegistrationStatus: "auth-email-password:query:signup-registration-status";
@@ -36,7 +36,6 @@ export const AuthHandlers = {
36
36
  // inviteAccept: logged-in user → POST token (membership-add)
37
37
  // inviteAcceptWithLogin: anon user with existing email → POST token + email + password
38
38
  // inviteSignupComplete: anon user with new email → POST token + password
39
- // inviteCancel: admin cancels a pending invite
40
39
  inviteCreate: "auth-email-password:write:invite-create",
41
40
  // System-only variant (access.system) that may carry global roles — see
42
41
  // invite-create.write.ts. Dispatched in-process by runBootstrap.
@@ -45,7 +44,6 @@ export const AuthHandlers = {
45
44
  inviteAcceptWithLogin: "auth-email-password:write:invite-accept-with-login",
46
45
  inviteSignupComplete: "auth-email-password:write:invite-signup-complete",
47
46
  switchTenantMfaGate: "auth-email-password:write:switch-tenant-mfa-gate",
48
- inviteCancel: "auth-email-password:write:invite-cancel",
49
47
  };
50
48
  // Qualified query names. Anonymous-readable status so the (unauthenticated)
51
49
  // signup page can decide whether to show its own link/form.
@@ -82,13 +82,13 @@ export function createAuthEmailPasswordFeature(opts = {}) {
82
82
  { key: "accountUnlock", label: "Account-Unlock-Flow", type: "boolean", default: false },
83
83
  ],
84
84
  });
85
- r.requires("user");
86
- r.requires("tenant");
85
+ r.requires({ features: ["user"] });
86
+ r.requires({ features: ["tenant"] });
87
87
  // All five magic-link flows (reset/verify/signup/invite/unlock) dispatch
88
88
  // via ctx.notify → delivery must be mounted. Fail closed at boot instead
89
89
  // of silently dropping the mail.
90
90
  if (sendsMagicLinkMail(opts))
91
- r.requires("delivery");
91
+ r.requires({ features: ["delivery"] });
92
92
  r.envSchema(authEmailPasswordEnvSchema);
93
93
  r.translations({ keys: AUTH_EMAIL_PASSWORD_MAIL_I18N });
94
94
  // Declared unconditionally: apps also issue the activation link from their
@@ -176,6 +176,7 @@ export function createInviteCreateHandler(opts) {
176
176
  name: "invite-create",
177
177
  schema: InviteCreateSchema,
178
178
  access: { roles: access.admin },
179
+ agent: { expose: false },
179
180
  description: "Invites an email address into the caller's tenant with a chosen membership role, creating or reusing the invitation and mailing the invitee a fresh accept link; re-inviting the same address invalidates the previous link.",
180
181
  handler: async (event, ctx) => {
181
182
  const roleRejection = checkAssignableInviteRole(opts, event.user.roles, event.payload.role, ctx);
@@ -86,12 +86,12 @@ export function createAuthMfaFeature(opts) {
86
86
  // shared variable; an app mounting auth-mfa without `secrets` still gets the
87
87
  // KEK slot (and its _CIPHERTEXT twin) declared.
88
88
  r.envSchema(secretsEnvSchema);
89
- r.requires("user");
90
- r.requires("config");
89
+ r.requires({ features: ["user"] });
90
+ r.requires({ features: ["config"] });
91
91
  // verify.write.ts dispatches "tenant:query:memberships" — without this
92
92
  // the app boots clean and the first MFA login dies at runtime on an
93
93
  // unknown handler instead of failing at mount time.
94
- r.requires("tenant");
94
+ r.requires({ features: ["tenant"] });
95
95
  r.config("required", mfaRequiredConfigKey(opts.requiredPolicy));
96
96
  // Declarative secretMint screen — mint (no input) -> reveal QR/secret/
97
97
  // recovery-codes -> confirm with a code. App places it via r.nav in its
@@ -112,7 +112,7 @@ export function createAuthMfaFeature(opts) {
112
112
  },
113
113
  handler: (payload, ctx) => mfaReencryptJob(payload, ctx, ctx.db.unsafeRaw()),
114
114
  });
115
- r.entity("user-mfa", userMfaEntity);
115
+ r.entity({ name: "user-mfa", ...userMfaEntity });
116
116
  // Late-bound by run-prod-app once the sessions feature (if mounted) has
117
117
  // produced a concrete `sessionRevokeAllOthers` callback — mirrors
118
118
  // sessions' own bindAutoRevokeOnPasswordChange. Shared by every handler
@@ -8,7 +8,7 @@ import { defineFeature, EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engi
8
8
  import { userMfaDeleteHook, userMfaExportHook } from "./hooks.js";
9
9
  export const authMfaUserDataFeature = defineFeature("auth-mfa-user-data", (r) => {
10
10
  r.describe("GDPR (Art. 20 export / Art. 17 erasure) coverage for the `auth-mfa` feature's `user-mfa` entity. Mounts the EXT_USER_DATA export + delete hooks so 2FA enrollment status is included in the user-data export bundle and a user's TOTP secret + recovery codes are hard-deleted (via executor.forget, rebuild-safe) on a data-subject erasure request. Kept separate from `auth-mfa` so consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `auth-mfa`.");
11
- r.requires("user-data-rights");
11
+ r.requires({ features: ["user-data-rights"] });
12
12
  r.optionalRequires("auth-mfa");
13
13
  r.useExtension(EXT_USER_DATA, "user-mfa", {
14
14
  export: userMfaExportHook,
@@ -22,7 +22,7 @@ function isConfirmableSubscriptionStatus(status) {
22
22
  return status === SubscriptionStatuses.active || status === SubscriptionStatuses.trialing;
23
23
  }
24
24
  export function registerConsumerProtection(r, options, consumerProtection) {
25
- r.requires("template-resolver", "delivery", "user", "tenant");
25
+ r.requires({ features: ["template-resolver", "delivery", "user", "tenant"] });
26
26
  registerConsumerProtectionMails(r);
27
27
  registerContractTermination(r, options, consumerProtection);
28
28
  r.defineEvent(CHECKOUT_CONSENT_RECORDED_EVENT_SHORT, checkoutConsentRecordedPayloadSchema, {
@@ -96,7 +96,7 @@ export function createBillingFoundationFeature(options = {}) {
96
96
  category: "billing",
97
97
  recommended: false,
98
98
  });
99
- r.requires("tenant-lifecycle", "compliance-profiles");
99
+ r.requires({ features: ["tenant-lifecycle", "compliance-profiles"] });
100
100
  // 5 fine-grained domain-events. All 5 share the same payload shape (a
101
101
  // subscription-state snapshot); the event type tags what happened.
102
102
  // Future consumers (billing-history, accounting) listen directly on
@@ -1,11 +1,11 @@
1
- import { deleteMany, runInOwnTransaction } from "@cosmicdrift/kumiko-framework/db";
1
+ import { deleteMany, isUniqueViolation, runInOwnTransaction, } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import { appendEventInTenantDb, getStreamVersionInTenantDb, } from "@cosmicdrift/kumiko-framework/event-store";
4
4
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
5
5
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
6
6
  import { rollingCapAggregateId } from "./aggregate-id.js";
7
7
  import { applyCapDeltaOn, readRollingCapUsage, resolveCapCounterScope, retryCounterWriteOnVersionConflict, retryOnStreamVersionConflict, } from "./book-cap-usage.js";
8
- import { CAP_COUNTER_ROLLING_AGGREGATE_TYPE, CAP_RESERVATION_TTL_MINUTES, ROLLING_INCREMENTED_EVENT_QN, ROLLING_RELEASED_EVENT_QN, } from "./constants.js";
8
+ import { CAP_COUNTER_ROLLING_AGGREGATE_TYPE, CAP_RESERVATION_TTL_MINUTES, CAP_SETTLED_TOMBSTONE_TTL_MINUTES, ROLLING_INCREMENTED_EVENT_QN, ROLLING_RELEASED_EVENT_QN, } from "./constants.js";
9
9
  import { capReservationsTable } from "./tables.js";
10
10
  const NOTHING_TO_RELEASE = { isSuccess: true, data: {} };
11
11
  async function insertReservationRow(txDb, user, id, draft) {
@@ -19,6 +19,7 @@ async function insertReservationRow(txDb, user, id, draft) {
19
19
  amount: draft.amount,
20
20
  expiresAt: now.add({ minutes: CAP_RESERVATION_TTL_MINUTES }),
21
21
  createdAt: now,
22
+ settledAt: null,
22
23
  });
23
24
  }
24
25
  function toHandle(ctx, id, outsideTransaction) {
@@ -94,7 +95,11 @@ export async function reserveRollingCap(ctx, options, outsideTransaction = true)
94
95
  // Deleting the row takes its row lock inside the handler transaction, so a release started after a
95
96
  // failed COMMIT waits for the real outcome and finds the row gone when the commit went through.
96
97
  async function confirmCapReservation(confirm, id) {
97
- await deleteMany(confirm.tx, capReservationsTable, { id, tenantId: confirm.tenantId });
98
+ await deleteMany(confirm.tx, capReservationsTable, {
99
+ id,
100
+ tenantId: confirm.tenantId,
101
+ settledAt: null,
102
+ });
98
103
  }
99
104
  async function giveBackCalendar(txDb, user, row) {
100
105
  if (row.periodStartIso === null) {
@@ -113,7 +118,10 @@ async function giveBackRolling(txDb, user, row) {
113
118
  }
114
119
  async function giveBackReservation(txDb, user, id) {
115
120
  const ownRow = { id, tenantId: user.tenantId };
116
- const row = await txDb.fetchOne(capReservationsTable, ownRow);
121
+ const row = await txDb.fetchOne(capReservationsTable, {
122
+ ...ownRow,
123
+ settledAt: null,
124
+ });
117
125
  if (!row)
118
126
  return NOTHING_TO_RELEASE;
119
127
  const claimed = await claimReservationRow(txDb, user, row);
@@ -127,9 +135,10 @@ async function giveBackReservation(txDb, user, id) {
127
135
  await txDb.deleteMany(capReservationsTable, ownRow);
128
136
  return given;
129
137
  }
130
- // Waits for a confirm still holding the row; false means that confirm (or another release) won.
138
+ // Waits for a confirm still holding the row; false means that confirm, a settle or another release
139
+ // won (Postgres re-checks settled_at after the lock wait).
131
140
  async function claimReservationRow(txDb, user, row) {
132
- const claimed = await txDb.updateMany(capReservationsTable, { expiresAt: row.expiresAt }, { id: row.id, tenantId: user.tenantId });
141
+ const claimed = await txDb.updateMany(capReservationsTable, { expiresAt: row.expiresAt }, { id: row.id, tenantId: user.tenantId, settledAt: null });
133
142
  return claimed.length > 0;
134
143
  }
135
144
  export async function releaseCapReservation(ctx, id, outsideTransaction = true) {
@@ -143,39 +152,78 @@ function bookCalendarDelta(txDb, user, period, delta) {
143
152
  return Promise.resolve({ isSuccess: true, data: {} });
144
153
  return applyCapDeltaOn(txDb, user, { ...period, amount: Math.abs(delta) }, delta > 0 ? "add" : "subtract");
145
154
  }
146
- // Replaces the reserved amount by the actual one in a single transaction. When the row is already
147
- // gone (swept or released), the reserved amount was given back, so the full actual amount is booked.
155
+ async function settleInTransaction(txDb, user, id, options) {
156
+ const ownRow = { id, tenantId: user.tenantId };
157
+ const open = await txDb.fetchOne(capReservationsTable, {
158
+ ...ownRow,
159
+ settledAt: null,
160
+ });
161
+ if (open && (await claimReservationRow(txDb, user, open))) {
162
+ const booked = await bookCalendarDelta(txDb, user, options, options.actualAmount - open.amount);
163
+ if (!booked.isSuccess)
164
+ return booked;
165
+ const now = Temporal.Now.instant();
166
+ await txDb.updateMany(capReservationsTable, { settledAt: now, expiresAt: now.add({ minutes: CAP_SETTLED_TOMBSTONE_TTL_MINUTES }) }, ownRow);
167
+ return booked;
168
+ }
169
+ // The claim lost to a concurrent settle or release: a tombstone means the amount is booked.
170
+ if (await txDb.fetchOne(capReservationsTable, ownRow)) {
171
+ return NOTHING_TO_RELEASE;
172
+ }
173
+ // Row swept or released: its amount was given back, so the full actual amount is booked and a
174
+ // tombstone makes a repeated settle a no-op. A parallel settle inserting first fails this insert.
175
+ const now = Temporal.Now.instant();
176
+ await txDb.insertOne(capReservationsTable, {
177
+ ...ownRow,
178
+ capName: options.capName,
179
+ kind: "calendar",
180
+ periodStartIso: options.periodStartIso,
181
+ amount: options.actualAmount,
182
+ expiresAt: now.add({ minutes: CAP_SETTLED_TOMBSTONE_TTL_MINUTES }),
183
+ createdAt: now,
184
+ settledAt: now,
185
+ });
186
+ return bookCalendarDelta(txDb, user, options, options.actualAmount);
187
+ }
188
+ // The failed tombstone insert aborts the whole attempt, so nothing was booked; the repeat sees the
189
+ // winner's tombstone.
190
+ async function retryOnceOnTombstoneRace(attempt) {
191
+ try {
192
+ return await attempt();
193
+ }
194
+ catch (error) {
195
+ if (!isUniqueViolation(error))
196
+ throw error;
197
+ return attempt();
198
+ }
199
+ }
200
+ // Replaces the reserved amount by the actual one in a single transaction and keeps the row as a
201
+ // settled tombstone, so a repeated settle (retry, lost ack) books nothing twice.
148
202
  export async function settleCalendarCapReservation(ctx, id, options, outsideTransaction = true) {
149
203
  if (!Number.isInteger(options.actualAmount) || options.actualAmount < 0) {
150
204
  throw new Error(`cap-counter: settle amount must be a non-negative integer, got ${options.actualAmount}`);
151
205
  }
152
206
  const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
153
- const settled = await retryCounterWriteOnVersionConflict(() => runInOwnTransaction(writeDb, async (txDb) => {
154
- const row = await txDb.fetchOne(capReservationsTable, {
155
- id,
156
- tenantId: user.tenantId,
157
- });
158
- const claimed = row ? await claimReservationRow(txDb, user, row) : false;
159
- if (!row || !claimed) {
160
- return bookCalendarDelta(txDb, user, options, options.actualAmount);
161
- }
162
- const booked = await bookCalendarDelta(txDb, user, options, options.actualAmount - row.amount);
163
- if (!booked.isSuccess)
164
- return booked;
165
- await txDb.deleteMany(capReservationsTable, { id, tenantId: user.tenantId });
166
- return booked;
167
- }));
207
+ const settled = await retryCounterWriteOnVersionConflict(() => retryOnceOnTombstoneRace(() => runInOwnTransaction(writeDb, (txDb) => settleInTransaction(txDb, user, id, options))));
168
208
  if (!settled.isSuccess)
169
209
  throw reraiseAsKumikoError(settled.error);
170
210
  }
171
211
  // A failed release leaves its row behind; giving it back once the TTL passed bounds how long that
172
- // capacity stays counted.
212
+ // capacity stays counted. Expired settled tombstones are only deleted: their amount stays booked.
173
213
  export async function releaseExpiredCapReservations(ctx, capName, outsideTransaction = true) {
174
214
  const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
175
- const expired = await writeDb.selectMany(capReservationsTable, {
215
+ const expiredWhere = {
176
216
  tenantId: user.tenantId,
177
217
  capName,
178
218
  expiresAt: { lt: Temporal.Now.instant() },
219
+ };
220
+ await writeDb.deleteMany(capReservationsTable, {
221
+ ...expiredWhere,
222
+ settledAt: { ne: null },
223
+ });
224
+ const expired = await writeDb.selectMany(capReservationsTable, {
225
+ ...expiredWhere,
226
+ settledAt: null,
179
227
  });
180
228
  for (const { id } of expired) {
181
229
  try {
@@ -6,6 +6,7 @@ export declare const ROLLING_INCREMENTED_EVENT_QN: "cap-counter:event:rolling-in
6
6
  export declare const ROLLING_RELEASED_EVENT_SHORT: "rolling-released";
7
7
  export declare const ROLLING_RELEASED_EVENT_QN: "cap-counter:event:rolling-released";
8
8
  export declare const CAP_RESERVATION_TTL_MINUTES = 60;
9
+ export declare const CAP_SETTLED_TOMBSTONE_TTL_MINUTES: number;
9
10
  export declare const CapCounterHandlers: {
10
11
  readonly increment: "cap-counter:write:increment";
11
12
  readonly incrementRolling: "cap-counter:write:increment-rolling";
@@ -22,6 +22,8 @@ export const ROLLING_RELEASED_EVENT_QN = "cap-counter:event:rolling-released";
22
22
  // A reservation row older than this is treated as a lost release and given back before the next
23
23
  // reservation for the same cap, which bounds the over-count a failed release can leave behind.
24
24
  export const CAP_RESERVATION_TTL_MINUTES = 60;
25
+ // A settled reservation stays as a tombstone this long so a retried or lost-ack settle stays a no-op.
26
+ export const CAP_SETTLED_TOMBSTONE_TTL_MINUTES = 24 * 60;
25
27
  // Qualified write handler names (QN format: scope:type:name).
26
28
  export const CapCounterHandlers = {
27
29
  increment: "cap-counter:write:increment",
@@ -1,7 +1,7 @@
1
1
  // cap-counter — Counter-Storage + Increment-API + Soft-Warn-State.
2
2
  //
3
3
  // **Was diese Feature liefert:**
4
- // 1. r.entity("cap-counter") — Counter-Rows pro (tenant, capName,
4
+ // 1. r.entity({ name: "cap-counter" }) — Counter-Rows pro (tenant, capName,
5
5
  // period) für Calendar-Period-Caps (Mails/Monat, Egress/Monat).
6
6
  // 2. increment-Handler — atomic counter increment via deterministic
7
7
  // aggregate-id (try-create / executor-update). Race-frei via
@@ -60,7 +60,7 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
60
60
  category: "operations",
61
61
  recommended: false,
62
62
  });
63
- r.entity("cap-counter", capCounterEntity);
63
+ r.entity({ name: "cap-counter", ...capCounterEntity });
64
64
  r.storeTable(capReservationsTableMeta, {
65
65
  reason: "coordination.cap_reservation_ledger",
66
66
  });
@@ -2,8 +2,9 @@ import { defineUnmanagedTable, index, instant, integer, table as pgTable, text,
2
2
  const CAP_RESERVATIONS_TENANT_CAP_EXPIRES_INDEX = "store_cap_reservations_tenant_id_cap_name_expires_at_idx";
3
3
  // One row per capacity that was booked before a handler transaction and is not settled yet. The
4
4
  // handler transaction deletes it on its own commit (confirm); a release or the TTL sweep deletes it
5
- // together with the counter decrement. Unmanaged: the rows are a coordination aid, not state with
6
- // an event history.
5
+ // together with the counter decrement. A settle keeps the row as a tombstone (settled_at set) until
6
+ // its shortened TTL passes, so a repeated settle or a late release finds it and does nothing.
7
+ // Unmanaged: the rows are a coordination aid, not state with an event history.
7
8
  export const capReservationsTable = pgTable("store_cap_reservations", {
8
9
  id: uuid("id").primaryKey(),
9
10
  tenantId: uuid("tenant_id").notNull(),
@@ -14,6 +15,7 @@ export const capReservationsTable = pgTable("store_cap_reservations", {
14
15
  amount: integer("amount").notNull(),
15
16
  expiresAt: instant("expires_at").notNull(),
16
17
  createdAt: instant("created_at").notNull(),
18
+ settledAt: instant("settled_at"),
17
19
  }, (t) => [index(CAP_RESERVATIONS_TENANT_CAP_EXPIRES_INDEX).on(t.tenantId, t.capName, t.expiresAt)]);
18
20
  export const capReservationsTableMeta = defineUnmanagedTable({
19
21
  tableName: "store_cap_reservations",
@@ -32,5 +34,6 @@ export const capReservationsTableMeta = defineUnmanagedTable({
32
34
  { name: "amount", pgType: "integer", notNull: true },
33
35
  { name: "expires_at", pgType: "timestamptz", notNull: true },
34
36
  { name: "created_at", pgType: "timestamptz", notNull: true },
37
+ { name: "settled_at", pgType: "timestamptz", notNull: false },
35
38
  ],
36
39
  });
@@ -43,9 +43,9 @@ export function createCapOverviewFeature(opts) {
43
43
  recommended: false,
44
44
  });
45
45
  r.systemScope();
46
- r.requires("tenant");
47
- r.requires("tier-engine");
48
- r.requires("billing-foundation");
46
+ r.requires({ features: ["tenant"] });
47
+ r.requires({ features: ["tier-engine"] });
48
+ r.requires({ features: ["billing-foundation"] });
49
49
  r.queryHandler(createTenantCapsListQuery(opts.caps, listCaps));
50
50
  r.queryHandler(createCapsUsageQuery(opts.caps, usageRoles));
51
51
  r.queryHandler(tenantOptionsQuery);
@@ -9,8 +9,8 @@ export function createChannelDiscordFeature(options = {}) {
9
9
  category: "notifications",
10
10
  recommended: false,
11
11
  });
12
- r.requires("delivery");
13
- r.requires("secrets");
12
+ r.requires({ features: ["delivery"] });
13
+ r.requires({ features: ["secrets"] });
14
14
  const webhookTarget = resolveChatWebhookTarget({
15
15
  defaultAllowedHosts: DISCORD_DEFAULT_ALLOWED_HOSTS,
16
16
  requiredPathPrefix: DISCORD_WEBHOOK_PATH_PREFIX,
@@ -10,7 +10,7 @@ export function createChannelEmailFeature(options) {
10
10
  category: "notifications",
11
11
  recommended: false,
12
12
  });
13
- r.requires("delivery");
13
+ r.requires({ features: ["delivery"] });
14
14
  r.translations({ keys: { "delivery.channel.email": { en: "Email" } } });
15
15
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, EMAIL_CHANNEL_NAME, {
16
16
  mode: channel.mode,
@@ -14,7 +14,7 @@ export function createChannelInAppFeature() {
14
14
  category: "notifications",
15
15
  recommended: false,
16
16
  });
17
- r.requires("delivery");
17
+ r.requires({ features: ["delivery"] });
18
18
  r.storeTable(inAppMessagesTableMeta, {
19
19
  reason: "read_side.in_app_messages",
20
20
  });
@@ -10,7 +10,7 @@ export function createChannelPushFeature(options) {
10
10
  category: "notifications",
11
11
  recommended: false,
12
12
  });
13
- r.requires("delivery");
13
+ r.requires({ features: ["delivery"] });
14
14
  r.translations({ keys: { "delivery.channel.push": { en: "Push" } } });
15
15
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "push", {
16
16
  mode: channel.mode,
@@ -9,8 +9,8 @@ export function createChannelSlackFeature(options = {}) {
9
9
  category: "notifications",
10
10
  recommended: false,
11
11
  });
12
- r.requires("delivery");
13
- r.requires("secrets");
12
+ r.requires({ features: ["delivery"] });
13
+ r.requires({ features: ["secrets"] });
14
14
  const webhookTarget = resolveChatWebhookTarget({ defaultAllowedHosts: SLACK_DEFAULT_ALLOWED_HOSTS }, options);
15
15
  const webhooks = r.secretNamespace("webhooks", {
16
16
  label: { en: "Slack webhooks", de: "Slack-Webhooks", es: "Webhooks de Slack" },
@@ -9,8 +9,8 @@ export function createChannelTeamsFeature(options = {}) {
9
9
  category: "notifications",
10
10
  recommended: false,
11
11
  });
12
- r.requires("delivery");
13
- r.requires("secrets");
12
+ r.requires({ features: ["delivery"] });
13
+ r.requires({ features: ["secrets"] });
14
14
  const webhookTarget = resolveChatWebhookTarget({ defaultAllowedHosts: TEAMS_DEFAULT_ALLOWED_HOSTS }, options);
15
15
  const webhooks = r.secretNamespace("webhooks", {
16
16
  label: {
@@ -10,8 +10,8 @@ export function createChannelTelegramFeature(options = {}) {
10
10
  category: "notifications",
11
11
  recommended: false,
12
12
  });
13
- r.requires("delivery");
14
- r.requires("secrets");
13
+ r.requires({ features: ["delivery"] });
14
+ r.requires({ features: ["secrets"] });
15
15
  const botToken = r.secret("botToken", {
16
16
  label: { en: "Telegram bot token" },
17
17
  hint: {
@@ -22,7 +22,7 @@ export function createComplianceProfilesFeature(options) {
22
22
  // Context, Profile-Selection ist eigene Entity, sub-processor-Liste
23
23
  // sind Constants. Wenn S1.4+ Cross-Feature-Reads dazukommen, kommt
24
24
  // r.requires hier rein.
25
- r.entity("tenant-compliance-profile", tenantComplianceProfileEntity);
25
+ r.entity({ name: "tenant-compliance-profile", ...tenantComplianceProfileEntity });
26
26
  r.exposesApi("compliance.forTenant");
27
27
  const handlers = {
28
28
  setProfile: r.writeHandler({ ...setProfileWrite, access: { roles: resolvedAccess } }),
@@ -7,7 +7,7 @@
7
7
  // a separate feature (mirrors folders-user-data/notes-history-user-data)
8
8
  // rather than folded into compliance-profiles: this is the one genuinely
9
9
  // cross-tenant capability in the picker's orbit, and it alone needs
10
- // r.systemScope() + a hard r.requires("tenant") — apps that only want the
10
+ // r.systemScope() + a hard r.requires({ features: ["tenant"] }) — apps that only want the
11
11
  // per-tenant picker (the vast majority) stay unaffected.
12
12
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
13
13
  import { tenantsMissingProfileQuery } from "./handlers/tenants-missing-profile.query.js";
@@ -19,8 +19,8 @@ export const complianceProfilesOpsFeature = defineFeature("compliance-profiles-o
19
19
  recommended: false,
20
20
  });
21
21
  r.systemScope();
22
- r.requires("compliance-profiles");
23
- r.requires("tenant");
22
+ r.requires({ features: ["compliance-profiles"] });
23
+ r.requires({ features: ["tenant"] });
24
24
  const queries = {
25
25
  tenantsMissingProfile: r.queryHandler(tenantsMissingProfileQuery),
26
26
  };