@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.
- package/dist/auth-mfa/i18n.js +1 -0
- package/dist/auth-mfa/schema/user-mfa.d.ts +6 -0
- package/dist/auth-mfa/screens.js +8 -1
- package/dist/billing-foundation/entities.d.ts +8 -0
- package/dist/billing-foundation/projection.d.ts +8 -0
- package/dist/cap-counter/book-cap-usage.d.ts +11 -5
- package/dist/cap-counter/book-cap-usage.js +54 -13
- package/dist/cap-counter/calendar-cap-usage-reservation.d.ts +17 -0
- package/dist/cap-counter/calendar-cap-usage-reservation.js +53 -0
- package/dist/cap-counter/cap-reservation.d.ts +15 -6
- package/dist/cap-counter/cap-reservation.js +80 -31
- package/dist/cap-counter/enforce-cap.d.ts +5 -5
- package/dist/cap-counter/enforce-cap.js +7 -20
- package/dist/cap-counter/entity.d.ts +1 -0
- package/dist/cap-counter/feature.js +7 -1
- package/dist/cap-counter/index.d.ts +1 -0
- package/dist/cap-counter/index.js +1 -0
- package/dist/cap-counter/with-cap-enforcement.js +4 -19
- package/dist/cap-overview/screens.js +3 -1
- package/dist/config/table.d.ts +6 -0
- package/dist/crypto-shredding/handlers/forget-subject.write.js +14 -3
- package/dist/custom-fields/entity.d.ts +4 -0
- package/dist/data-retention/schema/tenant-retention-override.d.ts +4 -0
- package/dist/delivery/chat-webhook-sender.js +1 -1
- package/dist/delivery/constants.d.ts +1 -0
- package/dist/delivery/constants.js +4 -0
- package/dist/delivery/delivery-service.js +62 -25
- package/dist/delivery/feature.js +7 -1
- package/dist/delivery/jobs.js +25 -1
- package/dist/delivery/tables.d.ts +6 -0
- package/dist/delivery/testing.d.ts +1 -0
- package/dist/document-ingest-foundation/entity.d.ts +4 -0
- package/dist/folders/entity.d.ts +8 -0
- package/dist/form-draft/entity.d.ts +2 -0
- package/dist/inbound-mail-foundation/entities.d.ts +25 -0
- package/dist/inbound-mail-foundation/projection.d.ts +20 -0
- package/dist/jobs/job-run-logger.js +21 -17
- package/dist/jobs/job-run-table.d.ts +6 -0
- package/dist/jobs/job-run-table.js +10 -3
- package/dist/ledger/entity.d.ts +13 -0
- package/dist/managed-pages/table.d.ts +12 -0
- package/dist/notes-history/entity.d.ts +12 -0
- package/dist/notes-history/entity.js +9 -7
- package/dist/notes-history-user-data/hooks.d.ts +8 -0
- package/dist/notes-history-user-data/hooks.js +56 -72
- package/dist/notes-history-user-data/index.d.ts +1 -0
- package/dist/notes-history-user-data/index.js +2 -1
- package/dist/personal-access-tokens/schema/api-token.d.ts +6 -0
- package/dist/personal-access-tokens/screens.js +2 -0
- package/dist/secrets/table.d.ts +1 -0
- package/dist/sessions/schema/user-session.d.ts +4 -0
- package/dist/sessions/screens.js +2 -0
- package/dist/tags/entity.d.ts +9 -0
- package/dist/template-resolver/table.d.ts +10 -0
- package/dist/template-resolver/user-content-table.d.ts +10 -0
- package/dist/tenant/constants.d.ts +1 -0
- package/dist/tenant/constants.js +6 -3
- package/dist/tenant/feature.js +2 -0
- package/dist/tenant/handlers/member-role-availability.query.d.ts +7 -0
- package/dist/tenant/handlers/member-role-availability.query.js +18 -0
- package/dist/tenant/invitation-table.d.ts +6 -0
- package/dist/tenant/member-role-options.d.ts +2 -0
- package/dist/tenant/member-role-options.js +4 -0
- package/dist/tenant/membership-table.d.ts +4 -0
- package/dist/tenant/schema/tenant.d.ts +6 -0
- package/dist/tenant/screens.d.ts +4 -2
- package/dist/tenant/screens.js +10 -6
- package/dist/tier-engine/entity.d.ts +2 -0
- package/dist/tier-engine/tier-resolver.d.ts +2 -0
- package/dist/user/schema/user.d.ts +12 -0
- package/dist/user/screens.js +8 -1
- package/dist/user-data-rights/schema/download-attempt.d.ts +14 -0
- package/dist/user-data-rights/schema/download-token.d.ts +8 -0
- package/dist/user-data-rights/schema/export-job.d.ts +6 -0
- package/dist/user-data-rights/screens.js +8 -1
- package/dist/waitlist/entity.d.ts +14 -0
- package/dist/waitlist/screens.js +8 -1
- package/package.json +9 -9
- package/src/auth-mfa/changes.json +6 -0
- package/src/cap-counter/changes.json +7 -0
- package/src/crypto-shredding/changes.json +6 -0
- package/src/delivery/changes.json +6 -0
- package/src/notes-history/changes.json +6 -0
- package/src/tenant/changes.json +6 -0
package/dist/auth-mfa/i18n.js
CHANGED
|
@@ -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;
|
package/dist/auth-mfa/screens.js
CHANGED
|
@@ -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: {
|
|
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
|
|
13
|
-
export
|
|
14
|
-
|
|
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:
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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=${
|
|
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
|
-
},
|
|
169
|
+
}, user, db);
|
|
128
170
|
}
|
|
129
171
|
if (options.outsideTransaction) {
|
|
130
|
-
|
|
131
|
-
return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, attemptMark));
|
|
172
|
+
return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(writeDb, attemptMark));
|
|
132
173
|
}
|
|
133
|
-
return retryCounterWriteOnVersionConflict(() => attemptMark(
|
|
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:
|
|
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:
|
|
11
|
-
export declare function releaseCapReservation(ctx:
|
|
12
|
-
export
|
|
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,
|
|
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
|
|
32
|
+
export async function reserveCalendarCap(ctx, options, outsideTransaction = true) {
|
|
33
|
+
const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
|
|
33
34
|
const id = generateId();
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
|
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(
|
|
66
|
-
await retryOnStreamVersionConflict(() => runInOwnTransaction(
|
|
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,
|
|
82
|
+
const usage = await readRollingCapUsage(txDb, user.tenantId, options);
|
|
69
83
|
options.guardCurrentValue?.(usage);
|
|
70
|
-
await appendEventInTenantDb(txDb, rollingEvent(
|
|
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
|
|
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
|
-
|
|
101
|
-
|
|
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,
|
|
123
|
+
await txDb.deleteMany(capReservationsTable, ownRow);
|
|
110
124
|
return given;
|
|
111
125
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
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
|
|
122
|
-
const expired = await
|
|
123
|
-
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 });
|