@cosmicdrift/kumiko-bundled-features 0.356.0 → 0.358.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 (84) hide show
  1. package/dist/auth-mfa/i18n.js +1 -0
  2. package/dist/auth-mfa/schema/user-mfa.d.ts +6 -0
  3. package/dist/auth-mfa/screens.js +8 -1
  4. package/dist/billing-foundation/entities.d.ts +8 -0
  5. package/dist/billing-foundation/projection.d.ts +8 -0
  6. package/dist/cap-counter/book-cap-usage.d.ts +11 -5
  7. package/dist/cap-counter/book-cap-usage.js +54 -13
  8. package/dist/cap-counter/calendar-cap-usage-reservation.d.ts +17 -0
  9. package/dist/cap-counter/calendar-cap-usage-reservation.js +53 -0
  10. package/dist/cap-counter/cap-reservation.d.ts +15 -6
  11. package/dist/cap-counter/cap-reservation.js +80 -31
  12. package/dist/cap-counter/enforce-cap.d.ts +5 -5
  13. package/dist/cap-counter/enforce-cap.js +7 -20
  14. package/dist/cap-counter/entity.d.ts +1 -0
  15. package/dist/cap-counter/feature.js +7 -1
  16. package/dist/cap-counter/index.d.ts +1 -0
  17. package/dist/cap-counter/index.js +1 -0
  18. package/dist/cap-counter/with-cap-enforcement.js +4 -19
  19. package/dist/cap-overview/screens.js +3 -1
  20. package/dist/config/table.d.ts +6 -0
  21. package/dist/crypto-shredding/handlers/forget-subject.write.js +14 -3
  22. package/dist/custom-fields/entity.d.ts +4 -0
  23. package/dist/data-retention/schema/tenant-retention-override.d.ts +4 -0
  24. package/dist/delivery/chat-webhook-sender.js +1 -1
  25. package/dist/delivery/constants.d.ts +1 -0
  26. package/dist/delivery/constants.js +4 -0
  27. package/dist/delivery/delivery-service.js +62 -25
  28. package/dist/delivery/feature.js +7 -1
  29. package/dist/delivery/jobs.js +25 -1
  30. package/dist/delivery/tables.d.ts +6 -0
  31. package/dist/delivery/testing.d.ts +1 -0
  32. package/dist/document-ingest-foundation/entity.d.ts +4 -0
  33. package/dist/folders/entity.d.ts +8 -0
  34. package/dist/form-draft/entity.d.ts +2 -0
  35. package/dist/inbound-mail-foundation/entities.d.ts +25 -0
  36. package/dist/inbound-mail-foundation/projection.d.ts +20 -0
  37. package/dist/jobs/job-run-logger.js +21 -17
  38. package/dist/jobs/job-run-table.d.ts +6 -0
  39. package/dist/jobs/job-run-table.js +10 -3
  40. package/dist/ledger/entity.d.ts +13 -0
  41. package/dist/managed-pages/table.d.ts +12 -0
  42. package/dist/notes-history/entity.d.ts +12 -0
  43. package/dist/notes-history/entity.js +9 -7
  44. package/dist/notes-history-user-data/hooks.d.ts +8 -0
  45. package/dist/notes-history-user-data/hooks.js +56 -72
  46. package/dist/notes-history-user-data/index.d.ts +1 -0
  47. package/dist/notes-history-user-data/index.js +2 -1
  48. package/dist/personal-access-tokens/schema/api-token.d.ts +6 -0
  49. package/dist/personal-access-tokens/screens.js +2 -0
  50. package/dist/secrets/table.d.ts +1 -0
  51. package/dist/sessions/schema/user-session.d.ts +4 -0
  52. package/dist/sessions/screens.js +2 -0
  53. package/dist/tags/entity.d.ts +9 -0
  54. package/dist/template-resolver/table.d.ts +10 -0
  55. package/dist/template-resolver/user-content-table.d.ts +10 -0
  56. package/dist/tenant/constants.d.ts +1 -0
  57. package/dist/tenant/constants.js +6 -3
  58. package/dist/tenant/feature.js +2 -0
  59. package/dist/tenant/handlers/member-role-availability.query.d.ts +7 -0
  60. package/dist/tenant/handlers/member-role-availability.query.js +18 -0
  61. package/dist/tenant/invitation-table.d.ts +6 -0
  62. package/dist/tenant/member-role-options.d.ts +2 -0
  63. package/dist/tenant/member-role-options.js +4 -0
  64. package/dist/tenant/membership-table.d.ts +4 -0
  65. package/dist/tenant/schema/tenant.d.ts +6 -0
  66. package/dist/tenant/screens.d.ts +4 -2
  67. package/dist/tenant/screens.js +10 -6
  68. package/dist/tier-engine/entity.d.ts +2 -0
  69. package/dist/tier-engine/tier-resolver.d.ts +2 -0
  70. package/dist/user/schema/user.d.ts +12 -0
  71. package/dist/user/screens.js +8 -1
  72. package/dist/user-data-rights/schema/download-attempt.d.ts +14 -0
  73. package/dist/user-data-rights/schema/download-token.d.ts +8 -0
  74. package/dist/user-data-rights/schema/export-job.d.ts +6 -0
  75. package/dist/user-data-rights/screens.js +8 -1
  76. package/dist/waitlist/entity.d.ts +14 -0
  77. package/dist/waitlist/screens.js +8 -1
  78. package/package.json +9 -9
  79. package/src/auth-mfa/changes.json +6 -0
  80. package/src/cap-counter/changes.json +7 -0
  81. package/src/crypto-shredding/changes.json +6 -0
  82. package/src/delivery/changes.json +6 -0
  83. package/src/notes-history/changes.json +6 -0
  84. package/src/tenant/changes.json +6 -0
@@ -19,6 +19,7 @@ export const AUTH_MFA_FEATURE_I18N = {
19
19
  "mfa.enable.reveal.qr": { en: "Scan this QR code" },
20
20
  "mfa.enable.reveal.secret": { en: "Or enter this code manually" },
21
21
  "mfa.enable.reveal.recoveryCodes": { en: "Recovery codes" },
22
+ "mfa.enable.reveal.savedCodes": { en: "I have saved my recovery codes." },
22
23
  "mfa.enable.confirm.submit": { en: "Enable" },
23
24
  "mfa.enable.confirm.done": { en: "Two-factor authentication is now enabled." },
24
25
  "screen:auth-mfa-disable.title": { en: "Disable two-factor authentication" },
@@ -4,6 +4,7 @@ export declare const userMfaEntity: import("@cosmicdrift/kumiko-types/fields").E
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
6
  readonly minLength?: number;
7
+ readonly autoComplete?: string;
7
8
  readonly required?: boolean;
8
9
  readonly searchable?: boolean;
9
10
  readonly sortable?: boolean;
@@ -26,6 +27,7 @@ export declare const userMfaEntity: import("@cosmicdrift/kumiko-types/fields").E
26
27
  readonly description?: string;
27
28
  readonly maxLength?: number;
28
29
  readonly minLength?: number;
30
+ readonly autoComplete?: string;
29
31
  readonly required?: boolean;
30
32
  readonly searchable?: boolean;
31
33
  readonly sortable?: boolean;
@@ -48,6 +50,7 @@ export declare const userMfaEntity: import("@cosmicdrift/kumiko-types/fields").E
48
50
  readonly description?: string;
49
51
  readonly maxLength?: number;
50
52
  readonly minLength?: number;
53
+ readonly autoComplete?: string;
51
54
  readonly required?: boolean;
52
55
  readonly searchable?: boolean;
53
56
  readonly sortable?: boolean;
@@ -102,6 +105,7 @@ export declare const userMfaTable: import("@cosmicdrift/kumiko-framework/db").En
102
105
  readonly description?: string;
103
106
  readonly maxLength?: number;
104
107
  readonly minLength?: number;
108
+ readonly autoComplete?: string;
105
109
  readonly required?: boolean;
106
110
  readonly searchable?: boolean;
107
111
  readonly sortable?: boolean;
@@ -124,6 +128,7 @@ export declare const userMfaTable: import("@cosmicdrift/kumiko-framework/db").En
124
128
  readonly description?: string;
125
129
  readonly maxLength?: number;
126
130
  readonly minLength?: number;
131
+ readonly autoComplete?: string;
127
132
  readonly required?: boolean;
128
133
  readonly searchable?: boolean;
129
134
  readonly sortable?: boolean;
@@ -146,6 +151,7 @@ export declare const userMfaTable: import("@cosmicdrift/kumiko-framework/db").En
146
151
  readonly description?: string;
147
152
  readonly maxLength?: number;
148
153
  readonly minLength?: number;
154
+ readonly autoComplete?: string;
149
155
  readonly required?: boolean;
150
156
  readonly searchable?: boolean;
151
157
  readonly sortable?: boolean;
@@ -23,6 +23,7 @@ export const mfaEnableScreen = {
23
23
  reveal: {
24
24
  title: i18nKey("mfa.enable.reveal.title"),
25
25
  warning: i18nKey("mfa.enable.reveal.warning"),
26
+ acknowledge: i18nKey("mfa.enable.reveal.savedCodes"),
26
27
  fields: [
27
28
  {
28
29
  field: "otpauthUri",
@@ -47,7 +48,13 @@ export const mfaEnableScreen = {
47
48
  confirm: {
48
49
  handler: AuthMfaHandlers.enableConfirm,
49
50
  fields: {
50
- code: { type: "text", required: true, minLength: 6, maxLength: 6 },
51
+ code: {
52
+ type: "text",
53
+ required: true,
54
+ minLength: 6,
55
+ maxLength: 6,
56
+ autoComplete: "one-time-code",
57
+ },
51
58
  },
52
59
  layout: { sections: [{ fields: ["code"] }] },
53
60
  carry: ["setupToken"],
@@ -4,6 +4,7 @@ export declare const subscriptionEntity: import("@cosmicdrift/kumiko-types/field
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
6
  readonly minLength?: number;
7
+ readonly autoComplete?: string;
7
8
  readonly required?: boolean;
8
9
  readonly searchable?: boolean;
9
10
  readonly sortable?: boolean;
@@ -26,6 +27,7 @@ export declare const subscriptionEntity: import("@cosmicdrift/kumiko-types/field
26
27
  readonly description?: string;
27
28
  readonly maxLength?: number;
28
29
  readonly minLength?: number;
30
+ readonly autoComplete?: string;
29
31
  readonly required?: boolean;
30
32
  readonly searchable?: boolean;
31
33
  readonly sortable?: boolean;
@@ -48,6 +50,7 @@ export declare const subscriptionEntity: import("@cosmicdrift/kumiko-types/field
48
50
  readonly description?: string;
49
51
  readonly maxLength?: number;
50
52
  readonly minLength?: number;
53
+ readonly autoComplete?: string;
51
54
  readonly required?: boolean;
52
55
  readonly searchable?: boolean;
53
56
  readonly sortable?: boolean;
@@ -70,6 +73,7 @@ export declare const subscriptionEntity: import("@cosmicdrift/kumiko-types/field
70
73
  readonly description?: string;
71
74
  readonly maxLength?: number;
72
75
  readonly minLength?: number;
76
+ readonly autoComplete?: string;
73
77
  readonly required?: boolean;
74
78
  readonly searchable?: boolean;
75
79
  readonly sortable?: boolean;
@@ -92,6 +96,7 @@ export declare const subscriptionEntity: import("@cosmicdrift/kumiko-types/field
92
96
  readonly description?: string;
93
97
  readonly maxLength?: number;
94
98
  readonly minLength?: number;
99
+ readonly autoComplete?: string;
95
100
  readonly required?: boolean;
96
101
  readonly searchable?: boolean;
97
102
  readonly sortable?: boolean;
@@ -147,6 +152,7 @@ export declare const paymentEntity: import("@cosmicdrift/kumiko-types/fields").E
147
152
  readonly description?: string;
148
153
  readonly maxLength?: number;
149
154
  readonly minLength?: number;
155
+ readonly autoComplete?: string;
150
156
  readonly required?: boolean;
151
157
  readonly searchable?: boolean;
152
158
  readonly sortable?: boolean;
@@ -169,6 +175,7 @@ export declare const paymentEntity: import("@cosmicdrift/kumiko-types/fields").E
169
175
  readonly description?: string;
170
176
  readonly maxLength?: number;
171
177
  readonly minLength?: number;
178
+ readonly autoComplete?: string;
172
179
  readonly required?: boolean;
173
180
  readonly searchable?: boolean;
174
181
  readonly sortable?: boolean;
@@ -191,6 +198,7 @@ export declare const paymentEntity: import("@cosmicdrift/kumiko-types/fields").E
191
198
  readonly description?: string;
192
199
  readonly maxLength?: number;
193
200
  readonly minLength?: number;
201
+ readonly autoComplete?: string;
194
202
  readonly required?: boolean;
195
203
  readonly searchable?: boolean;
196
204
  readonly sortable?: boolean;
@@ -4,6 +4,7 @@ export declare const subscriptionsProjectionTable: import("@cosmicdrift/kumiko-f
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
6
  readonly minLength?: number;
7
+ readonly autoComplete?: string;
7
8
  readonly required?: boolean;
8
9
  readonly searchable?: boolean;
9
10
  readonly sortable?: boolean;
@@ -26,6 +27,7 @@ export declare const subscriptionsProjectionTable: import("@cosmicdrift/kumiko-f
26
27
  readonly description?: string;
27
28
  readonly maxLength?: number;
28
29
  readonly minLength?: number;
30
+ readonly autoComplete?: string;
29
31
  readonly required?: boolean;
30
32
  readonly searchable?: boolean;
31
33
  readonly sortable?: boolean;
@@ -48,6 +50,7 @@ export declare const subscriptionsProjectionTable: import("@cosmicdrift/kumiko-f
48
50
  readonly description?: string;
49
51
  readonly maxLength?: number;
50
52
  readonly minLength?: number;
53
+ readonly autoComplete?: string;
51
54
  readonly required?: boolean;
52
55
  readonly searchable?: boolean;
53
56
  readonly sortable?: boolean;
@@ -70,6 +73,7 @@ export declare const subscriptionsProjectionTable: import("@cosmicdrift/kumiko-f
70
73
  readonly description?: string;
71
74
  readonly maxLength?: number;
72
75
  readonly minLength?: number;
76
+ readonly autoComplete?: string;
73
77
  readonly required?: boolean;
74
78
  readonly searchable?: boolean;
75
79
  readonly sortable?: boolean;
@@ -92,6 +96,7 @@ export declare const subscriptionsProjectionTable: import("@cosmicdrift/kumiko-f
92
96
  readonly description?: string;
93
97
  readonly maxLength?: number;
94
98
  readonly minLength?: number;
99
+ readonly autoComplete?: string;
95
100
  readonly required?: boolean;
96
101
  readonly searchable?: boolean;
97
102
  readonly sortable?: boolean;
@@ -146,6 +151,7 @@ export declare const paymentsProjectionTable: import("@cosmicdrift/kumiko-framew
146
151
  readonly description?: string;
147
152
  readonly maxLength?: number;
148
153
  readonly minLength?: number;
154
+ readonly autoComplete?: string;
149
155
  readonly required?: boolean;
150
156
  readonly searchable?: boolean;
151
157
  readonly sortable?: boolean;
@@ -168,6 +174,7 @@ export declare const paymentsProjectionTable: import("@cosmicdrift/kumiko-framew
168
174
  readonly description?: string;
169
175
  readonly maxLength?: number;
170
176
  readonly minLength?: number;
177
+ readonly autoComplete?: string;
171
178
  readonly required?: boolean;
172
179
  readonly searchable?: boolean;
173
180
  readonly sortable?: boolean;
@@ -190,6 +197,7 @@ export declare const paymentsProjectionTable: import("@cosmicdrift/kumiko-framew
190
197
  readonly description?: string;
191
198
  readonly maxLength?: number;
192
199
  readonly minLength?: number;
200
+ readonly autoComplete?: string;
193
201
  readonly required?: boolean;
194
202
  readonly searchable?: boolean;
195
203
  readonly sortable?: boolean;
@@ -1,5 +1,5 @@
1
1
  import { type TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
- import { type HandlerContext, type SessionUser, type TenantId, type WriteResult } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type HandlerContext, type JobContext, type SessionUser, type TenantId, type WriteResult } from "@cosmicdrift/kumiko-framework/engine";
3
3
  export declare function retryCounterWriteOnVersionConflict(writeAttempt: () => Promise<WriteResult>): Promise<WriteResult>;
4
4
  export declare function retryOnStreamVersionConflict<T>(attempt: () => Promise<T>): Promise<T>;
5
5
  export type BookCapUsageOptions = {
@@ -9,16 +9,22 @@ export type BookCapUsageOptions = {
9
9
  readonly outsideTransaction?: boolean;
10
10
  readonly guardCurrentValue?: (currentValue: number) => void;
11
11
  };
12
- export declare function requireOutsideTransactionDb(ctx: HandlerContext): TenantDb;
13
- export declare function bookCapUsage(ctx: HandlerContext, options: BookCapUsageOptions): Promise<WriteResult>;
14
- export declare function releaseCapUsage(ctx: HandlerContext, options: Omit<BookCapUsageOptions, "guardCurrentValue">): Promise<WriteResult>;
12
+ export type CapCounterContext = HandlerContext | JobContext;
13
+ export type CapCounterScope = {
14
+ readonly user: SessionUser;
15
+ readonly readDb: TenantDb;
16
+ readonly writeDb: TenantDb;
17
+ };
18
+ export declare function resolveCapCounterScope(ctx: CapCounterContext, outsideTransaction: boolean): CapCounterScope;
19
+ export declare function bookCapUsage(ctx: CapCounterContext, options: BookCapUsageOptions): Promise<WriteResult>;
20
+ export declare function releaseCapUsage(ctx: CapCounterContext, options: Omit<BookCapUsageOptions, "guardCurrentValue">): Promise<WriteResult>;
15
21
  export declare function applyCapDeltaOn(db: TenantDb, user: SessionUser, options: BookCapUsageOptions, direction: "add" | "subtract"): Promise<WriteResult>;
16
22
  export type MarkCapSoftWarnedOptions = {
17
23
  readonly capName: string;
18
24
  readonly periodStartIso: string;
19
25
  readonly outsideTransaction?: boolean;
20
26
  };
21
- export declare function markCapSoftWarned(ctx: HandlerContext, options: MarkCapSoftWarnedOptions): Promise<WriteResult>;
27
+ export declare function markCapSoftWarned(ctx: CapCounterContext, options: MarkCapSoftWarnedOptions): Promise<WriteResult>;
22
28
  export type ReadRollingCapUsageOptions = {
23
29
  readonly capName: string;
24
30
  readonly windowDays: number;
@@ -1,6 +1,7 @@
1
1
  // In-process cap booking for the caller's own tenant; no SystemAdmin dispatch needed.
2
2
  import { runInOwnTransaction } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { createEntityExecutor, } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
4
5
  import { eventsTable, VersionConflictError } from "@cosmicdrift/kumiko-framework/event-store";
5
6
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
6
7
  import * as z from "zod";
@@ -48,11 +49,51 @@ export async function retryOnStreamVersionConflict(attempt) {
48
49
  }
49
50
  }
50
51
  }
51
- export function requireOutsideTransactionDb(ctx) {
52
- if (!ctx.dbOutsideTransaction) {
53
- throw new Error("cap-counter.bookCapUsage: outsideTransaction requested but ctx.dbOutsideTransaction is undefined");
52
+ // A tenant-mode db pins every row it writes to its own tenant; counter and reservation row would
53
+ // silently land on different tenants if that is not the user's.
54
+ function assertDbServesUser(db, user) {
55
+ if (db.mode === "tenant" && db.tenantId !== user.tenantId) {
56
+ throw new AccessDeniedError({
57
+ message: "cap-counter: the context's database belongs to a different tenant than its user",
58
+ });
54
59
  }
55
- return ctx.dbOutsideTransaction;
60
+ return db;
61
+ }
62
+ // Jobs write in place (their db is not transaction-bound); systemScope handlers reach both dbs via
63
+ // systemDb, because their ctx.db / ctx.dbOutsideTransaction are fail-closed proxies.
64
+ export function resolveCapCounterScope(ctx, outsideTransaction) {
65
+ // A job context spread into a handler-shaped one (user set) is a handler context: its user, not
66
+ // the job's systemUser, names the metered tenant.
67
+ if (!("user" in ctx) || !ctx.user) {
68
+ const jobUser = "systemUser" in ctx ? ctx.systemUser : undefined;
69
+ if (!jobUser) {
70
+ throw new Error("cap-counter: ctx.user.tenantId missing — required to resolve the counter");
71
+ }
72
+ const db = assertDbServesUser(ctx.db, jobUser);
73
+ return { user: jobUser, readDb: db, writeDb: db };
74
+ }
75
+ const { user } = ctx;
76
+ if (!user.tenantId) {
77
+ throw new Error("cap-counter: ctx.user.tenantId missing — required to resolve the counter");
78
+ }
79
+ if (ctx.systemDb) {
80
+ const readDb = ctx.systemDb.assertTenantMatch(user.tenantId);
81
+ const writeDb = outsideTransaction
82
+ ? ctx.systemDb.outsideTransaction.assertTenantMatch(user.tenantId)
83
+ : readDb;
84
+ return { user, readDb, writeDb };
85
+ }
86
+ const writeDb = outsideTransaction ? ctx.dbOutsideTransaction : ctx.db;
87
+ if (!writeDb) {
88
+ throw new Error(outsideTransaction
89
+ ? "cap-counter: outsideTransaction requested but ctx.dbOutsideTransaction is undefined"
90
+ : "cap-counter: ctx.db missing — run inside a handler context");
91
+ }
92
+ return {
93
+ user,
94
+ readDb: assertDbServesUser(ctx.db, user),
95
+ writeDb: assertDbServesUser(writeDb, user),
96
+ };
56
97
  }
57
98
  export async function bookCapUsage(ctx, options) {
58
99
  return applyCapDelta(ctx, options, "add");
@@ -96,19 +137,20 @@ export async function applyCapDeltaOn(db, user, options, direction) {
96
137
  }, user, db);
97
138
  }
98
139
  async function applyCapDelta(ctx, options, direction) {
140
+ const { user, writeDb } = resolveCapCounterScope(ctx, options.outsideTransaction === true);
99
141
  if (options.outsideTransaction) {
100
- const outsideDb = requireOutsideTransactionDb(ctx);
101
- return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, (txDb) => applyCapDeltaOn(txDb, ctx.user, options, direction)));
142
+ return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(writeDb, (txDb) => applyCapDeltaOn(txDb, user, options, direction)));
102
143
  }
103
- return retryCounterWriteOnVersionConflict(() => applyCapDeltaOn(ctx.db, ctx.user, options, direction));
144
+ return retryCounterWriteOnVersionConflict(() => applyCapDeltaOn(writeDb, user, options, direction));
104
145
  }
105
146
  export async function markCapSoftWarned(ctx, options) {
106
147
  const parsed = capPeriodSchema.parse(options);
107
- const aggregateId = capCounterAggregateId(ctx.user.tenantId, parsed.capName, parsed.periodStartIso);
148
+ const { user, writeDb } = resolveCapCounterScope(ctx, options.outsideTransaction === true);
149
+ const aggregateId = capCounterAggregateId(user.tenantId, parsed.capName, parsed.periodStartIso);
108
150
  async function attemptMark(db) {
109
151
  const existing = await db.selectMany(table, { id: aggregateId }, { limit: 1 });
110
152
  if (existing.length === 0) {
111
- throw new Error(`cap-counter: cannot mark-soft-warned, no counter found for tenant=${ctx.user.tenantId} cap=${parsed.capName} period=${parsed.periodStartIso}`);
153
+ throw new Error(`cap-counter: cannot mark-soft-warned, no counter found for tenant=${user.tenantId} cap=${parsed.capName} period=${parsed.periodStartIso}`);
112
154
  }
113
155
  const row = existing[0];
114
156
  if (!row) {
@@ -124,13 +166,12 @@ export async function markCapSoftWarned(ctx, options) {
124
166
  id: aggregateId,
125
167
  version: currentVersion,
126
168
  changes: { lastSoftWarnedAt: Temporal.Now.instant() },
127
- }, ctx.user, db);
169
+ }, user, db);
128
170
  }
129
171
  if (options.outsideTransaction) {
130
- const outsideDb = requireOutsideTransactionDb(ctx);
131
- return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, attemptMark));
172
+ return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(writeDb, attemptMark));
132
173
  }
133
- return retryCounterWriteOnVersionConflict(() => attemptMark(ctx.db));
174
+ return retryCounterWriteOnVersionConflict(() => attemptMark(writeDb));
134
175
  }
135
176
  async function sumRollingEventAmounts(db, tenantId, options, type) {
136
177
  const aggregateId = rollingCapAggregateId(tenantId, options.capName);
@@ -0,0 +1,17 @@
1
+ import type { CapCounterContext } from "./book-cap-usage.js";
2
+ import { type CalendarCapReservation } from "./cap-reservation.js";
3
+ import { type SoftHitNotifier } from "./enforce-cap.js";
4
+ import type { CalendarCapDef } from "./with-cap-enforcement.js";
5
+ export type CalendarCapUsageReservationSpec = Omit<CalendarCapDef, "amount" | "notify"> & {
6
+ readonly amount: number;
7
+ readonly notify?: SoftHitNotifier;
8
+ readonly outsideTransaction?: boolean;
9
+ };
10
+ export type CalendarCapUsageReservation = {
11
+ readonly amount: number;
12
+ readonly counterValue: number;
13
+ settle(actualAmount: number): Promise<void>;
14
+ release(): Promise<void>;
15
+ };
16
+ export declare function reserveEnforcedCalendarCap(ctx: CapCounterContext, cap: CalendarCapUsageReservationSpec): Promise<CalendarCapReservation>;
17
+ export declare function reserveCalendarCapUsage(ctx: CapCounterContext, spec: CalendarCapUsageReservationSpec): Promise<CalendarCapUsageReservation>;
@@ -0,0 +1,53 @@
1
+ import { releaseExpiredCapReservations, reserveCalendarCap, settleCalendarCapReservation, } from "./cap-reservation.js";
2
+ import { assertBelowHardCap, enforceCapAndMaybeNotify, } from "./enforce-cap.js";
3
+ // Shared by withCapEnforcement and the public reservation API so both enforce identically.
4
+ export async function reserveEnforcedCalendarCap(ctx, cap) {
5
+ const outsideTransaction = cap.outsideTransaction ?? true;
6
+ await releaseExpiredCapReservations(ctx, cap.capName, outsideTransaction);
7
+ if (cap.notify) {
8
+ await enforceCapAndMaybeNotify(ctx, {
9
+ capName: cap.capName,
10
+ periodStartIso: cap.periodStartIso,
11
+ limit: cap.limit,
12
+ profile: cap.profile,
13
+ notify: cap.notify,
14
+ amount: cap.amount,
15
+ markSoftWarnedOutsideTransaction: outsideTransaction,
16
+ });
17
+ }
18
+ return reserveCalendarCap(ctx, {
19
+ capName: cap.capName,
20
+ amount: cap.amount,
21
+ periodStartIso: cap.periodStartIso,
22
+ guardCurrentValue: (currentValue) => assertBelowHardCap(currentValue + cap.amount - 1, cap),
23
+ }, outsideTransaction);
24
+ }
25
+ export async function reserveCalendarCapUsage(ctx, spec) {
26
+ const outsideTransaction = spec.outsideTransaction ?? true;
27
+ const reservation = await reserveEnforcedCalendarCap(ctx, spec);
28
+ let state = "open";
29
+ return {
30
+ amount: spec.amount,
31
+ counterValue: reservation.counterValue,
32
+ async settle(actualAmount) {
33
+ if (state !== "open") {
34
+ throw new Error(`cap-counter: reservation for "${spec.capName}" is already ${state}`);
35
+ }
36
+ state = "settled";
37
+ try {
38
+ await settleCalendarCapReservation(ctx, reservation.reservationId, { capName: spec.capName, periodStartIso: spec.periodStartIso, actualAmount }, outsideTransaction);
39
+ }
40
+ catch (error) {
41
+ state = "open";
42
+ throw error;
43
+ }
44
+ },
45
+ async release() {
46
+ // skip: release after settle, or a second release, is a no-op by contract.
47
+ if (state !== "open")
48
+ return;
49
+ await reservation.release();
50
+ state = "released";
51
+ },
52
+ };
53
+ }
@@ -1,12 +1,21 @@
1
- import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
1
  import type { ReservationHandle } from "@cosmicdrift/kumiko-types/handlers";
3
- import { type BookCapUsageOptions, type ReadRollingCapUsageOptions } from "./book-cap-usage.js";
2
+ import { type BookCapUsageOptions, type CapCounterContext, type ReadRollingCapUsageOptions } from "./book-cap-usage.js";
3
+ export type CalendarCapReservation = ReservationHandle & {
4
+ readonly reservationId: string;
5
+ readonly counterValue: number;
6
+ };
4
7
  export type ReserveCalendarCapOptions = Required<Pick<BookCapUsageOptions, "capName" | "periodStartIso" | "amount">> & Pick<BookCapUsageOptions, "guardCurrentValue">;
5
- export declare function reserveCalendarCap(ctx: HandlerContext, options: ReserveCalendarCapOptions): Promise<ReservationHandle>;
8
+ export declare function reserveCalendarCap(ctx: CapCounterContext, options: ReserveCalendarCapOptions, outsideTransaction?: boolean): Promise<CalendarCapReservation>;
6
9
  export type ReserveRollingCapOptions = ReadRollingCapUsageOptions & {
7
10
  readonly amount: number;
8
11
  readonly guardCurrentValue?: (currentValue: number) => void;
9
12
  };
10
- export declare function reserveRollingCap(ctx: HandlerContext, options: ReserveRollingCapOptions): Promise<ReservationHandle>;
11
- export declare function releaseCapReservation(ctx: HandlerContext, id: string): Promise<void>;
12
- export declare function releaseExpiredCapReservations(ctx: HandlerContext, capName: string): Promise<void>;
13
+ export declare function reserveRollingCap(ctx: CapCounterContext, options: ReserveRollingCapOptions, outsideTransaction?: boolean): Promise<ReservationHandle>;
14
+ export declare function releaseCapReservation(ctx: CapCounterContext, id: string, outsideTransaction?: boolean): Promise<void>;
15
+ export type SettleCalendarCapOptions = {
16
+ readonly capName: string;
17
+ readonly periodStartIso: string;
18
+ readonly actualAmount: number;
19
+ };
20
+ export declare function settleCalendarCapReservation(ctx: CapCounterContext, id: string, options: SettleCalendarCapOptions, outsideTransaction?: boolean): Promise<void>;
21
+ export declare function releaseExpiredCapReservations(ctx: CapCounterContext, capName: string, outsideTransaction?: boolean): Promise<void>;
@@ -4,14 +4,15 @@ import { appendEventInTenantDb, getStreamVersionInTenantDb, } from "@cosmicdrift
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
- import { applyCapDeltaOn, readRollingCapUsage, requireOutsideTransactionDb, retryCounterWriteOnVersionConflict, retryOnStreamVersionConflict, } from "./book-cap-usage.js";
7
+ import { applyCapDeltaOn, readRollingCapUsage, resolveCapCounterScope, retryCounterWriteOnVersionConflict, retryOnStreamVersionConflict, } from "./book-cap-usage.js";
8
8
  import { CAP_COUNTER_ROLLING_AGGREGATE_TYPE, CAP_RESERVATION_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
- async function insertReservationRow(txDb, id, draft) {
11
+ async function insertReservationRow(txDb, user, id, draft) {
12
12
  const now = Temporal.Now.instant();
13
13
  await txDb.insertOne(capReservationsTable, {
14
14
  id,
15
+ tenantId: user.tenantId,
15
16
  capName: draft.capName,
16
17
  kind: draft.kind,
17
18
  periodStartIso: draft.periodStartIso ?? null,
@@ -20,22 +21,31 @@ async function insertReservationRow(txDb, id, draft) {
20
21
  createdAt: now,
21
22
  });
22
23
  }
23
- function toHandle(ctx, id) {
24
+ function toHandle(ctx, id, outsideTransaction) {
24
25
  return {
25
- release: () => releaseCapReservation(ctx, id),
26
+ release: () => releaseCapReservation(ctx, id, outsideTransaction),
26
27
  confirmInTransaction: (confirm) => confirmCapReservation(confirm, id),
27
28
  };
28
29
  }
29
30
  // The counter increment and the reservation row commit together, so a booked amount never exists
30
31
  // without a row that can give it back.
31
- export async function reserveCalendarCap(ctx, options) {
32
- const outsideDb = requireOutsideTransactionDb(ctx);
32
+ export async function reserveCalendarCap(ctx, options, outsideTransaction = true) {
33
+ const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
33
34
  const id = generateId();
34
- const reserved = await retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, async (txDb) => {
35
- const booked = await applyCapDeltaOn(txDb, ctx.user, options, "add");
35
+ // The last attempt is the one that committed, so this holds the value its increment started from.
36
+ let valueBeforeBooking = 0;
37
+ const observedOptions = {
38
+ ...options,
39
+ guardCurrentValue: (currentValue) => {
40
+ options.guardCurrentValue?.(currentValue);
41
+ valueBeforeBooking = currentValue;
42
+ },
43
+ };
44
+ const reserved = await retryCounterWriteOnVersionConflict(() => runInOwnTransaction(writeDb, async (txDb) => {
45
+ const booked = await applyCapDeltaOn(txDb, user, observedOptions, "add");
36
46
  if (!booked.isSuccess)
37
47
  return booked;
38
- await insertReservationRow(txDb, id, {
48
+ await insertReservationRow(txDb, user, id, {
39
49
  capName: options.capName,
40
50
  kind: "calendar",
41
51
  periodStartIso: options.periodStartIso,
@@ -45,7 +55,11 @@ export async function reserveCalendarCap(ctx, options) {
45
55
  }));
46
56
  if (!reserved.isSuccess)
47
57
  throw reraiseAsKumikoError(reserved.error);
48
- return toHandle(ctx, id);
58
+ return {
59
+ ...toHandle(ctx, id, outsideTransaction),
60
+ reservationId: id,
61
+ counterValue: valueBeforeBooking + options.amount,
62
+ };
49
63
  }
50
64
  function rollingEvent(user, capName, amount, expectedVersion, type) {
51
65
  return {
@@ -59,23 +73,23 @@ function rollingEvent(user, capName, amount, expectedVersion, type) {
59
73
  }
60
74
  // The append is version-guarded: a booker that read the usage before another one appended loses the
61
75
  // race and repeats the whole attempt, so the hard-cap check always sees the stream it appends to.
62
- export async function reserveRollingCap(ctx, options) {
63
- const outsideDb = requireOutsideTransactionDb(ctx);
76
+ export async function reserveRollingCap(ctx, options, outsideTransaction = true) {
77
+ const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
64
78
  const id = generateId();
65
- const aggregateId = rollingCapAggregateId(ctx.user.tenantId, options.capName);
66
- await retryOnStreamVersionConflict(() => runInOwnTransaction(outsideDb, async (txDb) => {
79
+ const aggregateId = rollingCapAggregateId(user.tenantId, options.capName);
80
+ await retryOnStreamVersionConflict(() => runInOwnTransaction(writeDb, async (txDb) => {
67
81
  const version = await getStreamVersionInTenantDb(txDb, aggregateId);
68
- const usage = await readRollingCapUsage(txDb, ctx.user.tenantId, options);
82
+ const usage = await readRollingCapUsage(txDb, user.tenantId, options);
69
83
  options.guardCurrentValue?.(usage);
70
- await appendEventInTenantDb(txDb, rollingEvent(ctx.user, options.capName, options.amount, version, ROLLING_INCREMENTED_EVENT_QN));
71
- await insertReservationRow(txDb, id, {
84
+ await appendEventInTenantDb(txDb, rollingEvent(user, options.capName, options.amount, version, ROLLING_INCREMENTED_EVENT_QN));
85
+ await insertReservationRow(txDb, user, id, {
72
86
  capName: options.capName,
73
87
  kind: "rolling",
74
88
  amount: options.amount,
75
89
  });
76
90
  return NOTHING_TO_RELEASE;
77
91
  }));
78
- return toHandle(ctx, id);
92
+ return toHandle(ctx, id, outsideTransaction);
79
93
  }
80
94
  // Deleting the row takes its row lock inside the handler transaction, so a release started after a
81
95
  // failed COMMIT waits for the real outcome and finds the row gone when the commit went through.
@@ -94,39 +108,74 @@ async function giveBackRolling(txDb, user, row) {
94
108
  return NOTHING_TO_RELEASE;
95
109
  }
96
110
  async function giveBackReservation(txDb, user, id) {
97
- const row = await txDb.fetchOne(capReservationsTable, { id });
111
+ const ownRow = { id, tenantId: user.tenantId };
112
+ const row = await txDb.fetchOne(capReservationsTable, ownRow);
98
113
  if (!row)
99
114
  return NOTHING_TO_RELEASE;
100
- // Waits for a confirm still holding the row; zero rows back means that confirm committed.
101
- const claimed = await txDb.updateMany(capReservationsTable, { expiresAt: row.expiresAt }, { id });
102
- if (claimed.length === 0)
115
+ const claimed = await claimReservationRow(txDb, user, row);
116
+ if (!claimed)
103
117
  return NOTHING_TO_RELEASE;
104
118
  const given = row.kind === "rolling"
105
119
  ? await giveBackRolling(txDb, user, row)
106
120
  : await giveBackCalendar(txDb, user, row);
107
121
  if (!given.isSuccess)
108
122
  return given;
109
- await txDb.deleteMany(capReservationsTable, { id });
123
+ await txDb.deleteMany(capReservationsTable, ownRow);
110
124
  return given;
111
125
  }
112
- export async function releaseCapReservation(ctx, id) {
113
- const outsideDb = requireOutsideTransactionDb(ctx);
114
- const released = await retryOnStreamVersionConflict(() => retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, (txDb) => giveBackReservation(txDb, ctx.user, id))));
126
+ // Waits for a confirm still holding the row; false means that confirm (or another release) won.
127
+ async function claimReservationRow(txDb, user, row) {
128
+ const claimed = await txDb.updateMany(capReservationsTable, { expiresAt: row.expiresAt }, { id: row.id, tenantId: user.tenantId });
129
+ return claimed.length > 0;
130
+ }
131
+ export async function releaseCapReservation(ctx, id, outsideTransaction = true) {
132
+ const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
133
+ const released = await retryOnStreamVersionConflict(() => retryCounterWriteOnVersionConflict(() => runInOwnTransaction(writeDb, (txDb) => giveBackReservation(txDb, user, id))));
115
134
  if (!released.isSuccess)
116
135
  throw reraiseAsKumikoError(released.error);
117
136
  }
137
+ function bookCalendarDelta(txDb, user, period, delta) {
138
+ if (delta === 0)
139
+ return Promise.resolve({ isSuccess: true, data: {} });
140
+ return applyCapDeltaOn(txDb, user, { ...period, amount: Math.abs(delta) }, delta > 0 ? "add" : "subtract");
141
+ }
142
+ // Replaces the reserved amount by the actual one in a single transaction. When the row is already
143
+ // gone (swept or released), the reserved amount was given back, so the full actual amount is booked.
144
+ export async function settleCalendarCapReservation(ctx, id, options, outsideTransaction = true) {
145
+ if (!Number.isInteger(options.actualAmount) || options.actualAmount < 0) {
146
+ throw new Error(`cap-counter: settle amount must be a non-negative integer, got ${options.actualAmount}`);
147
+ }
148
+ const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
149
+ const settled = await retryCounterWriteOnVersionConflict(() => runInOwnTransaction(writeDb, async (txDb) => {
150
+ const row = await txDb.fetchOne(capReservationsTable, {
151
+ id,
152
+ tenantId: user.tenantId,
153
+ });
154
+ const claimed = row ? await claimReservationRow(txDb, user, row) : false;
155
+ if (!row || !claimed) {
156
+ return bookCalendarDelta(txDb, user, options, options.actualAmount);
157
+ }
158
+ const booked = await bookCalendarDelta(txDb, user, options, options.actualAmount - row.amount);
159
+ if (!booked.isSuccess)
160
+ return booked;
161
+ await txDb.deleteMany(capReservationsTable, { id, tenantId: user.tenantId });
162
+ return booked;
163
+ }));
164
+ if (!settled.isSuccess)
165
+ throw reraiseAsKumikoError(settled.error);
166
+ }
118
167
  // A failed release leaves its row behind; giving it back once the TTL passed bounds how long that
119
168
  // capacity stays counted.
120
- export async function releaseExpiredCapReservations(ctx, capName) {
121
- const outsideDb = requireOutsideTransactionDb(ctx);
122
- const expired = await outsideDb.selectMany(capReservationsTable, {
123
- tenantId: ctx.user.tenantId,
169
+ export async function releaseExpiredCapReservations(ctx, capName, outsideTransaction = true) {
170
+ const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
171
+ const expired = await writeDb.selectMany(capReservationsTable, {
172
+ tenantId: user.tenantId,
124
173
  capName,
125
174
  expiresAt: { lt: Temporal.Now.instant() },
126
175
  });
127
176
  for (const { id } of expired) {
128
177
  try {
129
- await releaseCapReservation(ctx, id);
178
+ await releaseCapReservation(ctx, id, outsideTransaction);
130
179
  }
131
180
  catch (error) {
132
181
  ctx.log?.error("releasing an expired cap reservation failed", { id, capName, error });