@cosmicdrift/kumiko-bundled-features 0.351.0 → 0.353.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/agent-tools/agent-manifest.js +8 -2
- package/dist/agent-tools/manifest-deny-qns.d.ts +3 -0
- package/dist/agent-tools/manifest-deny-qns.js +17 -0
- package/dist/agent-tools/tool-catalog.d.ts +2 -1
- package/dist/agent-tools/tool-catalog.js +4 -1
- package/dist/audit/handlers/list.query.js +13 -8
- package/dist/auth-email-password/handlers/signup-confirm.write.js +2 -3
- package/dist/auth-mfa/schema/user-mfa.d.ts +6 -0
- package/dist/auth-mfa/screens.js +2 -2
- package/dist/billing-foundation/checkout-core.d.ts +7 -0
- package/dist/billing-foundation/checkout-core.js +7 -2
- package/dist/billing-foundation/entities.d.ts +8 -0
- package/dist/billing-foundation/handlers/start-plan-checkout.write.js +7 -1
- package/dist/billing-foundation/projection.d.ts +8 -0
- package/dist/cap-counter/book-cap-usage.d.ts +5 -1
- package/dist/cap-counter/book-cap-usage.js +65 -44
- package/dist/cap-counter/cap-reservation.d.ts +12 -0
- package/dist/cap-counter/cap-reservation.js +135 -0
- package/dist/cap-counter/constants.d.ts +3 -0
- package/dist/cap-counter/constants.js +6 -0
- package/dist/cap-counter/entity.d.ts +1 -0
- package/dist/cap-counter/feature.js +7 -2
- package/dist/cap-counter/handlers/increment-rolling.write.d.ts +5 -0
- package/dist/cap-counter/handlers/increment-rolling.write.js +4 -0
- package/dist/cap-counter/index.d.ts +4 -2
- package/dist/cap-counter/index.js +3 -1
- package/dist/cap-counter/payload-field.d.ts +1 -0
- package/dist/cap-counter/payload-field.js +11 -0
- package/dist/cap-counter/reservation-chain.d.ts +2 -0
- package/dist/cap-counter/reservation-chain.js +44 -0
- package/dist/cap-counter/stock-cap-guard.d.ts +3 -0
- package/dist/cap-counter/tables.d.ts +3 -0
- package/dist/cap-counter/tables.js +36 -0
- package/dist/cap-counter/value-cap-guard.d.ts +18 -0
- package/dist/cap-counter/value-cap-guard.js +33 -0
- package/dist/cap-counter/with-cap-enforcement.d.ts +5 -7
- package/dist/cap-counter/with-cap-enforcement.js +35 -51
- package/dist/cap-overview/access.js +2 -9
- package/dist/cap-overview/handlers/tenant-caps-list.query.js +2 -2
- package/dist/cap-overview/handlers/tenant-options.query.js +4 -5
- package/dist/channel-discord/feature.js +1 -0
- package/dist/channel-slack/feature.js +1 -0
- package/dist/channel-teams/feature.js +1 -0
- package/dist/compliance-profiles/schema/profile-selection.d.ts +10 -8
- package/dist/compliance-profiles-ops/handlers/tenants-missing-profile.query.js +2 -4
- package/dist/config/handlers/values.query.d.ts +3 -1
- package/dist/config/handlers/values.query.js +7 -2
- package/dist/config/table.d.ts +6 -0
- package/dist/custom-fields/entity.d.ts +4 -0
- package/dist/data-retention/schema/tenant-retention-override.d.ts +4 -0
- package/dist/delivery/attempt-log-retention.d.ts +9 -0
- package/dist/delivery/attempt-log-retention.js +23 -0
- package/dist/delivery/attempt-log.d.ts +1 -1
- package/dist/delivery/attempt-log.js +21 -2
- package/dist/delivery/constants.d.ts +1 -1
- package/dist/delivery/constants.js +1 -1
- package/dist/delivery/events.d.ts +1 -0
- package/dist/delivery/events.js +2 -0
- package/dist/delivery/feature.d.ts +1 -0
- package/dist/delivery/feature.js +39 -3
- package/dist/delivery/index.d.ts +2 -1
- package/dist/delivery/index.js +2 -1
- package/dist/delivery/mask-recipient-address.d.ts +1 -0
- package/dist/delivery/mask-recipient-address.js +41 -0
- package/dist/delivery/public-names.d.ts +2 -0
- package/dist/delivery/public-names.js +4 -1
- package/dist/delivery/tables.d.ts +6 -0
- package/dist/delivery/types.d.ts +1 -0
- package/dist/delivery/types.js +6 -2
- package/dist/document-ingest-foundation/entity.d.ts +4 -0
- package/dist/files-tenant-data/hooks.js +3 -2
- package/dist/folders/entity.d.ts +8 -0
- package/dist/folders/feature.js +2 -10
- package/dist/form-draft/db/queries/cleanup.d.ts +1 -1
- package/dist/form-draft/db/queries/cleanup.js +4 -3
- package/dist/form-draft/entity.d.ts +2 -0
- package/dist/form-draft/handlers/cleanup.job.d.ts +5 -1
- package/dist/form-draft/handlers/cleanup.job.js +11 -5
- package/dist/inbound-mail-foundation/entities.d.ts +25 -0
- package/dist/inbound-mail-foundation/projection.d.ts +20 -0
- package/dist/jobs/handlers/list.query.d.ts +2 -1
- package/dist/jobs/handlers/list.query.js +39 -14
- package/dist/jobs/i18n.js +1 -0
- package/dist/jobs/job-run-table.d.ts +6 -0
- package/dist/ledger/entity.d.ts +24 -8
- package/dist/ledger/entity.js +4 -0
- package/dist/ledger/schemas.js +8 -2
- package/dist/managed-pages/feature.js +28 -10
- package/dist/managed-pages/table.d.ts +12 -0
- package/dist/notes-history/entity.d.ts +12 -0
- package/dist/notes-history/feature.js +2 -10
- package/dist/personal-access-tokens/schema/api-token.d.ts +6 -0
- package/dist/personal-access-tokens/screens.js +14 -3
- package/dist/renderer-foundation/types.d.ts +1 -0
- package/dist/renderer-simple/feature.js +4 -6
- package/dist/renderer-simple/index.d.ts +1 -0
- package/dist/renderer-simple/mail-blocks.d.ts +21 -0
- package/dist/renderer-simple/mail-blocks.js +107 -0
- package/dist/renderer-simple/simple-renderer.js +21 -3
- package/dist/secrets/table.d.ts +1 -0
- package/dist/seo/feature.js +28 -6
- package/dist/sessions/schema/user-session.d.ts +4 -0
- package/dist/shared/assert-no-where-rule-in-ownership-write.d.ts +2 -0
- package/dist/shared/assert-no-where-rule-in-ownership-write.js +18 -0
- package/dist/shared/index.d.ts +2 -1
- package/dist/shared/index.js +2 -1
- package/dist/shared/rate-limited-text-response.d.ts +2 -0
- package/dist/shared/rate-limited-text-response.js +9 -0
- package/dist/tags/entity.d.ts +9 -0
- package/dist/tags/feature.js +2 -10
- package/dist/template-resolver/table.d.ts +48 -30
- package/dist/template-resolver/user-content-table.d.ts +34 -20
- package/dist/tenant/decrypt-tenant-names.d.ts +3 -0
- package/dist/tenant/decrypt-tenant-names.js +10 -0
- package/dist/tenant/handlers/me.query.js +5 -1
- package/dist/tenant/handlers/memberships.query.js +3 -2
- package/dist/tenant/handlers/tenant-directory.query.js +9 -3
- package/dist/tenant/index.d.ts +1 -0
- package/dist/tenant/index.js +1 -0
- package/dist/tenant/invitation-table.d.ts +16 -8
- package/dist/tenant/membership-table.d.ts +4 -0
- package/dist/tenant/schema/tenant.d.ts +18 -10
- package/dist/tenant/schema/tenant.js +4 -6
- package/dist/tenant/screens.js +1 -1
- package/dist/tenant-handover/move-entity-graph.js +11 -18
- package/dist/tenant-lifecycle/feature.js +1 -0
- package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +3 -0
- package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -0
- package/dist/tenant-lifecycle/stages.d.ts +2 -0
- package/dist/tenant-lifecycle/stages.js +7 -1
- package/dist/tier-engine/entity.d.ts +2 -0
- package/dist/tier-engine/index.d.ts +1 -0
- package/dist/tier-engine/index.js +1 -0
- package/dist/tier-engine/tier-option-gate.d.ts +32 -0
- package/dist/tier-engine/tier-option-gate.js +62 -0
- package/dist/tier-engine/tier-resolver.d.ts +2 -0
- package/dist/user/handlers/list.query.js +2 -2
- package/dist/user/schema/user.d.ts +22 -8
- package/dist/user-data-rights/boot-checks.js +5 -0
- package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +2 -2
- 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 +16 -8
- package/dist/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.js +25 -6
- package/dist/waitlist/entity.d.ts +22 -6
- package/dist/workflow-runner/event-trigger.js +6 -3
- package/dist/workflow-runner/handlers/resume-run.write.js +3 -0
- package/package.json +10 -10
- package/src/agent-tools/changes.json +12 -0
- package/src/audit/changes.json +6 -0
- package/src/cap-counter/changes.json +28 -0
- package/src/delivery/changes.json +12 -0
- package/src/derivatives-sharp/changes.json +6 -0
- package/src/form-draft/changes.json +7 -1
- package/src/legal-pages/changes.json +6 -0
- package/src/managed-pages/changes.json +6 -0
- package/src/renderer-simple/changes.json +6 -0
- package/src/seo/changes.json +5 -0
- package/src/tenant/changes.json +7 -0
- package/src/tenant-handover/changes.json +6 -0
- package/src/tier-engine/changes.json +6 -0
- package/src/workflow-runner/changes.json +5 -0
- package/dist/shared/has-where-rule.d.ts +0 -2
- package/dist/shared/has-where-rule.js +0 -10
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { hasAccess, isAgentVisibleScreen, resolveAgentExposure, SYSTEM_ONLY_JSON_SCHEMA_KEY, } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { translationValueOtherText } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
3
|
import * as z from "zod";
|
|
4
|
+
import { recordManifestDenyQns } from "./manifest-deny-qns.js";
|
|
4
5
|
/** The manifest surfaces one string per locale — a plural entry reads as its
|
|
5
6
|
* CLDR `other` form since the manifest has no `count` to select a category. */
|
|
6
7
|
function entryAsLabels(entry) {
|
|
@@ -232,6 +233,9 @@ function lastSegment(qualifiedId) {
|
|
|
232
233
|
function detailIdParam(screen) {
|
|
233
234
|
return screen.type === "projectionDetail" ? (screen.idParam ?? "id") : "id";
|
|
234
235
|
}
|
|
236
|
+
function screenTitles(translations, screenId, shortId) {
|
|
237
|
+
return entryAsLabels(translations[`${screenId}.title`] ?? translations[`screen:${shortId}.title`] ?? {});
|
|
238
|
+
}
|
|
235
239
|
function buildScreens(screens, navs, workspaces, translations, roles, visibleNavIds) {
|
|
236
240
|
const allNavs = [...navs.values()];
|
|
237
241
|
const result = [];
|
|
@@ -257,7 +261,7 @@ function buildScreens(screens, navs, workspaces, translations, roles, visibleNav
|
|
|
257
261
|
result.push({
|
|
258
262
|
id: screen.id,
|
|
259
263
|
type: screen.type,
|
|
260
|
-
titles:
|
|
264
|
+
titles: screenTitles(translations, screen.id, shortId),
|
|
261
265
|
// `screen.description` may be a raw i18n key (`registry.getAllTranslations()`
|
|
262
266
|
// double-prefixes it, see build-app-schema.ts:77-81) — match on suffix like
|
|
263
267
|
// `labelsForSuffix` does for entity/field labels, else fall back to the
|
|
@@ -304,7 +308,7 @@ export function buildAgentManifest(registry, options) {
|
|
|
304
308
|
const navs = buildNavs(navMap, workspaceMap, translations, roles, screenMap);
|
|
305
309
|
const screens = buildScreens(screenMap, navMap, workspaceMap, translations, roles, new Set(navs.map((nav) => nav.id)));
|
|
306
310
|
const workspaces = buildWorkspaces(workspaceMap, translations, roles);
|
|
307
|
-
|
|
311
|
+
const manifest = {
|
|
308
312
|
builtForRoles: [...roles],
|
|
309
313
|
features: sortedByKey(features, (f) => f.name),
|
|
310
314
|
entities: sortedByKey(entities, (e) => e.name),
|
|
@@ -317,4 +321,6 @@ export function buildAgentManifest(registry, options) {
|
|
|
317
321
|
...(options.currency && { currency: options.currency }),
|
|
318
322
|
},
|
|
319
323
|
};
|
|
324
|
+
recordManifestDenyQns(manifest, new Set(options.denyQns ?? []));
|
|
325
|
+
return manifest;
|
|
320
326
|
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { AgentManifest } from "./types.js";
|
|
2
|
+
export declare function recordManifestDenyQns(manifest: AgentManifest, denyQns: ReadonlySet<string>): void;
|
|
3
|
+
export declare function assertCatalogMirrorsManifestDenyQns(manifest: AgentManifest, catalogDenyQns: ReadonlySet<string>): void;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The deny list is deliberately not a manifest field (the manifest is prompt
|
|
2
|
+
// payload), so the catalog builder learns it through this side channel to
|
|
3
|
+
// detect a manifest cut that the catalog would silently not mirror.
|
|
4
|
+
const denyQnsByManifest = new WeakMap();
|
|
5
|
+
export function recordManifestDenyQns(manifest, denyQns) {
|
|
6
|
+
denyQnsByManifest.set(manifest, denyQns);
|
|
7
|
+
}
|
|
8
|
+
export function assertCatalogMirrorsManifestDenyQns(manifest, catalogDenyQns) {
|
|
9
|
+
const manifestDenyQns = denyQnsByManifest.get(manifest);
|
|
10
|
+
// skip: manifest has no registered deny list, nothing to mirror
|
|
11
|
+
if (!manifestDenyQns)
|
|
12
|
+
return;
|
|
13
|
+
const missing = [...manifestDenyQns].filter((qn) => !catalogDenyQns.has(qn));
|
|
14
|
+
if (missing.length > 0) {
|
|
15
|
+
throw new Error(`agent denyQns passed to buildAgentManifest but not to buildToolCatalog: ${missing.join(", ")} — the catalog would still expose their entity CRUD tools`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -24,5 +24,6 @@ export declare const OPEN_FORM_TOOL_NAME = "open_form";
|
|
|
24
24
|
* to drop an entity CRUD tool, which no `agent.expose` on the manifest side can reach. It is
|
|
25
25
|
* NOT read off the manifest on purpose — the manifest is prompt payload, and a list of the
|
|
26
26
|
* handlers the model may not call has no business travelling to the provider. Pass the same
|
|
27
|
-
* list to `buildAgentManifest` so the manifest stops describing what the catalog withholds
|
|
27
|
+
* list to `buildAgentManifest` so the manifest stops describing what the catalog withholds;
|
|
28
|
+
* a manifest built with `denyQns` that the catalog lacks throws. */
|
|
28
29
|
export declare function buildToolCatalog(registry: RegistrySearchView, manifest: AgentManifest, options: ToolCatalogOptions): ToolCatalog;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { hasAccess, normalizeAccessEntry } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { FILTER_OPS, isRecord } from "./filter-ops.js";
|
|
3
|
+
import { assertCatalogMirrorsManifestDenyQns } from "./manifest-deny-qns.js";
|
|
3
4
|
const MAX_TOOL_NAME_LENGTH = 64;
|
|
4
5
|
/** Field types that declare `filterable` (per `packages/framework/src/engine/types/fields.ts`).
|
|
5
6
|
* Mapped to the JSON-Schema type an LLM tool-call argument should use. `undefined` = field type
|
|
@@ -466,13 +467,15 @@ function addClientTools(manifest, mode, sink) {
|
|
|
466
467
|
* to drop an entity CRUD tool, which no `agent.expose` on the manifest side can reach. It is
|
|
467
468
|
* NOT read off the manifest on purpose — the manifest is prompt payload, and a list of the
|
|
468
469
|
* handlers the model may not call has no business travelling to the provider. Pass the same
|
|
469
|
-
* list to `buildAgentManifest` so the manifest stops describing what the catalog withholds
|
|
470
|
+
* list to `buildAgentManifest` so the manifest stops describing what the catalog withholds;
|
|
471
|
+
* a manifest built with `denyQns` that the catalog lacks throws. */
|
|
470
472
|
export function buildToolCatalog(registry, manifest, options) {
|
|
471
473
|
const sink = { tools: [], dispatchTable: new Map(), usedNames: new Set() };
|
|
472
474
|
const roleFilter = { roles: manifest.builtForRoles };
|
|
473
475
|
const locale = manifest.tenantSettings.locale;
|
|
474
476
|
const entityByName = new Map(manifest.entities.map((entity) => [entity.name, entity]));
|
|
475
477
|
const denyQns = new Set(options.denyQns ?? []);
|
|
478
|
+
assertCatalogMirrorsManifestDenyQns(manifest, denyQns);
|
|
476
479
|
addRegistrySearchTools(registry, roleFilter, denyQns, sink);
|
|
477
480
|
const { detailQnByEntity, listQnByEntity, entityListDetailQns } = collectEntityHandlerQns(registry, roleFilter, denyQns);
|
|
478
481
|
addGetTools(registry, detailQnByEntity, entityByName, locale, sink);
|
|
@@ -34,7 +34,7 @@ function buildDateRange(from, to) {
|
|
|
34
34
|
function escapeLikePattern(value) {
|
|
35
35
|
return value.replace(/[\\%_]/g, (m) => `\\${m}`);
|
|
36
36
|
}
|
|
37
|
-
function buildAuditWhere(tenantId, p) {
|
|
37
|
+
function buildAuditWhere(tenantId, p, direction) {
|
|
38
38
|
const where = { tenantId };
|
|
39
39
|
if (p.aggregateType)
|
|
40
40
|
where["aggregateType"] = p.aggregateType;
|
|
@@ -50,8 +50,10 @@ function buildAuditWhere(tenantId, p) {
|
|
|
50
50
|
if (range)
|
|
51
51
|
where["createdAt"] = range;
|
|
52
52
|
const cursor = p.cursor ?? p.before;
|
|
53
|
+
// The cursor is an event id, so it only walks pages in id order: ascending
|
|
54
|
+
// pages continue past it, descending pages stop before it.
|
|
53
55
|
if (cursor)
|
|
54
|
-
where["id"] = { lt: BigInt(cursor) };
|
|
56
|
+
where["id"] = direction === "asc" ? { gt: BigInt(cursor) } : { lt: BigInt(cursor) };
|
|
55
57
|
return where;
|
|
56
58
|
}
|
|
57
59
|
export const listQuery = defineQueryHandler({
|
|
@@ -81,15 +83,17 @@ export const listQuery = defineQueryHandler({
|
|
|
81
83
|
handler: async (query, ctx) => {
|
|
82
84
|
const p = query.payload;
|
|
83
85
|
const scopeFilter = resolveAuditScopeFilter(query.user, p.scope);
|
|
86
|
+
const direction = p.sortDirection ?? "desc";
|
|
84
87
|
const where = buildAuditWhere(scopeFilter.tenantId, {
|
|
85
88
|
...p,
|
|
86
89
|
aggregateType: scopeFilter.aggregateType ?? p.aggregateType,
|
|
87
|
-
});
|
|
90
|
+
}, direction);
|
|
88
91
|
const rows = await selectMany(ctx.db, eventsTable, where, {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
// `id` as tie-breaker keeps the order total, so pages never overlap.
|
|
93
|
+
orderBy: [
|
|
94
|
+
{ col: p.sort === "type" ? "type" : "createdAt", direction },
|
|
95
|
+
{ col: "id", direction },
|
|
96
|
+
],
|
|
93
97
|
limit: p.limit,
|
|
94
98
|
});
|
|
95
99
|
const serialised = rows.map((r) => ({
|
|
@@ -104,7 +108,8 @@ export const listQuery = defineQueryHandler({
|
|
|
104
108
|
createdBy: r.createdBy,
|
|
105
109
|
}));
|
|
106
110
|
const last = serialised[serialised.length - 1];
|
|
107
|
-
|
|
111
|
+
// An id cursor cannot resume a type-ordered listing, so that sort is one page.
|
|
112
|
+
const nextId = serialised.length === p.limit && last && p.sort !== "type" ? last.id : null;
|
|
108
113
|
return {
|
|
109
114
|
rows: serialised,
|
|
110
115
|
nextCursor: nextId,
|
|
@@ -124,9 +124,8 @@ export function createSignupConfirmHandler(opts = {}) {
|
|
|
124
124
|
displayName,
|
|
125
125
|
tenantId,
|
|
126
126
|
tenantKey,
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
tenantName: email,
|
|
127
|
+
// Generated slug as default name: no personal data (the email) in the tenant name.
|
|
128
|
+
tenantName: tenantKey,
|
|
130
129
|
},
|
|
131
130
|
// #1463: seedTenant's postSave hooks (tier-engine's auto-default-
|
|
132
131
|
// tier, an app's auto-default-compliance) must fire on self-signup
|
|
@@ -3,6 +3,7 @@ export declare const userMfaEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
3
3
|
readonly type: "text";
|
|
4
4
|
readonly description?: string;
|
|
5
5
|
readonly maxLength?: number;
|
|
6
|
+
readonly minLength?: number;
|
|
6
7
|
readonly required?: boolean;
|
|
7
8
|
readonly searchable?: boolean;
|
|
8
9
|
readonly sortable?: boolean;
|
|
@@ -24,6 +25,7 @@ export declare const userMfaEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
24
25
|
readonly type: "text";
|
|
25
26
|
readonly description?: string;
|
|
26
27
|
readonly maxLength?: number;
|
|
28
|
+
readonly minLength?: number;
|
|
27
29
|
readonly required?: boolean;
|
|
28
30
|
readonly searchable?: boolean;
|
|
29
31
|
readonly sortable?: boolean;
|
|
@@ -45,6 +47,7 @@ export declare const userMfaEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
45
47
|
readonly type: "text";
|
|
46
48
|
readonly description?: string;
|
|
47
49
|
readonly maxLength?: number;
|
|
50
|
+
readonly minLength?: number;
|
|
48
51
|
readonly required?: boolean;
|
|
49
52
|
readonly searchable?: boolean;
|
|
50
53
|
readonly sortable?: boolean;
|
|
@@ -98,6 +101,7 @@ export declare const userMfaTable: import("@cosmicdrift/kumiko-framework/db").En
|
|
|
98
101
|
readonly type: "text";
|
|
99
102
|
readonly description?: string;
|
|
100
103
|
readonly maxLength?: number;
|
|
104
|
+
readonly minLength?: number;
|
|
101
105
|
readonly required?: boolean;
|
|
102
106
|
readonly searchable?: boolean;
|
|
103
107
|
readonly sortable?: boolean;
|
|
@@ -119,6 +123,7 @@ export declare const userMfaTable: import("@cosmicdrift/kumiko-framework/db").En
|
|
|
119
123
|
readonly type: "text";
|
|
120
124
|
readonly description?: string;
|
|
121
125
|
readonly maxLength?: number;
|
|
126
|
+
readonly minLength?: number;
|
|
122
127
|
readonly required?: boolean;
|
|
123
128
|
readonly searchable?: boolean;
|
|
124
129
|
readonly sortable?: boolean;
|
|
@@ -140,6 +145,7 @@ export declare const userMfaTable: import("@cosmicdrift/kumiko-framework/db").En
|
|
|
140
145
|
readonly type: "text";
|
|
141
146
|
readonly description?: string;
|
|
142
147
|
readonly maxLength?: number;
|
|
148
|
+
readonly minLength?: number;
|
|
143
149
|
readonly required?: boolean;
|
|
144
150
|
readonly searchable?: boolean;
|
|
145
151
|
readonly sortable?: boolean;
|
package/dist/auth-mfa/screens.js
CHANGED
|
@@ -47,7 +47,7 @@ export const mfaEnableScreen = {
|
|
|
47
47
|
confirm: {
|
|
48
48
|
handler: AuthMfaHandlers.enableConfirm,
|
|
49
49
|
fields: {
|
|
50
|
-
code: { type: "text", required: true, maxLength: 6 },
|
|
50
|
+
code: { type: "text", required: true, minLength: 6, maxLength: 6 },
|
|
51
51
|
},
|
|
52
52
|
layout: { sections: [{ fields: ["code"] }] },
|
|
53
53
|
carry: ["setupToken"],
|
|
@@ -57,7 +57,7 @@ export const mfaEnableScreen = {
|
|
|
57
57
|
};
|
|
58
58
|
// Either a 6-digit TOTP code or a 9-char recovery code proves possession —
|
|
59
59
|
// same bounds as the disable/regenerate-recovery handler schemas.
|
|
60
|
-
const possessionCodeField = { type: "text", required: true, maxLength: 9 };
|
|
60
|
+
const possessionCodeField = { type: "text", required: true, minLength: 6, maxLength: 9 };
|
|
61
61
|
export const mfaDisableScreen = {
|
|
62
62
|
id: MFA_DISABLE_SCREEN_ID,
|
|
63
63
|
type: "actionForm",
|
|
@@ -80,6 +80,13 @@ export type OpenCheckoutOptions = {
|
|
|
80
80
|
readonly baseUrl?: string;
|
|
81
81
|
readonly catalog?: BillingPlanCatalog;
|
|
82
82
|
readonly now: () => Temporal.Instant;
|
|
83
|
+
/** The caller already ran `assertBillingEnabled` under its own handler name
|
|
84
|
+
* (before a costly provider lookup), so the gate is not repeated here. */
|
|
85
|
+
readonly billingEnabledChecked?: boolean;
|
|
86
|
+
/** The caller already ran `assertNoActiveSubscription` and passes its result
|
|
87
|
+
* (null = tenant has none); mode:"subscription" skips the second read and
|
|
88
|
+
* the providerCustomerId check reuses it. */
|
|
89
|
+
readonly checkedSubscription?: Awaited<ReturnType<typeof getSubscriptionForTenant>>;
|
|
83
90
|
};
|
|
84
91
|
export type OpenCheckoutInput = {
|
|
85
92
|
readonly providerName: string;
|
|
@@ -249,7 +249,9 @@ export async function assertCheckoutAllowed(ctx, options, input) {
|
|
|
249
249
|
// Checked before the redirect-origin hardening for every mode — a
|
|
250
250
|
// disabled provider must reject a mode:"payment" checkout just as much
|
|
251
251
|
// as a mode:"subscription" one.
|
|
252
|
-
|
|
252
|
+
if (options.billingEnabledChecked !== true) {
|
|
253
|
+
await assertBillingEnabled(ctx, plugin, "create-checkout-session");
|
|
254
|
+
}
|
|
253
255
|
assertRedirectOrigins([input.successUrl, input.cancelUrl], options.baseUrl);
|
|
254
256
|
const mode = input.mode ?? "subscription";
|
|
255
257
|
// Reused by the providerCustomerId check below to avoid a second lookup
|
|
@@ -257,7 +259,10 @@ export async function assertCheckoutAllowed(ctx, options, input) {
|
|
|
257
259
|
let ownSubscription;
|
|
258
260
|
if (mode === "subscription") {
|
|
259
261
|
assertSubscriptionPriceAllowed(plugin, options.catalog, input);
|
|
260
|
-
ownSubscription =
|
|
262
|
+
ownSubscription =
|
|
263
|
+
options.checkedSubscription !== undefined
|
|
264
|
+
? options.checkedSubscription
|
|
265
|
+
: await assertNoActiveSubscription(ctx, options.now());
|
|
261
266
|
}
|
|
262
267
|
else {
|
|
263
268
|
assertOneOffPriceAllowed(plugin, input);
|
|
@@ -3,6 +3,7 @@ export declare const subscriptionEntity: import("@cosmicdrift/kumiko-types/field
|
|
|
3
3
|
readonly type: "text";
|
|
4
4
|
readonly description?: string;
|
|
5
5
|
readonly maxLength?: number;
|
|
6
|
+
readonly minLength?: number;
|
|
6
7
|
readonly required?: boolean;
|
|
7
8
|
readonly searchable?: boolean;
|
|
8
9
|
readonly sortable?: boolean;
|
|
@@ -24,6 +25,7 @@ export declare const subscriptionEntity: import("@cosmicdrift/kumiko-types/field
|
|
|
24
25
|
readonly type: "text";
|
|
25
26
|
readonly description?: string;
|
|
26
27
|
readonly maxLength?: number;
|
|
28
|
+
readonly minLength?: number;
|
|
27
29
|
readonly required?: boolean;
|
|
28
30
|
readonly searchable?: boolean;
|
|
29
31
|
readonly sortable?: boolean;
|
|
@@ -45,6 +47,7 @@ export declare const subscriptionEntity: import("@cosmicdrift/kumiko-types/field
|
|
|
45
47
|
readonly type: "text";
|
|
46
48
|
readonly description?: string;
|
|
47
49
|
readonly maxLength?: number;
|
|
50
|
+
readonly minLength?: number;
|
|
48
51
|
readonly required?: boolean;
|
|
49
52
|
readonly searchable?: boolean;
|
|
50
53
|
readonly sortable?: boolean;
|
|
@@ -66,6 +69,7 @@ export declare const subscriptionEntity: import("@cosmicdrift/kumiko-types/field
|
|
|
66
69
|
readonly type: "text";
|
|
67
70
|
readonly description?: string;
|
|
68
71
|
readonly maxLength?: number;
|
|
72
|
+
readonly minLength?: number;
|
|
69
73
|
readonly required?: boolean;
|
|
70
74
|
readonly searchable?: boolean;
|
|
71
75
|
readonly sortable?: boolean;
|
|
@@ -87,6 +91,7 @@ export declare const subscriptionEntity: import("@cosmicdrift/kumiko-types/field
|
|
|
87
91
|
readonly type: "text";
|
|
88
92
|
readonly description?: string;
|
|
89
93
|
readonly maxLength?: number;
|
|
94
|
+
readonly minLength?: number;
|
|
90
95
|
readonly required?: boolean;
|
|
91
96
|
readonly searchable?: boolean;
|
|
92
97
|
readonly sortable?: boolean;
|
|
@@ -141,6 +146,7 @@ export declare const paymentEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
141
146
|
readonly type: "text";
|
|
142
147
|
readonly description?: string;
|
|
143
148
|
readonly maxLength?: number;
|
|
149
|
+
readonly minLength?: number;
|
|
144
150
|
readonly required?: boolean;
|
|
145
151
|
readonly searchable?: boolean;
|
|
146
152
|
readonly sortable?: boolean;
|
|
@@ -162,6 +168,7 @@ export declare const paymentEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
162
168
|
readonly type: "text";
|
|
163
169
|
readonly description?: string;
|
|
164
170
|
readonly maxLength?: number;
|
|
171
|
+
readonly minLength?: number;
|
|
165
172
|
readonly required?: boolean;
|
|
166
173
|
readonly searchable?: boolean;
|
|
167
174
|
readonly sortable?: boolean;
|
|
@@ -183,6 +190,7 @@ export declare const paymentEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
183
190
|
readonly type: "text";
|
|
184
191
|
readonly description?: string;
|
|
185
192
|
readonly maxLength?: number;
|
|
193
|
+
readonly minLength?: number;
|
|
186
194
|
readonly required?: boolean;
|
|
187
195
|
readonly searchable?: boolean;
|
|
188
196
|
readonly sortable?: boolean;
|
|
@@ -40,7 +40,13 @@ export function createStartPlanCheckoutHandler(options, catalog) {
|
|
|
40
40
|
? await prepareConsent(ctx, consumerProtection, payload.consent, "subscription")
|
|
41
41
|
: undefined;
|
|
42
42
|
const baseUrl = options.baseUrl ?? "";
|
|
43
|
-
const result = await openCheckout(ctx, {
|
|
43
|
+
const result = await openCheckout(ctx, {
|
|
44
|
+
baseUrl: options.baseUrl,
|
|
45
|
+
catalog,
|
|
46
|
+
now: options.now,
|
|
47
|
+
billingEnabledChecked: true,
|
|
48
|
+
checkedSubscription: existing,
|
|
49
|
+
}, {
|
|
44
50
|
providerName,
|
|
45
51
|
priceId: resolved.priceId,
|
|
46
52
|
successUrl: joinBaseUrl(baseUrl, catalog.successPath),
|
|
@@ -3,6 +3,7 @@ export declare const subscriptionsProjectionTable: import("@cosmicdrift/kumiko-f
|
|
|
3
3
|
readonly type: "text";
|
|
4
4
|
readonly description?: string;
|
|
5
5
|
readonly maxLength?: number;
|
|
6
|
+
readonly minLength?: number;
|
|
6
7
|
readonly required?: boolean;
|
|
7
8
|
readonly searchable?: boolean;
|
|
8
9
|
readonly sortable?: boolean;
|
|
@@ -24,6 +25,7 @@ export declare const subscriptionsProjectionTable: import("@cosmicdrift/kumiko-f
|
|
|
24
25
|
readonly type: "text";
|
|
25
26
|
readonly description?: string;
|
|
26
27
|
readonly maxLength?: number;
|
|
28
|
+
readonly minLength?: number;
|
|
27
29
|
readonly required?: boolean;
|
|
28
30
|
readonly searchable?: boolean;
|
|
29
31
|
readonly sortable?: boolean;
|
|
@@ -45,6 +47,7 @@ export declare const subscriptionsProjectionTable: import("@cosmicdrift/kumiko-f
|
|
|
45
47
|
readonly type: "text";
|
|
46
48
|
readonly description?: string;
|
|
47
49
|
readonly maxLength?: number;
|
|
50
|
+
readonly minLength?: number;
|
|
48
51
|
readonly required?: boolean;
|
|
49
52
|
readonly searchable?: boolean;
|
|
50
53
|
readonly sortable?: boolean;
|
|
@@ -66,6 +69,7 @@ export declare const subscriptionsProjectionTable: import("@cosmicdrift/kumiko-f
|
|
|
66
69
|
readonly type: "text";
|
|
67
70
|
readonly description?: string;
|
|
68
71
|
readonly maxLength?: number;
|
|
72
|
+
readonly minLength?: number;
|
|
69
73
|
readonly required?: boolean;
|
|
70
74
|
readonly searchable?: boolean;
|
|
71
75
|
readonly sortable?: boolean;
|
|
@@ -87,6 +91,7 @@ export declare const subscriptionsProjectionTable: import("@cosmicdrift/kumiko-f
|
|
|
87
91
|
readonly type: "text";
|
|
88
92
|
readonly description?: string;
|
|
89
93
|
readonly maxLength?: number;
|
|
94
|
+
readonly minLength?: number;
|
|
90
95
|
readonly required?: boolean;
|
|
91
96
|
readonly searchable?: boolean;
|
|
92
97
|
readonly sortable?: boolean;
|
|
@@ -140,6 +145,7 @@ export declare const paymentsProjectionTable: import("@cosmicdrift/kumiko-framew
|
|
|
140
145
|
readonly type: "text";
|
|
141
146
|
readonly description?: string;
|
|
142
147
|
readonly maxLength?: number;
|
|
148
|
+
readonly minLength?: number;
|
|
143
149
|
readonly required?: boolean;
|
|
144
150
|
readonly searchable?: boolean;
|
|
145
151
|
readonly sortable?: boolean;
|
|
@@ -161,6 +167,7 @@ export declare const paymentsProjectionTable: import("@cosmicdrift/kumiko-framew
|
|
|
161
167
|
readonly type: "text";
|
|
162
168
|
readonly description?: string;
|
|
163
169
|
readonly maxLength?: number;
|
|
170
|
+
readonly minLength?: number;
|
|
164
171
|
readonly required?: boolean;
|
|
165
172
|
readonly searchable?: boolean;
|
|
166
173
|
readonly sortable?: boolean;
|
|
@@ -182,6 +189,7 @@ export declare const paymentsProjectionTable: import("@cosmicdrift/kumiko-framew
|
|
|
182
189
|
readonly type: "text";
|
|
183
190
|
readonly description?: string;
|
|
184
191
|
readonly maxLength?: number;
|
|
192
|
+
readonly minLength?: number;
|
|
185
193
|
readonly required?: boolean;
|
|
186
194
|
readonly searchable?: boolean;
|
|
187
195
|
readonly sortable?: boolean;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
-
import { type HandlerContext, type TenantId, type WriteResult } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { type HandlerContext, type SessionUser, type TenantId, type WriteResult } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
export declare function retryCounterWriteOnVersionConflict(writeAttempt: () => Promise<WriteResult>): Promise<WriteResult>;
|
|
4
|
+
export declare function retryOnStreamVersionConflict<T>(attempt: () => Promise<T>): Promise<T>;
|
|
3
5
|
export type BookCapUsageOptions = {
|
|
4
6
|
readonly capName: string;
|
|
5
7
|
readonly periodStartIso: string;
|
|
@@ -7,8 +9,10 @@ export type BookCapUsageOptions = {
|
|
|
7
9
|
readonly outsideTransaction?: boolean;
|
|
8
10
|
readonly guardCurrentValue?: (currentValue: number) => void;
|
|
9
11
|
};
|
|
12
|
+
export declare function requireOutsideTransactionDb(ctx: HandlerContext): TenantDb;
|
|
10
13
|
export declare function bookCapUsage(ctx: HandlerContext, options: BookCapUsageOptions): Promise<WriteResult>;
|
|
11
14
|
export declare function releaseCapUsage(ctx: HandlerContext, options: Omit<BookCapUsageOptions, "guardCurrentValue">): Promise<WriteResult>;
|
|
15
|
+
export declare function applyCapDeltaOn(db: TenantDb, user: SessionUser, options: BookCapUsageOptions, direction: "add" | "subtract"): Promise<WriteResult>;
|
|
12
16
|
export type MarkCapSoftWarnedOptions = {
|
|
13
17
|
readonly capName: string;
|
|
14
18
|
readonly periodStartIso: string;
|
|
@@ -1,11 +1,11 @@
|
|
|
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 { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
|
+
import { eventsTable, VersionConflictError } from "@cosmicdrift/kumiko-framework/event-store";
|
|
5
5
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
6
6
|
import * as z from "zod";
|
|
7
7
|
import { capCounterAggregateId, rollingCapAggregateId } from "./aggregate-id.js";
|
|
8
|
-
import { CAP_COUNTER_ROLLING_AGGREGATE_TYPE, ROLLING_INCREMENTED_EVENT_QN } from "./constants.js";
|
|
8
|
+
import { CAP_COUNTER_ROLLING_AGGREGATE_TYPE, ROLLING_INCREMENTED_EVENT_QN, ROLLING_RELEASED_EVENT_QN, } from "./constants.js";
|
|
9
9
|
import { capCounterEntity } from "./entity.js";
|
|
10
10
|
const { table, executor } = createEntityExecutor("cap-counter", capCounterEntity);
|
|
11
11
|
// periodStartIso feeds the aggregate id and Temporal.Instant.from(); a non-instant string would
|
|
@@ -26,7 +26,7 @@ const BACKOFF_JITTER_MS_PER_ATTEMPT = 5;
|
|
|
26
26
|
function sleepMs(ms) {
|
|
27
27
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
28
28
|
}
|
|
29
|
-
async function retryCounterWriteOnVersionConflict(writeAttempt) {
|
|
29
|
+
export async function retryCounterWriteOnVersionConflict(writeAttempt) {
|
|
30
30
|
let result = await writeAttempt();
|
|
31
31
|
for (let attempt = 1; attempt < MAX_COUNTER_WRITE_ATTEMPTS && isLostCounterRace(result); attempt++) {
|
|
32
32
|
await sleepMs(Math.random() * attempt * BACKOFF_JITTER_MS_PER_ATTEMPT);
|
|
@@ -34,7 +34,21 @@ async function retryCounterWriteOnVersionConflict(writeAttempt) {
|
|
|
34
34
|
}
|
|
35
35
|
return result;
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
// Event-store appends signal a lost race by throwing; the whole attempt (read, guard, append) is repeated.
|
|
38
|
+
export async function retryOnStreamVersionConflict(attempt) {
|
|
39
|
+
for (let tries = 1;; tries++) {
|
|
40
|
+
try {
|
|
41
|
+
return await attempt();
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (!(error instanceof VersionConflictError) || tries >= MAX_COUNTER_WRITE_ATTEMPTS) {
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
await sleepMs(Math.random() * tries * BACKOFF_JITTER_MS_PER_ATTEMPT);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function requireOutsideTransactionDb(ctx) {
|
|
38
52
|
if (!ctx.dbOutsideTransaction) {
|
|
39
53
|
throw new Error("cap-counter.bookCapUsage: outsideTransaction requested but ctx.dbOutsideTransaction is undefined");
|
|
40
54
|
}
|
|
@@ -47,45 +61,46 @@ export async function bookCapUsage(ctx, options) {
|
|
|
47
61
|
export async function releaseCapUsage(ctx, options) {
|
|
48
62
|
return applyCapDelta(ctx, options, "subtract");
|
|
49
63
|
}
|
|
50
|
-
|
|
64
|
+
// One attempt against an already-open db handle; a lost race comes back as a version_conflict failure the caller retries.
|
|
65
|
+
export async function applyCapDeltaOn(db, user, options, direction) {
|
|
51
66
|
const parsed = capBookingSchema.parse(options);
|
|
52
|
-
const aggregateId = capCounterAggregateId(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return executor.create({
|
|
60
|
-
id: aggregateId,
|
|
61
|
-
capName: parsed.capName,
|
|
62
|
-
value: parsed.amount,
|
|
63
|
-
periodStart: Temporal.Instant.from(parsed.periodStartIso),
|
|
64
|
-
lastSoftWarnedAt: null,
|
|
65
|
-
}, ctx.user, db);
|
|
66
|
-
}
|
|
67
|
-
const currentRow = existing[0];
|
|
68
|
-
if (!currentRow) {
|
|
69
|
-
throw new Error("cap-counter.bookCapUsage: row vanished between length-check and read");
|
|
70
|
-
}
|
|
71
|
-
const currentValue = currentRow["value"]; // @cast-boundary db-row
|
|
72
|
-
const currentVersion = currentRow["version"]; // @cast-boundary db-row
|
|
73
|
-
options.guardCurrentValue?.(currentValue);
|
|
74
|
-
return executor.update({
|
|
67
|
+
const aggregateId = capCounterAggregateId(user.tenantId, parsed.capName, parsed.periodStartIso);
|
|
68
|
+
const existing = await db.selectMany(table, { id: aggregateId }, { limit: 1 });
|
|
69
|
+
if (existing.length === 0) {
|
|
70
|
+
options.guardCurrentValue?.(0);
|
|
71
|
+
if (direction === "subtract")
|
|
72
|
+
return { isSuccess: true, data: {} };
|
|
73
|
+
return executor.create({
|
|
75
74
|
id: aggregateId,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
75
|
+
capName: parsed.capName,
|
|
76
|
+
value: parsed.amount,
|
|
77
|
+
periodStart: Temporal.Instant.from(parsed.periodStartIso),
|
|
78
|
+
lastSoftWarnedAt: null,
|
|
79
|
+
}, user, db);
|
|
80
|
+
}
|
|
81
|
+
const currentRow = existing[0];
|
|
82
|
+
if (!currentRow) {
|
|
83
|
+
throw new Error("cap-counter.bookCapUsage: row vanished between length-check and read");
|
|
83
84
|
}
|
|
85
|
+
const currentValue = currentRow["value"]; // @cast-boundary db-row
|
|
86
|
+
const currentVersion = currentRow["version"]; // @cast-boundary db-row
|
|
87
|
+
options.guardCurrentValue?.(currentValue);
|
|
88
|
+
return executor.update({
|
|
89
|
+
id: aggregateId,
|
|
90
|
+
version: currentVersion,
|
|
91
|
+
changes: {
|
|
92
|
+
value: direction === "add"
|
|
93
|
+
? currentValue + parsed.amount
|
|
94
|
+
: Math.max(0, currentValue - parsed.amount),
|
|
95
|
+
},
|
|
96
|
+
}, user, db);
|
|
97
|
+
}
|
|
98
|
+
async function applyCapDelta(ctx, options, direction) {
|
|
84
99
|
if (options.outsideTransaction) {
|
|
85
100
|
const outsideDb = requireOutsideTransactionDb(ctx);
|
|
86
|
-
return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb,
|
|
101
|
+
return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, (txDb) => applyCapDeltaOn(txDb, ctx.user, options, direction)));
|
|
87
102
|
}
|
|
88
|
-
return retryCounterWriteOnVersionConflict(() =>
|
|
103
|
+
return retryCounterWriteOnVersionConflict(() => applyCapDeltaOn(ctx.db, ctx.user, options, direction));
|
|
89
104
|
}
|
|
90
105
|
export async function markCapSoftWarned(ctx, options) {
|
|
91
106
|
const parsed = capPeriodSchema.parse(options);
|
|
@@ -117,27 +132,33 @@ export async function markCapSoftWarned(ctx, options) {
|
|
|
117
132
|
}
|
|
118
133
|
return retryCounterWriteOnVersionConflict(() => attemptMark(ctx.db));
|
|
119
134
|
}
|
|
120
|
-
|
|
121
|
-
// (e.g. a CapSpec.usage callback), not the enforce-and-throw path.
|
|
122
|
-
export async function readRollingCapUsage(db, tenantId, options) {
|
|
135
|
+
async function sumRollingEventAmounts(db, tenantId, options, type) {
|
|
123
136
|
const aggregateId = rollingCapAggregateId(tenantId, options.capName);
|
|
124
137
|
const cutoff = Temporal.Now.instant().subtract({ hours: options.windowDays * 24 });
|
|
125
138
|
const rows = await db.selectMany(eventsTable, {
|
|
126
139
|
tenantId,
|
|
127
140
|
aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
|
|
128
141
|
aggregateId,
|
|
129
|
-
type
|
|
142
|
+
type,
|
|
130
143
|
createdAt: { gte: cutoff },
|
|
131
144
|
});
|
|
132
|
-
let
|
|
145
|
+
let total = 0;
|
|
133
146
|
for (const row of rows) {
|
|
134
147
|
// @cast-boundary engine-payload — events.payload is jsonb (typed as
|
|
135
148
|
// unknown by drizzle's $type<Record<string,unknown>>); narrowing the
|
|
136
149
|
// shape here mirrors enforceRollingCap's own read-side contract.
|
|
137
150
|
const payload = row["payload"];
|
|
138
151
|
if (typeof payload.amount === "number") {
|
|
139
|
-
|
|
152
|
+
total += payload.amount;
|
|
140
153
|
}
|
|
141
154
|
}
|
|
142
|
-
return
|
|
155
|
+
return total;
|
|
156
|
+
}
|
|
157
|
+
// Sums usage without throwing — for callers that only need the raw number
|
|
158
|
+
// (e.g. a CapSpec.usage callback), not the enforce-and-throw path.
|
|
159
|
+
// Incremented minus released amounts inside the window; a release whose increment already left the window cannot push it below 0.
|
|
160
|
+
export async function readRollingCapUsage(db, tenantId, options) {
|
|
161
|
+
const incremented = await sumRollingEventAmounts(db, tenantId, options, ROLLING_INCREMENTED_EVENT_QN);
|
|
162
|
+
const released = await sumRollingEventAmounts(db, tenantId, options, ROLLING_RELEASED_EVENT_QN);
|
|
163
|
+
return Math.max(0, incremented - released);
|
|
143
164
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import type { ReservationHandle } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
|
+
import { type BookCapUsageOptions, type ReadRollingCapUsageOptions } from "./book-cap-usage.js";
|
|
4
|
+
export type ReserveCalendarCapOptions = Required<Pick<BookCapUsageOptions, "capName" | "periodStartIso" | "amount">> & Pick<BookCapUsageOptions, "guardCurrentValue">;
|
|
5
|
+
export declare function reserveCalendarCap(ctx: HandlerContext, options: ReserveCalendarCapOptions): Promise<ReservationHandle>;
|
|
6
|
+
export type ReserveRollingCapOptions = ReadRollingCapUsageOptions & {
|
|
7
|
+
readonly amount: number;
|
|
8
|
+
readonly guardCurrentValue?: (currentValue: number) => void;
|
|
9
|
+
};
|
|
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>;
|