@cosmicdrift/kumiko-samples 0.357.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/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +71 -21
- package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +99 -0
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +143 -43
- package/packages/bundled-features/src/cap-counter/changes.json +7 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +18 -32
- package/packages/bundled-features/src/cap-counter/index.ts +5 -0
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -25
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +0 -1
- package/packages/bundled-features/src/jobs/job-run-logger.ts +23 -15
- package/packages/bundled-features/src/jobs/job-run-table.ts +10 -3
- package/packages/bundled-features/src/notes-history/changes.json +6 -0
- package/packages/bundled-features/src/notes-history/entity.ts +2 -1
- package/packages/bundled-features/src/notes-history-user-data/hooks.ts +3 -42
- package/packages/bundled-features/src/notes-history-user-data/index.ts +1 -1
- package/samples/apps/styleguide/src/features/widgets/feature.ts +1 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.358.0",
|
|
4
4
|
"description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.358.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -4,10 +4,12 @@ import { runInOwnTransaction, type TenantDb } from "@cosmicdrift/kumiko-framewor
|
|
|
4
4
|
import {
|
|
5
5
|
createEntityExecutor,
|
|
6
6
|
type HandlerContext,
|
|
7
|
+
type JobContext,
|
|
7
8
|
type SessionUser,
|
|
8
9
|
type TenantId,
|
|
9
10
|
type WriteResult,
|
|
10
11
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
12
|
+
import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
|
|
11
13
|
import { eventsTable, VersionConflictError } from "@cosmicdrift/kumiko-framework/event-store";
|
|
12
14
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
13
15
|
import * as z from "zod";
|
|
@@ -82,17 +84,69 @@ export type BookCapUsageOptions = {
|
|
|
82
84
|
readonly guardCurrentValue?: (currentValue: number) => void;
|
|
83
85
|
};
|
|
84
86
|
|
|
85
|
-
export
|
|
86
|
-
|
|
87
|
+
export type CapCounterContext = HandlerContext | JobContext;
|
|
88
|
+
|
|
89
|
+
export type CapCounterScope = {
|
|
90
|
+
readonly user: SessionUser;
|
|
91
|
+
readonly readDb: TenantDb;
|
|
92
|
+
readonly writeDb: TenantDb;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// A tenant-mode db pins every row it writes to its own tenant; counter and reservation row would
|
|
96
|
+
// silently land on different tenants if that is not the user's.
|
|
97
|
+
function assertDbServesUser(db: TenantDb, user: SessionUser): TenantDb {
|
|
98
|
+
if (db.mode === "tenant" && db.tenantId !== user.tenantId) {
|
|
99
|
+
throw new AccessDeniedError({
|
|
100
|
+
message: "cap-counter: the context's database belongs to a different tenant than its user",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return db;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Jobs write in place (their db is not transaction-bound); systemScope handlers reach both dbs via
|
|
107
|
+
// systemDb, because their ctx.db / ctx.dbOutsideTransaction are fail-closed proxies.
|
|
108
|
+
export function resolveCapCounterScope(
|
|
109
|
+
ctx: CapCounterContext,
|
|
110
|
+
outsideTransaction: boolean,
|
|
111
|
+
): CapCounterScope {
|
|
112
|
+
// A job context spread into a handler-shaped one (user set) is a handler context: its user, not
|
|
113
|
+
// the job's systemUser, names the metered tenant.
|
|
114
|
+
if (!("user" in ctx) || !ctx.user) {
|
|
115
|
+
const jobUser = "systemUser" in ctx ? ctx.systemUser : undefined;
|
|
116
|
+
if (!jobUser) {
|
|
117
|
+
throw new Error("cap-counter: ctx.user.tenantId missing — required to resolve the counter");
|
|
118
|
+
}
|
|
119
|
+
const db = assertDbServesUser(ctx.db, jobUser);
|
|
120
|
+
return { user: jobUser, readDb: db, writeDb: db };
|
|
121
|
+
}
|
|
122
|
+
const { user } = ctx;
|
|
123
|
+
if (!user.tenantId) {
|
|
124
|
+
throw new Error("cap-counter: ctx.user.tenantId missing — required to resolve the counter");
|
|
125
|
+
}
|
|
126
|
+
if (ctx.systemDb) {
|
|
127
|
+
const readDb = ctx.systemDb.assertTenantMatch(user.tenantId);
|
|
128
|
+
const writeDb = outsideTransaction
|
|
129
|
+
? ctx.systemDb.outsideTransaction.assertTenantMatch(user.tenantId)
|
|
130
|
+
: readDb;
|
|
131
|
+
return { user, readDb, writeDb };
|
|
132
|
+
}
|
|
133
|
+
const writeDb = outsideTransaction ? ctx.dbOutsideTransaction : ctx.db;
|
|
134
|
+
if (!writeDb) {
|
|
87
135
|
throw new Error(
|
|
88
|
-
|
|
136
|
+
outsideTransaction
|
|
137
|
+
? "cap-counter: outsideTransaction requested but ctx.dbOutsideTransaction is undefined"
|
|
138
|
+
: "cap-counter: ctx.db missing — run inside a handler context",
|
|
89
139
|
);
|
|
90
140
|
}
|
|
91
|
-
return
|
|
141
|
+
return {
|
|
142
|
+
user,
|
|
143
|
+
readDb: assertDbServesUser(ctx.db, user),
|
|
144
|
+
writeDb: assertDbServesUser(writeDb, user),
|
|
145
|
+
};
|
|
92
146
|
}
|
|
93
147
|
|
|
94
148
|
export async function bookCapUsage(
|
|
95
|
-
ctx:
|
|
149
|
+
ctx: CapCounterContext,
|
|
96
150
|
options: BookCapUsageOptions,
|
|
97
151
|
): Promise<WriteResult> {
|
|
98
152
|
return applyCapDelta(ctx, options, "add");
|
|
@@ -100,7 +154,7 @@ export async function bookCapUsage(
|
|
|
100
154
|
|
|
101
155
|
// Gives back a reservation whose operation failed afterwards; the counter never drops below 0.
|
|
102
156
|
export async function releaseCapUsage(
|
|
103
|
-
ctx:
|
|
157
|
+
ctx: CapCounterContext,
|
|
104
158
|
options: Omit<BookCapUsageOptions, "guardCurrentValue">,
|
|
105
159
|
): Promise<WriteResult> {
|
|
106
160
|
return applyCapDelta(ctx, options, "subtract");
|
|
@@ -157,18 +211,18 @@ export async function applyCapDeltaOn(
|
|
|
157
211
|
}
|
|
158
212
|
|
|
159
213
|
async function applyCapDelta(
|
|
160
|
-
ctx:
|
|
214
|
+
ctx: CapCounterContext,
|
|
161
215
|
options: BookCapUsageOptions,
|
|
162
216
|
direction: "add" | "subtract",
|
|
163
217
|
): Promise<WriteResult> {
|
|
218
|
+
const { user, writeDb } = resolveCapCounterScope(ctx, options.outsideTransaction === true);
|
|
164
219
|
if (options.outsideTransaction) {
|
|
165
|
-
const outsideDb = requireOutsideTransactionDb(ctx);
|
|
166
220
|
return retryCounterWriteOnVersionConflict(() =>
|
|
167
|
-
runInOwnTransaction(
|
|
221
|
+
runInOwnTransaction(writeDb, (txDb) => applyCapDeltaOn(txDb, user, options, direction)),
|
|
168
222
|
);
|
|
169
223
|
}
|
|
170
224
|
return retryCounterWriteOnVersionConflict(() =>
|
|
171
|
-
applyCapDeltaOn(
|
|
225
|
+
applyCapDeltaOn(writeDb, user, options, direction),
|
|
172
226
|
);
|
|
173
227
|
}
|
|
174
228
|
|
|
@@ -179,21 +233,18 @@ export type MarkCapSoftWarnedOptions = {
|
|
|
179
233
|
};
|
|
180
234
|
|
|
181
235
|
export async function markCapSoftWarned(
|
|
182
|
-
ctx:
|
|
236
|
+
ctx: CapCounterContext,
|
|
183
237
|
options: MarkCapSoftWarnedOptions,
|
|
184
238
|
): Promise<WriteResult> {
|
|
185
239
|
const parsed = capPeriodSchema.parse(options);
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
parsed.capName,
|
|
189
|
-
parsed.periodStartIso,
|
|
190
|
-
);
|
|
240
|
+
const { user, writeDb } = resolveCapCounterScope(ctx, options.outsideTransaction === true);
|
|
241
|
+
const aggregateId = capCounterAggregateId(user.tenantId, parsed.capName, parsed.periodStartIso);
|
|
191
242
|
|
|
192
243
|
async function attemptMark(db: TenantDb): Promise<WriteResult> {
|
|
193
244
|
const existing = await db.selectMany(table, { id: aggregateId }, { limit: 1 });
|
|
194
245
|
if (existing.length === 0) {
|
|
195
246
|
throw new Error(
|
|
196
|
-
`cap-counter: cannot mark-soft-warned, no counter found for tenant=${
|
|
247
|
+
`cap-counter: cannot mark-soft-warned, no counter found for tenant=${user.tenantId} cap=${parsed.capName} period=${parsed.periodStartIso}`,
|
|
197
248
|
);
|
|
198
249
|
}
|
|
199
250
|
const row = existing[0];
|
|
@@ -213,16 +264,15 @@ export async function markCapSoftWarned(
|
|
|
213
264
|
version: currentVersion,
|
|
214
265
|
changes: { lastSoftWarnedAt: Temporal.Now.instant() },
|
|
215
266
|
},
|
|
216
|
-
|
|
267
|
+
user,
|
|
217
268
|
db,
|
|
218
269
|
);
|
|
219
270
|
}
|
|
220
271
|
|
|
221
272
|
if (options.outsideTransaction) {
|
|
222
|
-
|
|
223
|
-
return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, attemptMark));
|
|
273
|
+
return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(writeDb, attemptMark));
|
|
224
274
|
}
|
|
225
|
-
return retryCounterWriteOnVersionConflict(() => attemptMark(
|
|
275
|
+
return retryCounterWriteOnVersionConflict(() => attemptMark(writeDb));
|
|
226
276
|
}
|
|
227
277
|
|
|
228
278
|
export type ReadRollingCapUsageOptions = {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { CapCounterContext } from "./book-cap-usage.js";
|
|
2
|
+
import {
|
|
3
|
+
type CalendarCapReservation,
|
|
4
|
+
releaseExpiredCapReservations,
|
|
5
|
+
reserveCalendarCap,
|
|
6
|
+
settleCalendarCapReservation,
|
|
7
|
+
} from "./cap-reservation.js";
|
|
8
|
+
import {
|
|
9
|
+
assertBelowHardCap,
|
|
10
|
+
enforceCapAndMaybeNotify,
|
|
11
|
+
type SoftHitNotifier,
|
|
12
|
+
} from "./enforce-cap.js";
|
|
13
|
+
import type { CalendarCapDef } from "./with-cap-enforcement.js";
|
|
14
|
+
|
|
15
|
+
export type CalendarCapUsageReservationSpec = Omit<CalendarCapDef, "amount" | "notify"> & {
|
|
16
|
+
readonly amount: number;
|
|
17
|
+
readonly notify?: SoftHitNotifier;
|
|
18
|
+
// Default true: the reservation must commit on its own, not roll back with the caller's transaction.
|
|
19
|
+
readonly outsideTransaction?: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type CalendarCapUsageReservation = {
|
|
23
|
+
readonly amount: number;
|
|
24
|
+
// Counter value right after this reservation was booked.
|
|
25
|
+
readonly counterValue: number;
|
|
26
|
+
// Replaces the reserved amount with the actual one; rejects a second settle and one after release.
|
|
27
|
+
settle(actualAmount: number): Promise<void>;
|
|
28
|
+
// Gives the reserved amount back; a no-op once settled.
|
|
29
|
+
release(): Promise<void>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Shared by withCapEnforcement and the public reservation API so both enforce identically.
|
|
33
|
+
export async function reserveEnforcedCalendarCap(
|
|
34
|
+
ctx: CapCounterContext,
|
|
35
|
+
cap: CalendarCapUsageReservationSpec,
|
|
36
|
+
): Promise<CalendarCapReservation> {
|
|
37
|
+
const outsideTransaction = cap.outsideTransaction ?? true;
|
|
38
|
+
await releaseExpiredCapReservations(ctx, cap.capName, outsideTransaction);
|
|
39
|
+
if (cap.notify) {
|
|
40
|
+
await enforceCapAndMaybeNotify(ctx, {
|
|
41
|
+
capName: cap.capName,
|
|
42
|
+
periodStartIso: cap.periodStartIso,
|
|
43
|
+
limit: cap.limit,
|
|
44
|
+
profile: cap.profile,
|
|
45
|
+
notify: cap.notify,
|
|
46
|
+
amount: cap.amount,
|
|
47
|
+
markSoftWarnedOutsideTransaction: outsideTransaction,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return reserveCalendarCap(
|
|
51
|
+
ctx,
|
|
52
|
+
{
|
|
53
|
+
capName: cap.capName,
|
|
54
|
+
amount: cap.amount,
|
|
55
|
+
periodStartIso: cap.periodStartIso,
|
|
56
|
+
guardCurrentValue: (currentValue) => assertBelowHardCap(currentValue + cap.amount - 1, cap),
|
|
57
|
+
},
|
|
58
|
+
outsideTransaction,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type ReservationState = "open" | "settled" | "released";
|
|
63
|
+
|
|
64
|
+
export async function reserveCalendarCapUsage(
|
|
65
|
+
ctx: CapCounterContext,
|
|
66
|
+
spec: CalendarCapUsageReservationSpec,
|
|
67
|
+
): Promise<CalendarCapUsageReservation> {
|
|
68
|
+
const outsideTransaction = spec.outsideTransaction ?? true;
|
|
69
|
+
const reservation = await reserveEnforcedCalendarCap(ctx, spec);
|
|
70
|
+
let state: ReservationState = "open";
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
amount: spec.amount,
|
|
74
|
+
counterValue: reservation.counterValue,
|
|
75
|
+
async settle(actualAmount) {
|
|
76
|
+
if (state !== "open") {
|
|
77
|
+
throw new Error(`cap-counter: reservation for "${spec.capName}" is already ${state}`);
|
|
78
|
+
}
|
|
79
|
+
state = "settled";
|
|
80
|
+
try {
|
|
81
|
+
await settleCalendarCapReservation(
|
|
82
|
+
ctx,
|
|
83
|
+
reservation.reservationId,
|
|
84
|
+
{ capName: spec.capName, periodStartIso: spec.periodStartIso, actualAmount },
|
|
85
|
+
outsideTransaction,
|
|
86
|
+
);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
state = "open";
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
async release() {
|
|
93
|
+
// skip: release after settle, or a second release, is a no-op by contract.
|
|
94
|
+
if (state !== "open") return;
|
|
95
|
+
await reservation.release();
|
|
96
|
+
state = "released";
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import { deleteMany, runInOwnTransaction, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
-
import type {
|
|
3
|
-
HandlerContext,
|
|
4
|
-
SessionUser,
|
|
5
|
-
WriteResult,
|
|
6
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import type { SessionUser, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
3
|
import { reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
4
|
import {
|
|
9
5
|
appendEventInTenantDb,
|
|
@@ -19,9 +15,10 @@ import { rollingCapAggregateId } from "./aggregate-id.js";
|
|
|
19
15
|
import {
|
|
20
16
|
applyCapDeltaOn,
|
|
21
17
|
type BookCapUsageOptions,
|
|
18
|
+
type CapCounterContext,
|
|
22
19
|
type ReadRollingCapUsageOptions,
|
|
23
20
|
readRollingCapUsage,
|
|
24
|
-
|
|
21
|
+
resolveCapCounterScope,
|
|
25
22
|
retryCounterWriteOnVersionConflict,
|
|
26
23
|
retryOnStreamVersionConflict,
|
|
27
24
|
} from "./book-cap-usage.js";
|
|
@@ -47,10 +44,16 @@ const NOTHING_TO_RELEASE: WriteResult = { isSuccess: true, data: {} };
|
|
|
47
44
|
type ReservationRowDraft = Pick<CapReservationRow, "capName" | "kind" | "amount"> &
|
|
48
45
|
Pick<Partial<CapReservationRow>, "periodStartIso">;
|
|
49
46
|
|
|
50
|
-
async function insertReservationRow(
|
|
47
|
+
async function insertReservationRow(
|
|
48
|
+
txDb: TenantDb,
|
|
49
|
+
user: SessionUser,
|
|
50
|
+
id: string,
|
|
51
|
+
draft: ReservationRowDraft,
|
|
52
|
+
) {
|
|
51
53
|
const now = Temporal.Now.instant();
|
|
52
54
|
await txDb.insertOne(capReservationsTable, {
|
|
53
55
|
id,
|
|
56
|
+
tenantId: user.tenantId,
|
|
54
57
|
capName: draft.capName,
|
|
55
58
|
kind: draft.kind,
|
|
56
59
|
periodStartIso: draft.periodStartIso ?? null,
|
|
@@ -60,9 +63,19 @@ async function insertReservationRow(txDb: TenantDb, id: string, draft: Reservati
|
|
|
60
63
|
});
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
|
|
66
|
+
export type CalendarCapReservation = ReservationHandle & {
|
|
67
|
+
readonly reservationId: string;
|
|
68
|
+
// Counter value right after this reservation was booked.
|
|
69
|
+
readonly counterValue: number;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function toHandle(
|
|
73
|
+
ctx: CapCounterContext,
|
|
74
|
+
id: string,
|
|
75
|
+
outsideTransaction: boolean,
|
|
76
|
+
): ReservationHandle {
|
|
64
77
|
return {
|
|
65
|
-
release: () => releaseCapReservation(ctx, id),
|
|
78
|
+
release: () => releaseCapReservation(ctx, id, outsideTransaction),
|
|
66
79
|
confirmInTransaction: (confirm) => confirmCapReservation(confirm, id),
|
|
67
80
|
};
|
|
68
81
|
}
|
|
@@ -75,16 +88,26 @@ export type ReserveCalendarCapOptions = Required<
|
|
|
75
88
|
// The counter increment and the reservation row commit together, so a booked amount never exists
|
|
76
89
|
// without a row that can give it back.
|
|
77
90
|
export async function reserveCalendarCap(
|
|
78
|
-
ctx:
|
|
91
|
+
ctx: CapCounterContext,
|
|
79
92
|
options: ReserveCalendarCapOptions,
|
|
80
|
-
|
|
81
|
-
|
|
93
|
+
outsideTransaction = true,
|
|
94
|
+
): Promise<CalendarCapReservation> {
|
|
95
|
+
const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
|
|
82
96
|
const id = generateId();
|
|
97
|
+
// The last attempt is the one that committed, so this holds the value its increment started from.
|
|
98
|
+
let valueBeforeBooking = 0;
|
|
99
|
+
const observedOptions: ReserveCalendarCapOptions = {
|
|
100
|
+
...options,
|
|
101
|
+
guardCurrentValue: (currentValue) => {
|
|
102
|
+
options.guardCurrentValue?.(currentValue);
|
|
103
|
+
valueBeforeBooking = currentValue;
|
|
104
|
+
},
|
|
105
|
+
};
|
|
83
106
|
const reserved = await retryCounterWriteOnVersionConflict(() =>
|
|
84
|
-
runInOwnTransaction(
|
|
85
|
-
const booked = await applyCapDeltaOn(txDb,
|
|
107
|
+
runInOwnTransaction(writeDb, async (txDb) => {
|
|
108
|
+
const booked = await applyCapDeltaOn(txDb, user, observedOptions, "add");
|
|
86
109
|
if (!booked.isSuccess) return booked;
|
|
87
|
-
await insertReservationRow(txDb, id, {
|
|
110
|
+
await insertReservationRow(txDb, user, id, {
|
|
88
111
|
capName: options.capName,
|
|
89
112
|
kind: "calendar",
|
|
90
113
|
periodStartIso: options.periodStartIso,
|
|
@@ -94,7 +117,11 @@ export async function reserveCalendarCap(
|
|
|
94
117
|
}),
|
|
95
118
|
);
|
|
96
119
|
if (!reserved.isSuccess) throw reraiseAsKumikoError(reserved.error);
|
|
97
|
-
return
|
|
120
|
+
return {
|
|
121
|
+
...toHandle(ctx, id, outsideTransaction),
|
|
122
|
+
reservationId: id,
|
|
123
|
+
counterValue: valueBeforeBooking + options.amount,
|
|
124
|
+
};
|
|
98
125
|
}
|
|
99
126
|
|
|
100
127
|
export type ReserveRollingCapOptions = ReadRollingCapUsageOptions & {
|
|
@@ -123,28 +150,23 @@ function rollingEvent(
|
|
|
123
150
|
// The append is version-guarded: a booker that read the usage before another one appended loses the
|
|
124
151
|
// race and repeats the whole attempt, so the hard-cap check always sees the stream it appends to.
|
|
125
152
|
export async function reserveRollingCap(
|
|
126
|
-
ctx:
|
|
153
|
+
ctx: CapCounterContext,
|
|
127
154
|
options: ReserveRollingCapOptions,
|
|
155
|
+
outsideTransaction = true,
|
|
128
156
|
): Promise<ReservationHandle> {
|
|
129
|
-
const
|
|
157
|
+
const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
|
|
130
158
|
const id = generateId();
|
|
131
|
-
const aggregateId = rollingCapAggregateId(
|
|
159
|
+
const aggregateId = rollingCapAggregateId(user.tenantId, options.capName);
|
|
132
160
|
await retryOnStreamVersionConflict(() =>
|
|
133
|
-
runInOwnTransaction(
|
|
161
|
+
runInOwnTransaction(writeDb, async (txDb) => {
|
|
134
162
|
const version = await getStreamVersionInTenantDb(txDb, aggregateId);
|
|
135
|
-
const usage = await readRollingCapUsage(txDb,
|
|
163
|
+
const usage = await readRollingCapUsage(txDb, user.tenantId, options);
|
|
136
164
|
options.guardCurrentValue?.(usage);
|
|
137
165
|
await appendEventInTenantDb(
|
|
138
166
|
txDb,
|
|
139
|
-
rollingEvent(
|
|
140
|
-
ctx.user,
|
|
141
|
-
options.capName,
|
|
142
|
-
options.amount,
|
|
143
|
-
version,
|
|
144
|
-
ROLLING_INCREMENTED_EVENT_QN,
|
|
145
|
-
),
|
|
167
|
+
rollingEvent(user, options.capName, options.amount, version, ROLLING_INCREMENTED_EVENT_QN),
|
|
146
168
|
);
|
|
147
|
-
await insertReservationRow(txDb, id, {
|
|
169
|
+
await insertReservationRow(txDb, user, id, {
|
|
148
170
|
capName: options.capName,
|
|
149
171
|
kind: "rolling",
|
|
150
172
|
amount: options.amount,
|
|
@@ -152,7 +174,7 @@ export async function reserveRollingCap(
|
|
|
152
174
|
return NOTHING_TO_RELEASE;
|
|
153
175
|
}),
|
|
154
176
|
);
|
|
155
|
-
return toHandle(ctx, id);
|
|
177
|
+
return toHandle(ctx, id, outsideTransaction);
|
|
156
178
|
}
|
|
157
179
|
|
|
158
180
|
// Deleting the row takes its row lock inside the handler transaction, so a release started after a
|
|
@@ -201,45 +223,123 @@ async function giveBackReservation(
|
|
|
201
223
|
user: SessionUser,
|
|
202
224
|
id: string,
|
|
203
225
|
): Promise<WriteResult> {
|
|
204
|
-
const
|
|
226
|
+
const ownRow = { id, tenantId: user.tenantId };
|
|
227
|
+
const row = await txDb.fetchOne<CapReservationRow>(capReservationsTable, ownRow);
|
|
205
228
|
if (!row) return NOTHING_TO_RELEASE;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (claimed.length === 0) return NOTHING_TO_RELEASE;
|
|
229
|
+
const claimed = await claimReservationRow(txDb, user, row);
|
|
230
|
+
if (!claimed) return NOTHING_TO_RELEASE;
|
|
209
231
|
const given =
|
|
210
232
|
row.kind === "rolling"
|
|
211
233
|
? await giveBackRolling(txDb, user, row)
|
|
212
234
|
: await giveBackCalendar(txDb, user, row);
|
|
213
235
|
if (!given.isSuccess) return given;
|
|
214
|
-
await txDb.deleteMany(capReservationsTable,
|
|
236
|
+
await txDb.deleteMany(capReservationsTable, ownRow);
|
|
215
237
|
return given;
|
|
216
238
|
}
|
|
217
239
|
|
|
218
|
-
|
|
219
|
-
|
|
240
|
+
// Waits for a confirm still holding the row; false means that confirm (or another release) won.
|
|
241
|
+
async function claimReservationRow(
|
|
242
|
+
txDb: TenantDb,
|
|
243
|
+
user: SessionUser,
|
|
244
|
+
row: CapReservationRow,
|
|
245
|
+
): Promise<boolean> {
|
|
246
|
+
const claimed = await txDb.updateMany(
|
|
247
|
+
capReservationsTable,
|
|
248
|
+
{ expiresAt: row.expiresAt },
|
|
249
|
+
{ id: row.id, tenantId: user.tenantId },
|
|
250
|
+
);
|
|
251
|
+
return claimed.length > 0;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function releaseCapReservation(
|
|
255
|
+
ctx: CapCounterContext,
|
|
256
|
+
id: string,
|
|
257
|
+
outsideTransaction = true,
|
|
258
|
+
): Promise<void> {
|
|
259
|
+
const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
|
|
220
260
|
const released = await retryOnStreamVersionConflict(() =>
|
|
221
261
|
retryCounterWriteOnVersionConflict(() =>
|
|
222
|
-
runInOwnTransaction(
|
|
262
|
+
runInOwnTransaction(writeDb, (txDb) => giveBackReservation(txDb, user, id)),
|
|
223
263
|
),
|
|
224
264
|
);
|
|
225
265
|
if (!released.isSuccess) throw reraiseAsKumikoError(released.error);
|
|
226
266
|
}
|
|
227
267
|
|
|
268
|
+
export type SettleCalendarCapOptions = {
|
|
269
|
+
readonly capName: string;
|
|
270
|
+
readonly periodStartIso: string;
|
|
271
|
+
readonly actualAmount: number;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
function bookCalendarDelta(
|
|
275
|
+
txDb: TenantDb,
|
|
276
|
+
user: SessionUser,
|
|
277
|
+
period: Pick<SettleCalendarCapOptions, "capName" | "periodStartIso">,
|
|
278
|
+
delta: number,
|
|
279
|
+
): Promise<WriteResult> {
|
|
280
|
+
if (delta === 0) return Promise.resolve({ isSuccess: true, data: {} });
|
|
281
|
+
return applyCapDeltaOn(
|
|
282
|
+
txDb,
|
|
283
|
+
user,
|
|
284
|
+
{ ...period, amount: Math.abs(delta) },
|
|
285
|
+
delta > 0 ? "add" : "subtract",
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Replaces the reserved amount by the actual one in a single transaction. When the row is already
|
|
290
|
+
// gone (swept or released), the reserved amount was given back, so the full actual amount is booked.
|
|
291
|
+
export async function settleCalendarCapReservation(
|
|
292
|
+
ctx: CapCounterContext,
|
|
293
|
+
id: string,
|
|
294
|
+
options: SettleCalendarCapOptions,
|
|
295
|
+
outsideTransaction = true,
|
|
296
|
+
): Promise<void> {
|
|
297
|
+
if (!Number.isInteger(options.actualAmount) || options.actualAmount < 0) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
`cap-counter: settle amount must be a non-negative integer, got ${options.actualAmount}`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
|
|
303
|
+
const settled = await retryCounterWriteOnVersionConflict(() =>
|
|
304
|
+
runInOwnTransaction(writeDb, async (txDb) => {
|
|
305
|
+
const row = await txDb.fetchOne<CapReservationRow>(capReservationsTable, {
|
|
306
|
+
id,
|
|
307
|
+
tenantId: user.tenantId,
|
|
308
|
+
});
|
|
309
|
+
const claimed = row ? await claimReservationRow(txDb, user, row) : false;
|
|
310
|
+
if (!row || !claimed) {
|
|
311
|
+
return bookCalendarDelta(txDb, user, options, options.actualAmount);
|
|
312
|
+
}
|
|
313
|
+
const booked = await bookCalendarDelta(
|
|
314
|
+
txDb,
|
|
315
|
+
user,
|
|
316
|
+
options,
|
|
317
|
+
options.actualAmount - row.amount,
|
|
318
|
+
);
|
|
319
|
+
if (!booked.isSuccess) return booked;
|
|
320
|
+
await txDb.deleteMany(capReservationsTable, { id, tenantId: user.tenantId });
|
|
321
|
+
return booked;
|
|
322
|
+
}),
|
|
323
|
+
);
|
|
324
|
+
if (!settled.isSuccess) throw reraiseAsKumikoError(settled.error);
|
|
325
|
+
}
|
|
326
|
+
|
|
228
327
|
// A failed release leaves its row behind; giving it back once the TTL passed bounds how long that
|
|
229
328
|
// capacity stays counted.
|
|
230
329
|
export async function releaseExpiredCapReservations(
|
|
231
|
-
ctx:
|
|
330
|
+
ctx: CapCounterContext,
|
|
232
331
|
capName: string,
|
|
332
|
+
outsideTransaction = true,
|
|
233
333
|
): Promise<void> {
|
|
234
|
-
const
|
|
235
|
-
const expired = await
|
|
236
|
-
tenantId:
|
|
334
|
+
const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
|
|
335
|
+
const expired = await writeDb.selectMany<Pick<CapReservationRow, "id">>(capReservationsTable, {
|
|
336
|
+
tenantId: user.tenantId,
|
|
237
337
|
capName,
|
|
238
338
|
expiresAt: { lt: Temporal.Now.instant() },
|
|
239
339
|
});
|
|
240
340
|
for (const { id } of expired) {
|
|
241
341
|
try {
|
|
242
|
-
await releaseCapReservation(ctx, id);
|
|
342
|
+
await releaseCapReservation(ctx, id, outsideTransaction);
|
|
243
343
|
} catch (error) {
|
|
244
344
|
ctx.log?.error("releasing an expired cap reservation failed", { id, capName, error });
|
|
245
345
|
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.358.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Reserve and settle cap usage outside the dispatcher",
|
|
6
|
+
"detail": "reserveCalendarCapUsage(ctx, { capName, periodStartIso, limit, profile, amount, notify? }) runs the same sweep, soft-hit notification and guarded hard-cap reservation as withCapEnforcement and returns a handle. settle(actualAmount) replaces the reserved amount by the actual one in one transaction (and books the full actual amount if the reservation was swept or released meanwhile); release() gives it back. Parallel callers cannot exceed the hard cap. It works with a HandlerContext (also r.systemScope() handlers) and a JobContext. Pattern: `const r = await reserveCalendarCapUsage(ctx, {...}); try { ...; await r.settle(actual) } catch (e) { await r.release(); throw e }`. bookCapUsage, markCapSoftWarned, releaseCapUsage and the enforceCap family accept a JobContext as well.",
|
|
7
|
+
"migration": "withCapEnforcement and bookCapUsage now work on r.systemScope() handlers; before, they failed on the fail-closed ctx.db. Reservation rows carry the tenant of ctx.user explicitly, and a context whose tenant-mode db belongs to another tenant than ctx.user is rejected with access_denied. enforceCap now filters the counter by ctx.user.tenantId, so a system-mode db no longer reads another tenant's counter."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.352.0",
|
|
4
11
|
"type": "breaking",
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import { createEntityExecutor
|
|
1
|
+
import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { KumikoError, reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
type CapCounterContext,
|
|
6
|
+
markCapSoftWarned,
|
|
7
|
+
readRollingCapUsage,
|
|
8
|
+
resolveCapCounterScope,
|
|
9
|
+
} from "./book-cap-usage.js";
|
|
5
10
|
import { capCounterEntity } from "./entity.js";
|
|
6
11
|
|
|
7
12
|
const { table } = createEntityExecutor("cap-counter", capCounterEntity);
|
|
@@ -95,7 +100,7 @@ export function assertBelowHardCap(
|
|
|
95
100
|
* level (e.g. uniqueness-index on apps.tenantId+slot-number).
|
|
96
101
|
*/
|
|
97
102
|
export async function enforceCap(
|
|
98
|
-
ctx:
|
|
103
|
+
ctx: CapCounterContext,
|
|
99
104
|
options: {
|
|
100
105
|
readonly capName: string;
|
|
101
106
|
readonly periodStartIso: string;
|
|
@@ -106,9 +111,7 @@ export async function enforceCap(
|
|
|
106
111
|
readonly amount?: number;
|
|
107
112
|
},
|
|
108
113
|
): Promise<EnforceCapResult> {
|
|
109
|
-
|
|
110
|
-
throw new Error("cap-counter.enforceCap: ctx.db missing — run inside a handler context");
|
|
111
|
-
}
|
|
114
|
+
const { user, readDb } = resolveCapCounterScope(ctx, false);
|
|
112
115
|
|
|
113
116
|
const amount = options.amount ?? 1;
|
|
114
117
|
if (!Number.isInteger(amount) || amount < 1) {
|
|
@@ -120,9 +123,9 @@ export async function enforceCap(
|
|
|
120
123
|
const tolerance = CAP_TOLERANCES[options.profile];
|
|
121
124
|
const softThreshold = options.limit * tolerance.soft;
|
|
122
125
|
|
|
123
|
-
const rows = await
|
|
126
|
+
const rows = await readDb.selectMany(
|
|
124
127
|
table,
|
|
125
|
-
{ capName: options.capName, periodStart: options.periodStartIso },
|
|
128
|
+
{ tenantId: user.tenantId, capName: options.capName, periodStart: options.periodStartIso },
|
|
126
129
|
{ limit: 1 },
|
|
127
130
|
);
|
|
128
131
|
|
|
@@ -170,7 +173,7 @@ export async function enforceCap(
|
|
|
170
173
|
* vorgezogen.
|
|
171
174
|
*/
|
|
172
175
|
export async function enforceRollingCap(
|
|
173
|
-
ctx:
|
|
176
|
+
ctx: CapCounterContext,
|
|
174
177
|
options: {
|
|
175
178
|
readonly capName: string;
|
|
176
179
|
readonly windowDays: number;
|
|
@@ -178,20 +181,13 @@ export async function enforceRollingCap(
|
|
|
178
181
|
readonly profile: CapToleranceProfileName;
|
|
179
182
|
},
|
|
180
183
|
): Promise<EnforceCapResult> {
|
|
181
|
-
|
|
182
|
-
throw new Error("cap-counter.enforceRollingCap: ctx.db missing — run inside a handler context");
|
|
183
|
-
}
|
|
184
|
-
if (!ctx.user?.tenantId) {
|
|
185
|
-
throw new Error(
|
|
186
|
-
"cap-counter.enforceRollingCap: ctx.user.tenantId missing — required to compute aggregate-id",
|
|
187
|
-
);
|
|
188
|
-
}
|
|
184
|
+
const { user, readDb } = resolveCapCounterScope(ctx, false);
|
|
189
185
|
|
|
190
186
|
const tolerance = CAP_TOLERANCES[options.profile];
|
|
191
187
|
const softThreshold = options.limit * tolerance.soft;
|
|
192
188
|
const hardThreshold = options.limit * tolerance.hard;
|
|
193
189
|
|
|
194
|
-
const value = await readRollingCapUsage(
|
|
190
|
+
const value = await readRollingCapUsage(readDb, user.tenantId, {
|
|
195
191
|
capName: options.capName,
|
|
196
192
|
windowDays: options.windowDays,
|
|
197
193
|
});
|
|
@@ -295,7 +291,7 @@ export type SoftHitNotifier = (info: {
|
|
|
295
291
|
* Email-Notification).
|
|
296
292
|
*/
|
|
297
293
|
export async function enforceCapAndMaybeNotify(
|
|
298
|
-
ctx:
|
|
294
|
+
ctx: CapCounterContext,
|
|
299
295
|
options: {
|
|
300
296
|
readonly capName: string;
|
|
301
297
|
readonly periodStartIso: string;
|
|
@@ -315,16 +311,11 @@ export async function enforceCapAndMaybeNotify(
|
|
|
315
311
|
});
|
|
316
312
|
|
|
317
313
|
if (result.state === "soft-hit" && result.crossed) {
|
|
318
|
-
if (!ctx.user?.tenantId) {
|
|
319
|
-
throw new Error(
|
|
320
|
-
"cap-counter.enforceCapAndMaybeNotify: ctx.user.tenantId missing — required for notification",
|
|
321
|
-
);
|
|
322
|
-
}
|
|
323
314
|
await options.notify({
|
|
324
315
|
capName: options.capName,
|
|
325
316
|
value: result.value,
|
|
326
317
|
limit: options.limit,
|
|
327
|
-
tenantId: ctx.user.tenantId,
|
|
318
|
+
tenantId: resolveCapCounterScope(ctx, false).user.tenantId,
|
|
328
319
|
});
|
|
329
320
|
// Flip the soft-warned flag so the same period doesn't re-notify.
|
|
330
321
|
const marked = await markCapSoftWarned(ctx, {
|
|
@@ -349,7 +340,7 @@ export async function enforceCapAndMaybeNotify(
|
|
|
349
340
|
* Tag/Stunde auf (Hourly-Cron statt pro Request).
|
|
350
341
|
*/
|
|
351
342
|
export async function enforceRollingCapAndMaybeNotify(
|
|
352
|
-
ctx:
|
|
343
|
+
ctx: CapCounterContext,
|
|
353
344
|
options: {
|
|
354
345
|
readonly capName: string;
|
|
355
346
|
readonly windowDays: number;
|
|
@@ -366,16 +357,11 @@ export async function enforceRollingCapAndMaybeNotify(
|
|
|
366
357
|
});
|
|
367
358
|
|
|
368
359
|
if (result.state === "soft-hit") {
|
|
369
|
-
if (!ctx.user?.tenantId) {
|
|
370
|
-
throw new Error(
|
|
371
|
-
"cap-counter.enforceRollingCapAndMaybeNotify: ctx.user.tenantId missing — required for notification",
|
|
372
|
-
);
|
|
373
|
-
}
|
|
374
360
|
await options.notify({
|
|
375
361
|
capName: options.capName,
|
|
376
362
|
value: result.value,
|
|
377
363
|
limit: options.limit,
|
|
378
|
-
tenantId: ctx.user.tenantId,
|
|
364
|
+
tenantId: resolveCapCounterScope(ctx, false).user.tenantId,
|
|
379
365
|
});
|
|
380
366
|
}
|
|
381
367
|
|
|
@@ -9,6 +9,11 @@ export {
|
|
|
9
9
|
type ReadRollingCapUsageOptions,
|
|
10
10
|
readRollingCapUsage,
|
|
11
11
|
} from "./book-cap-usage.js";
|
|
12
|
+
export {
|
|
13
|
+
type CalendarCapUsageReservation,
|
|
14
|
+
type CalendarCapUsageReservationSpec,
|
|
15
|
+
reserveCalendarCapUsage,
|
|
16
|
+
} from "./calendar-cap-usage-reservation.js";
|
|
12
17
|
export {
|
|
13
18
|
CAP_COUNTER_FEATURE,
|
|
14
19
|
CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
|
|
@@ -29,15 +29,11 @@ import type {
|
|
|
29
29
|
WriteEvent,
|
|
30
30
|
WriteHandlerDef,
|
|
31
31
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
32
|
-
import {
|
|
33
|
-
|
|
34
|
-
reserveCalendarCap,
|
|
35
|
-
reserveRollingCap,
|
|
36
|
-
} from "./cap-reservation.js";
|
|
32
|
+
import { reserveEnforcedCalendarCap } from "./calendar-cap-usage-reservation.js";
|
|
33
|
+
import { releaseExpiredCapReservations, reserveRollingCap } from "./cap-reservation.js";
|
|
37
34
|
import {
|
|
38
35
|
assertBelowHardCap,
|
|
39
36
|
type CapToleranceProfileName,
|
|
40
|
-
enforceCapAndMaybeNotify,
|
|
41
37
|
enforceRollingCapAndMaybeNotify,
|
|
42
38
|
type SoftHitNotifier,
|
|
43
39
|
} from "./enforce-cap.js";
|
|
@@ -93,25 +89,7 @@ export function withCapEnforcement(
|
|
|
93
89
|
handler.reserveBeforeTransaction,
|
|
94
90
|
async (event, ctx) => {
|
|
95
91
|
const cap = await capResolver(event, ctx);
|
|
96
|
-
|
|
97
|
-
await releaseExpiredCapReservations(ctx, cap.capName);
|
|
98
|
-
await enforceCapAndMaybeNotify(ctx, {
|
|
99
|
-
capName: cap.capName,
|
|
100
|
-
periodStartIso: cap.periodStartIso,
|
|
101
|
-
limit: cap.limit,
|
|
102
|
-
profile: cap.profile,
|
|
103
|
-
notify: cap.notify,
|
|
104
|
-
markSoftWarnedOutsideTransaction: true,
|
|
105
|
-
...(cap.amount !== undefined && { amount: cap.amount }),
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
const amount = cap.amount ?? 1;
|
|
109
|
-
return reserveCalendarCap(ctx, {
|
|
110
|
-
capName: cap.capName,
|
|
111
|
-
amount,
|
|
112
|
-
periodStartIso: cap.periodStartIso,
|
|
113
|
-
guardCurrentValue: (currentValue) => assertBelowHardCap(currentValue + amount - 1, cap),
|
|
114
|
-
});
|
|
92
|
+
return reserveEnforcedCalendarCap(ctx, { ...cap, amount: cap.amount ?? 1 });
|
|
115
93
|
},
|
|
116
94
|
),
|
|
117
95
|
};
|
|
@@ -325,7 +325,6 @@ async function sweepDerivedSubjectData(
|
|
|
325
325
|
if (ctx.registry.features.has("notes-history")) {
|
|
326
326
|
await eraseNoteBodiesReachingSubject({
|
|
327
327
|
db: crossTenantSubjectRunner,
|
|
328
|
-
registry: ctx.registry,
|
|
329
328
|
subject,
|
|
330
329
|
erasedBy: audit.erasedBy,
|
|
331
330
|
requestId: audit.requestId,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
KeyErasedError,
|
|
15
15
|
type LocalKeyKmsAdapter,
|
|
16
16
|
PII_ERASED_SENTINEL,
|
|
17
|
+
type SubjectId,
|
|
17
18
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
18
19
|
import { acquireNamespacedAdvisoryLock, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
19
20
|
import { type Registry, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -71,6 +72,15 @@ const DEFAULT_CACHE_MAX_ENTRIES = 10_000;
|
|
|
71
72
|
// to DB-lookup if actually needed.
|
|
72
73
|
const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
73
74
|
|
|
75
|
+
// Run rows hang on SYSTEM_TENANT_ID and a null trigger is a cron/system run;
|
|
76
|
+
// job-run-table.ts declares whenAbsent: "tenant" for exactly that case, so the
|
|
77
|
+
// logger must resolve the same subject the entity chain would (fw#3654).
|
|
78
|
+
function jobRunSubject(triggeredById: string | null): SubjectId {
|
|
79
|
+
return triggeredById === null
|
|
80
|
+
? { kind: "tenant", tenantId: SYSTEM_TENANT_ID }
|
|
81
|
+
: { kind: "user", userId: triggeredById };
|
|
82
|
+
}
|
|
83
|
+
|
|
74
84
|
// Same per-subject encryption as encryptFailureError/encryptStartedPayload,
|
|
75
85
|
// applied to a single value — shared so payload/message/error all go
|
|
76
86
|
// through the identical erased-key fallback. A key erased right before (or
|
|
@@ -81,14 +91,14 @@ const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
|
81
91
|
// whole run row.
|
|
82
92
|
async function encryptOrSentinel(
|
|
83
93
|
kms: LocalKeyKmsAdapter,
|
|
84
|
-
|
|
94
|
+
subject: SubjectId,
|
|
85
95
|
value: string,
|
|
86
96
|
field: string,
|
|
87
97
|
): Promise<string> {
|
|
88
98
|
try {
|
|
89
99
|
return await encryptPiiValueForSubject(
|
|
90
100
|
kms,
|
|
91
|
-
|
|
101
|
+
subject,
|
|
92
102
|
value,
|
|
93
103
|
{ requestId: "jobs:job-run-logger" },
|
|
94
104
|
field,
|
|
@@ -104,20 +114,20 @@ async function encryptOrSentinel(
|
|
|
104
114
|
// jobRunLogsTable — that table is unmanaged/direct-write, so there is no
|
|
105
115
|
// event-piiFields catalog to lean on here either. "message" is the AAD
|
|
106
116
|
// field name and must match the field passed to decryptStoredPii on read
|
|
107
|
-
// (detail.query.ts) or decrypt fails loud. Same skip
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
117
|
+
// (detail.query.ts) or decrypt fails loud. Same skip rule as the payload:
|
|
118
|
+
// absent KMS (rollout mode) stays plaintext. Bounded concurrency (#2307) —
|
|
119
|
+
// unbounded Promise.all would fire one getOrCreateDek per log line at once
|
|
120
|
+
// against the KMS adapter's pool.
|
|
111
121
|
async function encryptLogMessages<T extends { readonly message: string }>(
|
|
112
122
|
logs: readonly T[],
|
|
113
123
|
triggeredById: string | null,
|
|
114
124
|
): Promise<T[]> {
|
|
115
|
-
if (triggeredById === null) return [...logs];
|
|
116
125
|
const kms = configuredPiiSubjectKms();
|
|
117
126
|
if (!kms) return [...logs];
|
|
127
|
+
const subject = jobRunSubject(triggeredById);
|
|
118
128
|
return mapWithConcurrency(logs, KMS_POOL_CONCURRENCY, async (log) => ({
|
|
119
129
|
...log,
|
|
120
|
-
message: await encryptOrSentinel(kms,
|
|
130
|
+
message: await encryptOrSentinel(kms, subject, log.message, "message"),
|
|
121
131
|
}));
|
|
122
132
|
}
|
|
123
133
|
|
|
@@ -131,29 +141,27 @@ export async function encryptFailureError(
|
|
|
131
141
|
error: string,
|
|
132
142
|
triggeredById: string | null,
|
|
133
143
|
): Promise<string> {
|
|
134
|
-
if (triggeredById === null) return error;
|
|
135
144
|
const kms = configuredPiiSubjectKms();
|
|
136
145
|
if (!kms) return error;
|
|
137
|
-
return encryptOrSentinel(kms, triggeredById, error, "error");
|
|
146
|
+
return encryptOrSentinel(kms, jobRunSubject(triggeredById), error, "error");
|
|
138
147
|
}
|
|
139
148
|
|
|
140
149
|
// The run-started payload can carry arbitrary user data; triggeredById
|
|
141
150
|
// names its owning user. No event-PII catalog involved (#2243 removed the
|
|
142
151
|
// jobRun r.defineEvent registrations, so there is nothing to catalog) —
|
|
143
152
|
// the subject is known statically, so we encrypt directly. A null subject
|
|
144
|
-
// (system cron runs, recipient-less triggers)
|
|
145
|
-
//
|
|
146
|
-
// rule. Absent KMS adapter stays plaintext too (rollout mode, unchanged).
|
|
153
|
+
// (system cron runs, recipient-less triggers) encrypts under the system
|
|
154
|
+
// tenant key. Absent KMS adapter stays plaintext (rollout mode, unchanged).
|
|
147
155
|
// A key already erased by the time the trigger lands (#2307) must not fail
|
|
148
156
|
// the whole run-start write — sentinel like the log/error paths.
|
|
149
157
|
async function encryptStartedPayload(
|
|
150
158
|
payload: string | null,
|
|
151
159
|
triggeredById: string | null,
|
|
152
160
|
): Promise<string | null> {
|
|
153
|
-
if (payload === null
|
|
161
|
+
if (payload === null) return payload;
|
|
154
162
|
const kms = configuredPiiSubjectKms();
|
|
155
163
|
if (!kms) return payload;
|
|
156
|
-
return encryptOrSentinel(kms, triggeredById, payload, "payload");
|
|
164
|
+
return encryptOrSentinel(kms, jobRunSubject(triggeredById), payload, "payload");
|
|
157
165
|
}
|
|
158
166
|
|
|
159
167
|
// pg_advisory_xact_lock namespace (int4): 'tjfl' as ASCII, disjoint from the
|
|
@@ -46,11 +46,18 @@ export const jobRunEntity = createEntity({
|
|
|
46
46
|
status: createTextField({ required: true, personal: false, reason: "system_metadata" }),
|
|
47
47
|
// The run-started payload can carry arbitrary caller-supplied data,
|
|
48
48
|
// encrypted by job-run-logger.ts under the triggering user's DEK (see
|
|
49
|
-
// jobs-pii-kms.integration.test.ts) — same subject as `triggeredById
|
|
50
|
-
|
|
49
|
+
// jobs-pii-kms.integration.test.ts) — same subject as `triggeredById`;
|
|
50
|
+
// cron runs have none and use the system tenant key (whenAbsent).
|
|
51
|
+
payload: createTextField({
|
|
52
|
+
personal: { of: "triggeredById", whenAbsent: "tenant" },
|
|
53
|
+
find: "none",
|
|
54
|
+
}),
|
|
51
55
|
// Failure messages can echo payload content back — same subject/DEK as
|
|
52
56
|
// `payload` (job-run-logger.ts encryptFailureError).
|
|
53
|
-
error: createTextField({
|
|
57
|
+
error: createTextField({
|
|
58
|
+
personal: { of: "triggeredById", whenAbsent: "tenant" },
|
|
59
|
+
find: "none",
|
|
60
|
+
}),
|
|
54
61
|
attempt: createNumberField({ required: true, default: 1, integer: true }),
|
|
55
62
|
startedAt: createTimestampField({ required: true }),
|
|
56
63
|
finishedAt: createTimestampField(),
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.358.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "forget-subject shreds note bodies even when the host entity has anonymize or blockDelete retention",
|
|
6
|
+
"detail": "Note bodies that @-mention the forgotten subject or hang on it as host entity were skipped when the host entity's retention strategy was anonymize or blockDelete, so the note text stayed readable while the host's own PII fields were anonymized. The note text is free-form PII, and a retention strategy protects the row, not its content, so these bodies are now always crypto-shredded. This applies to the manual `forget-subject` command and to the automated Art. 17 delete hook."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.354.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -77,9 +77,10 @@ export function createNoteEntryEntity(
|
|
|
77
77
|
}),
|
|
78
78
|
// Stamped once at write time, never re-resolved later — the history
|
|
79
79
|
// needs the name as it was then, not whatever the account is called now.
|
|
80
|
+
// whenAbsent: authorless notes (system-written) encrypt under the tenant key.
|
|
80
81
|
authorName: createTextField({
|
|
81
82
|
maxLength: 200,
|
|
82
|
-
personal: { of: "authorId" },
|
|
83
|
+
personal: { of: "authorId", whenAbsent: "tenant" },
|
|
83
84
|
find: "none",
|
|
84
85
|
}),
|
|
85
86
|
body: createLongTextField({
|
|
@@ -8,18 +8,11 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
8
8
|
import { configuredPiiSubjectKms, type SubjectId } from "@cosmicdrift/kumiko-framework/crypto";
|
|
9
9
|
import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
10
10
|
import type {
|
|
11
|
-
Registry,
|
|
12
11
|
TenantId,
|
|
13
12
|
UserDataDeleteHook,
|
|
14
13
|
UserDataExportHook,
|
|
15
14
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
-
import {
|
|
17
|
-
type RetentionPresetKey,
|
|
18
|
-
resolveRetentionPolicyForTenant,
|
|
19
|
-
resolveTenantRetentionPreset,
|
|
20
|
-
} from "../data-retention/index.js";
|
|
21
15
|
import { noteEntryTable, noteMentionTable } from "../notes-history/index.js";
|
|
22
|
-
import { policyToStrategy } from "../user-data-rights/index.js";
|
|
23
16
|
|
|
24
17
|
// note-entry has no per-tenant scope quirk (unlike folders) — it's genuinely
|
|
25
18
|
// per-user content, so the export filters by authorId directly.
|
|
@@ -84,17 +77,15 @@ async function selectNotesReachingSubject(
|
|
|
84
77
|
// @-mentions the subject (`note-mention`, fw#2787) or hangs on it as host.
|
|
85
78
|
// Free-text mentions are NOT detected — the documented gap in entity.ts.
|
|
86
79
|
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// automated forget-cleanup applies to every other entity.
|
|
80
|
+
// Reached bodies are shredded regardless of the host entity's retention strategy:
|
|
81
|
+
// the note text is free-form PII, and a host's anonymize/blockDelete protects the
|
|
82
|
+
// row, not its content, just like the host's own PII fields get anonymized.
|
|
91
83
|
//
|
|
92
84
|
// Without a mounted KMS this erases nothing: record-owned fields fall back to
|
|
93
85
|
// plaintext framework-wide (see pii-field-encryption.ts), so apps that need
|
|
94
86
|
// Art. 17 coverage must mount one.
|
|
95
87
|
export async function eraseNoteBodiesReachingSubject(args: {
|
|
96
88
|
readonly db: DbRunner | TenantDb;
|
|
97
|
-
readonly registry: Registry;
|
|
98
89
|
readonly subject: SubjectId;
|
|
99
90
|
readonly erasedBy: string;
|
|
100
91
|
readonly requestId: string;
|
|
@@ -104,36 +95,7 @@ export async function eraseNoteBodiesReachingSubject(args: {
|
|
|
104
95
|
if (!kms) return;
|
|
105
96
|
|
|
106
97
|
const notes = await selectNotesReachingSubject(args.db, args.subject);
|
|
107
|
-
const dataRetentionMounted = args.registry.features.has("data-retention");
|
|
108
|
-
const presetByTenant = new Map<string, RetentionPresetKey | null>();
|
|
109
|
-
const strategyByTenantAndEntityType = new Map<string, ReturnType<typeof policyToStrategy>>();
|
|
110
98
|
for (const note of notes) {
|
|
111
|
-
const strategyKey = `${note.tenantId}:${note.entityType}`;
|
|
112
|
-
let strategy = strategyByTenantAndEntityType.get(strategyKey);
|
|
113
|
-
if (strategy === undefined) {
|
|
114
|
-
let tenantPreset = presetByTenant.get(note.tenantId);
|
|
115
|
-
if (tenantPreset === undefined) {
|
|
116
|
-
tenantPreset = await resolveTenantRetentionPreset({
|
|
117
|
-
db: args.db,
|
|
118
|
-
registry: args.registry,
|
|
119
|
-
tenantId: note.tenantId,
|
|
120
|
-
});
|
|
121
|
-
presetByTenant.set(note.tenantId, tenantPreset);
|
|
122
|
-
}
|
|
123
|
-
const hostPolicy = await resolveRetentionPolicyForTenant({
|
|
124
|
-
db: args.db,
|
|
125
|
-
registry: args.registry,
|
|
126
|
-
tenantId: note.tenantId,
|
|
127
|
-
entityName: note.entityType,
|
|
128
|
-
preloadedTenantPreset: tenantPreset,
|
|
129
|
-
// Without data-retention there is no override table to read.
|
|
130
|
-
...(!dataRetentionMounted && { preloadedOverride: null }),
|
|
131
|
-
});
|
|
132
|
-
strategy = policyToStrategy(hostPolicy.policy?.strategy ?? null);
|
|
133
|
-
strategyByTenantAndEntityType.set(strategyKey, strategy);
|
|
134
|
-
}
|
|
135
|
-
if (strategy === "anonymize") continue;
|
|
136
|
-
|
|
137
99
|
await kms.eraseKey(
|
|
138
100
|
{ kind: "record", entity: "note-entry", id: note.id },
|
|
139
101
|
{
|
|
@@ -148,7 +110,6 @@ export async function eraseNoteBodiesReachingSubject(args: {
|
|
|
148
110
|
export const noteEntryDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
149
111
|
await eraseNoteBodiesReachingSubject({
|
|
150
112
|
db: ctx.db,
|
|
151
|
-
registry: ctx.registry,
|
|
152
113
|
subject: { kind: "user", userId: ctx.userId },
|
|
153
114
|
erasedBy: ctx.userId,
|
|
154
115
|
requestId: "notes-history-user-data:forget-reached-notes",
|
|
@@ -17,7 +17,7 @@ export { eraseNoteBodiesReachingSubject } from "./hooks.js";
|
|
|
17
17
|
|
|
18
18
|
export const notesHistoryUserDataFeature = defineFeature("notes-history-user-data", (r) => {
|
|
19
19
|
r.describe(
|
|
20
|
-
"GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` and `note-mention` entities. Mounts the export hook so a user's authored notes are included in the user-data export bundle; the note-entry delete hook crypto-shreds the row-subject key of each note that structurally @-mentions the forgotten user (`note-mention`) or hangs on the user as host entity (
|
|
20
|
+
"GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` and `note-mention` entities. Mounts the export hook so a user's authored notes are included in the user-data export bundle; the note-entry delete hook crypto-shreds the row-subject key of each note that structurally @-mentions the forgotten user (`note-mention`) or hangs on the user as host entity (regardless of the host entity's retention strategy: the note text is free PII, the host's anonymize/blockDelete protects the row, not the content), keeping the append-only history intact for all other notes. The manual `crypto-shredding` forget-subject command reaches the same notes for user and record subjects through the exported `eraseNoteBodiesReachingSubject`, and the export lists them under `note-mention`. `note-mention`'s own hooks are a no-op — it is a plain FK pointer, not separately exportable content. Kept separate from `notes-history` so notes consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `notes-history`.",
|
|
21
21
|
);
|
|
22
22
|
// user-data-rights is the hard dependency (EXT_USER_DATA host). notes-history
|
|
23
23
|
// is OPTIONAL: if it's mounted toggleable(default=false), a hard r.requires
|
|
@@ -153,6 +153,7 @@ export const widgetsFeature = defineFeature("widgets", (r) => {
|
|
|
153
153
|
legendTotals: false,
|
|
154
154
|
initialWindow: "from-today",
|
|
155
155
|
markerLegend: "legend",
|
|
156
|
+
markerLabels: true,
|
|
156
157
|
markerKinds: {
|
|
157
158
|
extra: { tone: "active", label: "widgets:dashboard:kind-extra" },
|
|
158
159
|
payoff: { tone: "positive", label: "widgets:dashboard:kind-payoff" },
|
|
@@ -1954,7 +1954,7 @@
|
|
|
1954
1954
|
},
|
|
1955
1955
|
{
|
|
1956
1956
|
"name": "notes-history-user-data",
|
|
1957
|
-
"description": "GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` and `note-mention` entities. Mounts the export hook so a user's authored notes are included in the user-data export bundle; the note-entry delete hook crypto-shreds the row-subject key of each note that structurally @-mentions the forgotten user (`note-mention`) or hangs on the user as host entity (
|
|
1957
|
+
"description": "GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` and `note-mention` entities. Mounts the export hook so a user's authored notes are included in the user-data export bundle; the note-entry delete hook crypto-shreds the row-subject key of each note that structurally @-mentions the forgotten user (`note-mention`) or hangs on the user as host entity (regardless of the host entity's retention strategy: the note text is free PII, the host's anonymize/blockDelete protects the row, not the content), keeping the append-only history intact for all other notes. The manual `crypto-shredding` forget-subject command reaches the same notes for user and record subjects through the exported `eraseNoteBodiesReachingSubject`, and the export lists them under `note-mention`. `note-mention`'s own hooks are a no-op — it is a plain FK pointer, not separately exportable content. Kept separate from `notes-history` so notes consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `notes-history`.",
|
|
1958
1958
|
"toggleableDefault": null,
|
|
1959
1959
|
"requires": [
|
|
1960
1960
|
"user-data-rights"
|