@thomasfosterau/pulpit 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +1272 -2
- package/dist/blobs.d.ts +249 -0
- package/dist/blobs.d.ts.map +1 -0
- package/dist/blobs.js +193 -0
- package/dist/blobs.js.map +1 -0
- package/dist/budgets.d.ts +255 -0
- package/dist/budgets.d.ts.map +1 -0
- package/dist/budgets.js +211 -0
- package/dist/budgets.js.map +1 -0
- package/dist/cache.d.ts +154 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +118 -0
- package/dist/cache.js.map +1 -0
- package/dist/collections.d.ts +242 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +215 -0
- package/dist/collections.js.map +1 -0
- package/dist/comments.d.ts +212 -0
- package/dist/comments.d.ts.map +1 -0
- package/dist/comments.js +192 -0
- package/dist/comments.js.map +1 -0
- package/dist/content.d.ts +231 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +204 -0
- package/dist/content.js.map +1 -0
- package/dist/documents.d.ts +184 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +190 -0
- package/dist/documents.js.map +1 -0
- package/dist/edges.d.ts +645 -0
- package/dist/edges.d.ts.map +1 -0
- package/dist/edges.js +558 -0
- package/dist/edges.js.map +1 -0
- package/dist/filter.d.ts +461 -0
- package/dist/filter.d.ts.map +1 -0
- package/dist/filter.js +387 -0
- package/dist/filter.js.map +1 -0
- package/dist/index.d.ts +133 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +133 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/filter-columns.d.ts +8 -0
- package/dist/internal/filter-columns.d.ts.map +1 -0
- package/dist/internal/filter-columns.js +43 -0
- package/dist/internal/filter-columns.js.map +1 -0
- package/dist/internal/list-order.d.ts +26 -0
- package/dist/internal/list-order.d.ts.map +1 -0
- package/dist/internal/list-order.js +92 -0
- package/dist/internal/list-order.js.map +1 -0
- package/dist/internal/owned-columns.d.ts +93 -0
- package/dist/internal/owned-columns.d.ts.map +1 -0
- package/dist/internal/owned-columns.js +98 -0
- package/dist/internal/owned-columns.js.map +1 -0
- package/dist/internal/plain-rows.d.ts +32 -0
- package/dist/internal/plain-rows.d.ts.map +1 -0
- package/dist/internal/plain-rows.js +32 -0
- package/dist/internal/plain-rows.js.map +1 -0
- package/dist/internal/reference-columns.d.ts +80 -0
- package/dist/internal/reference-columns.d.ts.map +1 -0
- package/dist/internal/reference-columns.js +102 -0
- package/dist/internal/reference-columns.js.map +1 -0
- package/dist/moderation.d.ts +392 -0
- package/dist/moderation.d.ts.map +1 -0
- package/dist/moderation.js +375 -0
- package/dist/moderation.js.map +1 -0
- package/dist/paths.d.ts +204 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +165 -0
- package/dist/paths.js.map +1 -0
- package/dist/proposals.d.ts +523 -0
- package/dist/proposals.d.ts.map +1 -0
- package/dist/proposals.js +505 -0
- package/dist/proposals.js.map +1 -0
- package/dist/publication.d.ts +148 -0
- package/dist/publication.d.ts.map +1 -0
- package/dist/publication.js +183 -0
- package/dist/publication.js.map +1 -0
- package/dist/purge.d.ts +224 -0
- package/dist/purge.d.ts.map +1 -0
- package/dist/purge.js +151 -0
- package/dist/purge.js.map +1 -0
- package/dist/reactions.d.ts +246 -0
- package/dist/reactions.d.ts.map +1 -0
- package/dist/reactions.js +183 -0
- package/dist/reactions.js.map +1 -0
- package/dist/registry.d.ts +316 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +305 -0
- package/dist/registry.js.map +1 -0
- package/dist/search.d.ts +366 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +273 -0
- package/dist/search.js.map +1 -0
- package/dist/social.d.ts +311 -0
- package/dist/social.d.ts.map +1 -0
- package/dist/social.js +205 -0
- package/dist/social.js.map +1 -0
- package/dist/subtype.d.ts +1613 -0
- package/dist/subtype.d.ts.map +1 -0
- package/dist/subtype.js +1300 -0
- package/dist/subtype.js.map +1 -0
- package/dist/testing.d.ts +675 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +1022 -0
- package/dist/testing.js.map +1 -0
- package/dist/version-chain.d.ts +702 -0
- package/dist/version-chain.d.ts.map +1 -0
- package/dist/version-chain.js +489 -0
- package/dist/version-chain.js.map +1 -0
- package/dist/working-copy.d.ts +116 -0
- package/dist/working-copy.d.ts.map +1 -0
- package/dist/working-copy.js +95 -0
- package/dist/working-copy.js.map +1 -0
- package/package.json +145 -3
- package/src/blobs.ts +384 -0
- package/src/budgets.ts +338 -0
- package/src/cache.ts +218 -0
- package/src/collections.ts +427 -0
- package/src/comments.ts +392 -0
- package/src/content.ts +238 -0
- package/src/documents.ts +284 -0
- package/src/edges.ts +1185 -0
- package/src/filter.ts +523 -0
- package/src/index.ts +153 -0
- package/src/internal/filter-columns.ts +50 -0
- package/src/internal/list-order.ts +117 -0
- package/src/internal/owned-columns.ts +140 -0
- package/src/internal/plain-rows.ts +54 -0
- package/src/internal/reference-columns.ts +156 -0
- package/src/moderation.ts +728 -0
- package/src/paths.ts +267 -0
- package/src/proposals.ts +1014 -0
- package/src/publication.ts +225 -0
- package/src/purge.ts +313 -0
- package/src/reactions.ts +347 -0
- package/src/registry.ts +524 -0
- package/src/search.ts +540 -0
- package/src/social.ts +469 -0
- package/src/subtype.ts +3095 -0
- package/src/testing.ts +1873 -0
- package/src/version-chain.ts +1093 -0
- package/src/working-copy.ts +192 -0
package/src/budgets.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **Write budgets** (pulpit#199): a declared ceiling on how many writes of
|
|
3
|
+
* one kind an account may make in a rolling window, so a family that takes
|
|
4
|
+
* user content (comments, proposals, reports, posts) throttles it without
|
|
5
|
+
* every application re-adding the check.
|
|
6
|
+
*
|
|
7
|
+
* - **Declared once per family and action.** A {@link WriteBudget} names a
|
|
8
|
+
* limit and a window; {@link bindBudget} binds it to a family's type, a
|
|
9
|
+
* {@link BudgetCounter} and the access policy, and checks the declaration
|
|
10
|
+
* (a malformed one throws {@link BudgetDeclarationError} when it is bound).
|
|
11
|
+
* - **Spent inside the write.** A chain write spends through
|
|
12
|
+
* {@link budgetHook}, a before hook, so a write over budget appends
|
|
13
|
+
* nothing; `./proposals`' `submit` and `./moderation`'s report `file` take
|
|
14
|
+
* the bound budget as an opt-in and spend it before their insert. Either
|
|
15
|
+
* way the refusal is `WriteRejected` with the reason {@link RATE_LIMITED},
|
|
16
|
+
* which {@link isRateLimited} tells apart from every other refusal (the
|
|
17
|
+
* wire's 429).
|
|
18
|
+
* - **Exempt above a rung.** An account the budget's `exempt` rung admits
|
|
19
|
+
* (the moderate rung, by default) spends nothing.
|
|
20
|
+
* - **One counter seam.** The owner's decision on #199: count within the
|
|
21
|
+
* write's transaction, with the seam shaped so an atomic counter can be
|
|
22
|
+
* plugged in later. {@link countingCounter} is the counting
|
|
23
|
+
* implementation, one indexed `count(*)` over the family's own rows; a
|
|
24
|
+
* counter that holds its own state (a Durable Object, a locked row)
|
|
25
|
+
* implements {@link BudgetCounter.take} directly.
|
|
26
|
+
*
|
|
27
|
+
* @copyright Thomas Foster
|
|
28
|
+
* @module pulpit/budgets
|
|
29
|
+
* @since 0.1.0
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import * as Clock from "effect/Clock";
|
|
33
|
+
import * as DateTime from "effect/DateTime";
|
|
34
|
+
import * as Effect from "effect/Effect";
|
|
35
|
+
import * as Option from "effect/Option";
|
|
36
|
+
import * as Schema from "effect/Schema";
|
|
37
|
+
import {
|
|
38
|
+
WriteRejected,
|
|
39
|
+
type AccessPolicy,
|
|
40
|
+
type BeforeWriteHook,
|
|
41
|
+
type Viewer,
|
|
42
|
+
type ViewerAccount,
|
|
43
|
+
} from "./subtype.ts";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The `WriteRejected` reason a write over its budget carries, so the wire
|
|
47
|
+
* can answer 429 rather than 422.
|
|
48
|
+
*
|
|
49
|
+
* @since 0.1.0
|
|
50
|
+
*/
|
|
51
|
+
export const RATE_LIMITED = "rate-limited";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether a refusal is a write budget's ({@link RATE_LIMITED}), not another
|
|
55
|
+
* hook's.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* import { strictEqual } from "node:assert";
|
|
60
|
+
* import { isRateLimited, RATE_LIMITED } from "@thomasfosterau/pulpit/budgets";
|
|
61
|
+
* import { WriteRejected } from "@thomasfosterau/pulpit/subtype";
|
|
62
|
+
*
|
|
63
|
+
* const refusal = new WriteRejected({ type: "comments", id: "c-1", action: "create", reason: RATE_LIMITED });
|
|
64
|
+
* strictEqual(isRateLimited(refusal), true);
|
|
65
|
+
* ```
|
|
66
|
+
*
|
|
67
|
+
* @since 0.1.0
|
|
68
|
+
*/
|
|
69
|
+
export const isRateLimited = (error: WriteRejected): boolean => error.reason === RATE_LIMITED;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A write budget: at most `limit` writes per account in any window of
|
|
73
|
+
* `windowSeconds`, for every account the `exempt` rung doesn't admit.
|
|
74
|
+
*
|
|
75
|
+
* @since 0.1.0
|
|
76
|
+
*/
|
|
77
|
+
export interface WriteBudget {
|
|
78
|
+
/** The most writes one account may make in a window: a positive safe integer. */
|
|
79
|
+
readonly limit: number;
|
|
80
|
+
/** The window's length, in seconds: a positive safe integer. */
|
|
81
|
+
readonly windowSeconds: number;
|
|
82
|
+
/**
|
|
83
|
+
* The rung that spends nothing, read from the access policy (as
|
|
84
|
+
* `./edges`' `rungGate` reads one). Absent, the moderate rung.
|
|
85
|
+
*/
|
|
86
|
+
// oxlint-disable-next-line effect/prefer-option-over-null -- the optional-field convention (AGENTS.md → Conventions); absent is the moderate rung
|
|
87
|
+
readonly exempt?: ((policy: AccessPolicy) => (account: ViewerAccount) => boolean) | undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A budget declaration that can never admit a write as intended (a limit or
|
|
92
|
+
* window that is not a positive safe integer), thrown by {@link bindBudget}
|
|
93
|
+
* when the budget is bound, never as an Effect failure.
|
|
94
|
+
*
|
|
95
|
+
* @since 0.1.0
|
|
96
|
+
*/
|
|
97
|
+
export class BudgetDeclarationError extends Schema.TaggedError<
|
|
98
|
+
BudgetDeclarationError,
|
|
99
|
+
{ readonly brand: unique symbol }
|
|
100
|
+
>()("BudgetDeclarationError", { type: Schema.String, reason: Schema.String }) {}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What a {@link BudgetCounter} is asked: whether the account may make one
|
|
104
|
+
* more write of `action` on the family `type`, given the window that
|
|
105
|
+
* started at `since` and the budget's `limit`.
|
|
106
|
+
*
|
|
107
|
+
* @since 0.1.0
|
|
108
|
+
*/
|
|
109
|
+
export interface BudgetRequest {
|
|
110
|
+
readonly type: string;
|
|
111
|
+
readonly action: string;
|
|
112
|
+
readonly accountId: string;
|
|
113
|
+
readonly since: Date;
|
|
114
|
+
readonly now: Date;
|
|
115
|
+
readonly limit: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The counter seam: spend one write against the budget, answering whether
|
|
120
|
+
* it was within it. Runs in the caller's transaction. A counting
|
|
121
|
+
* implementation ({@link countingCounter}) reads the writes already made,
|
|
122
|
+
* which the write itself then adds to; an atomic one (a Durable Object, a
|
|
123
|
+
* row taken `FOR UPDATE`) records the spend itself, and must spend nothing
|
|
124
|
+
* when it answers `false`. See the package README → Seams.
|
|
125
|
+
*
|
|
126
|
+
* @since 0.1.0
|
|
127
|
+
*/
|
|
128
|
+
export interface BudgetCounter<E, R> {
|
|
129
|
+
readonly take: (request: BudgetRequest) => Effect.Effect<boolean, E, R>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The read {@link countingCounter} counts with: how many writes of `action`
|
|
134
|
+
* on `type` the account made at or after `since`, as one indexed
|
|
135
|
+
* `count(*)` over the family's own rows (`author_id`, `created_at`).
|
|
136
|
+
*
|
|
137
|
+
* @since 0.1.0
|
|
138
|
+
*/
|
|
139
|
+
export interface WriteCountStore<E, R> {
|
|
140
|
+
readonly countSince: (query: {
|
|
141
|
+
readonly type: string;
|
|
142
|
+
readonly action: string;
|
|
143
|
+
readonly accountId: string;
|
|
144
|
+
readonly since: Date;
|
|
145
|
+
}) => Effect.Effect<number, E, R>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The count-within-the-transaction counter (gardeneria's
|
|
150
|
+
* `assertNotRateLimitedE`): a write is within budget while fewer than
|
|
151
|
+
* `limit` writes were made in the window. It spends nothing itself: the
|
|
152
|
+
* write's own row is the spend, and a write that rolls back took none.
|
|
153
|
+
* **It is exact but not atomic:** two concurrent transactions can both
|
|
154
|
+
* count `limit - 1` and both write. A family that must hold its limit under
|
|
155
|
+
* concurrency fills {@link BudgetCounter} with an atomic counter instead.
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* ```ts
|
|
159
|
+
* import { strictEqual } from "node:assert";
|
|
160
|
+
* import * as Effect from "effect/Effect";
|
|
161
|
+
* import { countingCounter } from "@thomasfosterau/pulpit/budgets";
|
|
162
|
+
*
|
|
163
|
+
* const counter = countingCounter({ countSince: () => Effect.succeed(2) });
|
|
164
|
+
* const request = {
|
|
165
|
+
* type: "comments",
|
|
166
|
+
* action: "create",
|
|
167
|
+
* accountId: "u-1",
|
|
168
|
+
* since: new Date(0),
|
|
169
|
+
* now: new Date(60_000),
|
|
170
|
+
* limit: 3,
|
|
171
|
+
* };
|
|
172
|
+
* strictEqual(Effect.runSync(counter.take(request)), true);
|
|
173
|
+
* strictEqual(Effect.runSync(counter.take({ ...request, limit: 2 })), false);
|
|
174
|
+
* ```
|
|
175
|
+
*
|
|
176
|
+
* @since 0.1.0
|
|
177
|
+
*/
|
|
178
|
+
export const countingCounter = <E, R>(store: WriteCountStore<E, R>): BudgetCounter<E, R> => ({
|
|
179
|
+
take: (request) =>
|
|
180
|
+
Effect.map(
|
|
181
|
+
store.countSince({
|
|
182
|
+
type: request.type,
|
|
183
|
+
action: request.action,
|
|
184
|
+
accountId: request.accountId,
|
|
185
|
+
since: request.since,
|
|
186
|
+
}),
|
|
187
|
+
(count) => count < request.limit,
|
|
188
|
+
),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A budget bound to its family: {@link BoundBudget.spend} spends one write.
|
|
193
|
+
*
|
|
194
|
+
* @since 0.1.0
|
|
195
|
+
*/
|
|
196
|
+
export interface BoundBudget<E, R> {
|
|
197
|
+
readonly type: string;
|
|
198
|
+
readonly budget: WriteBudget;
|
|
199
|
+
/**
|
|
200
|
+
* Spend one write of `action` by `viewer` on the node `id`. An anonymous
|
|
201
|
+
* viewer spends nothing (the write's own gate refuses it), nor does one
|
|
202
|
+
* the exempt rung admits. Over budget it fails `WriteRejected` with
|
|
203
|
+
* {@link RATE_LIMITED}.
|
|
204
|
+
*/
|
|
205
|
+
readonly spend: (
|
|
206
|
+
viewer: Viewer,
|
|
207
|
+
action: string,
|
|
208
|
+
id: string,
|
|
209
|
+
) => Effect.Effect<void, WriteRejected | E, R>;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const positiveSafeInteger = (value: number) => Number.isSafeInteger(value) && value > 0;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Bind a {@link WriteBudget} to a family's type, its counter and the access
|
|
216
|
+
* policy. Throws {@link BudgetDeclarationError} for a limit or window that
|
|
217
|
+
* is not a positive safe integer.
|
|
218
|
+
*
|
|
219
|
+
* @example
|
|
220
|
+
* ```ts
|
|
221
|
+
* import { strictEqual } from "node:assert";
|
|
222
|
+
* import * as Effect from "effect/Effect";
|
|
223
|
+
* import * as Exit from "effect/Exit";
|
|
224
|
+
* import * as Option from "effect/Option";
|
|
225
|
+
* import { bindBudget } from "@thomasfosterau/pulpit/budgets";
|
|
226
|
+
* import { makeInMemoryBudgetCounter } from "@thomasfosterau/pulpit/testing";
|
|
227
|
+
*
|
|
228
|
+
* const policy = {
|
|
229
|
+
* canViewPrivate: () => false,
|
|
230
|
+
* canViewDeleted: () => false,
|
|
231
|
+
* canModerate: () => false,
|
|
232
|
+
* canWriteCorpus: () => false,
|
|
233
|
+
* };
|
|
234
|
+
* const comments = bindBudget({
|
|
235
|
+
* type: "comments",
|
|
236
|
+
* budget: { limit: 1, windowSeconds: 60 },
|
|
237
|
+
* counter: makeInMemoryBudgetCounter(),
|
|
238
|
+
* policy,
|
|
239
|
+
* });
|
|
240
|
+
* const member = Option.some({ id: "u-1", role: "member" });
|
|
241
|
+
* const twice = Effect.gen(function* () {
|
|
242
|
+
* yield* comments.spend(member, "create", "c-1");
|
|
243
|
+
* return yield* Effect.exit(comments.spend(member, "create", "c-2"));
|
|
244
|
+
* });
|
|
245
|
+
* strictEqual(Exit.isFailure(Effect.runSync(twice)), true);
|
|
246
|
+
* ```
|
|
247
|
+
*
|
|
248
|
+
* @since 0.1.0
|
|
249
|
+
*/
|
|
250
|
+
export const bindBudget = <E, R>(definition: {
|
|
251
|
+
readonly type: string;
|
|
252
|
+
readonly budget: WriteBudget;
|
|
253
|
+
readonly counter: BudgetCounter<E, R>;
|
|
254
|
+
readonly policy: AccessPolicy;
|
|
255
|
+
}): BoundBudget<E, R> => {
|
|
256
|
+
const { type, budget, counter, policy } = definition;
|
|
257
|
+
if (!positiveSafeInteger(budget.limit)) {
|
|
258
|
+
throw new BudgetDeclarationError({ type, reason: "limit must be a positive safe integer" });
|
|
259
|
+
}
|
|
260
|
+
if (!positiveSafeInteger(budget.windowSeconds)) {
|
|
261
|
+
throw new BudgetDeclarationError({
|
|
262
|
+
type,
|
|
263
|
+
reason: "windowSeconds must be a positive safe integer",
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
const exempt = (budget.exempt ?? ((rungs: AccessPolicy) => rungs.canModerate))(policy);
|
|
267
|
+
const windowMillis = budget.windowSeconds * 1000;
|
|
268
|
+
|
|
269
|
+
const spend = (viewer: Viewer, action: string, id: string) =>
|
|
270
|
+
Option.match(
|
|
271
|
+
Option.filter(viewer, (account) => !exempt(account)),
|
|
272
|
+
{
|
|
273
|
+
onNone: () => Effect.void,
|
|
274
|
+
onSome: (account) =>
|
|
275
|
+
Effect.flatMap(Clock.currentTimeMillis, (millis) =>
|
|
276
|
+
Effect.flatMap(
|
|
277
|
+
counter.take({
|
|
278
|
+
type,
|
|
279
|
+
action,
|
|
280
|
+
accountId: account.id,
|
|
281
|
+
since: DateTime.toDate(DateTime.makeUnsafe(millis - windowMillis)),
|
|
282
|
+
now: DateTime.toDate(DateTime.makeUnsafe(millis)),
|
|
283
|
+
limit: budget.limit,
|
|
284
|
+
}),
|
|
285
|
+
(within) =>
|
|
286
|
+
within
|
|
287
|
+
? Effect.void
|
|
288
|
+
: Effect.fail(new WriteRejected({ type, id, action, reason: RATE_LIMITED })),
|
|
289
|
+
),
|
|
290
|
+
),
|
|
291
|
+
},
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
return { type, budget, spend };
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* A bound budget as a before hook for a chain write (`./subtype`'s
|
|
299
|
+
* `hooks.before`), spending one write on each of the `actions` it lists
|
|
300
|
+
* (`create`, by default) and passing the patch through. A localised write
|
|
301
|
+
* spends once: the hook's `locales` pass spends nothing.
|
|
302
|
+
*
|
|
303
|
+
* @example
|
|
304
|
+
* ```ts
|
|
305
|
+
* import { strictEqual } from "node:assert";
|
|
306
|
+
* import { bindBudget, budgetHook } from "@thomasfosterau/pulpit/budgets";
|
|
307
|
+
* import { makeInMemoryBudgetCounter } from "@thomasfosterau/pulpit/testing";
|
|
308
|
+
*
|
|
309
|
+
* const policy = {
|
|
310
|
+
* canViewPrivate: () => false,
|
|
311
|
+
* canViewDeleted: () => false,
|
|
312
|
+
* canModerate: () => false,
|
|
313
|
+
* canWriteCorpus: () => false,
|
|
314
|
+
* };
|
|
315
|
+
* const hook = budgetHook(
|
|
316
|
+
* bindBudget({
|
|
317
|
+
* type: "comments",
|
|
318
|
+
* budget: { limit: 10, windowSeconds: 3600 },
|
|
319
|
+
* counter: makeInMemoryBudgetCounter(),
|
|
320
|
+
* policy,
|
|
321
|
+
* }),
|
|
322
|
+
* );
|
|
323
|
+
* strictEqual(hook.name, "comments.budget");
|
|
324
|
+
* ```
|
|
325
|
+
*
|
|
326
|
+
* @since 0.1.0
|
|
327
|
+
*/
|
|
328
|
+
export const budgetHook = <E, R>(
|
|
329
|
+
bound: BoundBudget<E, R>,
|
|
330
|
+
actions: ReadonlyArray<string> = ["create"],
|
|
331
|
+
): BeforeWriteHook<E, R> => ({
|
|
332
|
+
name: `${bound.type}.budget`,
|
|
333
|
+
run: (event) =>
|
|
334
|
+
actions.includes(event.action)
|
|
335
|
+
? Effect.as(bound.spend(event.viewer, event.action, event.id), event.patch)
|
|
336
|
+
: Effect.succeed(event.patch),
|
|
337
|
+
locales: (event) => Effect.succeed(event.locales ?? {}),
|
|
338
|
+
});
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **The reference resource cache** (pulpit#205) — `./subtype`'s
|
|
3
|
+
* {@link ResourceCacheSeam}, with a read-through `get`, over a minimal
|
|
4
|
+
* versioned key-value seam ({@link VersionedKv}).
|
|
5
|
+
*
|
|
6
|
+
* The behavioural spec is the four apps' own caches (website's, gardeneria's,
|
|
7
|
+
* preferenceflow's and in-the-dark's `apps/api/src/cache.ts`), which this
|
|
8
|
+
* replaces: one implementation of the contract the kernel already states.
|
|
9
|
+
*
|
|
10
|
+
* - **Compare-on-set** (pulpit#121). Every entry carries the version it
|
|
11
|
+
* speaks for, and the KV seam's one conditional write keeps the highest:
|
|
12
|
+
* a `set` or `delete` below the stored version changes nothing, and an
|
|
13
|
+
* eviction raises the version, so a racing writer's late write-through can
|
|
14
|
+
* never re-cache a resource older than one already replaced or evicted.
|
|
15
|
+
* - **An eviction wins a tie.** A tombstone outranks a value at the same
|
|
16
|
+
* version: only a strictly newer write clears it, so a read-through that
|
|
17
|
+
* loaded the row before an unpublish at that version can't re-cache it.
|
|
18
|
+
* The cache stores a rank, `2·version` for a value and `2·version + 1` for
|
|
19
|
+
* a tombstone, as the entry's `version`, so the seam still compares one
|
|
20
|
+
* number.
|
|
21
|
+
* - **Tombstones outlive values.** A tombstone is kept for
|
|
22
|
+
* `tombstoneTtlSeconds` (by default a day, and never less than the value
|
|
23
|
+
* TTL), which must exceed the longest a write can be in flight: a late
|
|
24
|
+
* write-through that arrives after its tombstone has expired is no longer
|
|
25
|
+
* ordered against it.
|
|
26
|
+
* - **Serveability on both sides** (`CACHE-AUTH-005`). `set` refuses (and
|
|
27
|
+
* evicts) a resource the `serveable` policy refuses, and `get` checks it
|
|
28
|
+
* again, so a policy change can't serve a stale cached private row.
|
|
29
|
+
* - **Decode or evict.** `get` decodes the stored value with the resource's
|
|
30
|
+
* schema; a value that no longer decodes (the schema moved) is evicted and
|
|
31
|
+
* read as a miss.
|
|
32
|
+
* - **A TTL backstop.** Every write carries the configured TTL, so an entry
|
|
33
|
+
* no write ever evicted still expires.
|
|
34
|
+
* - **Never fails.** A KV failure reads as a miss and skips a write: the
|
|
35
|
+
* cache is an optimisation, and the store stays the source of truth.
|
|
36
|
+
*
|
|
37
|
+
* The KV seam is the application's (a Durable Object, Redis, a SQL table):
|
|
38
|
+
* nothing here imports a platform. Workers KV alone has no conditional
|
|
39
|
+
* write, so it can't fill {@link VersionedKv.putIfNotBehind}.
|
|
40
|
+
*
|
|
41
|
+
* @copyright Thomas Foster
|
|
42
|
+
* @module pulpit/cache
|
|
43
|
+
* @since 0.1.0
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import * as Effect from "effect/Effect";
|
|
47
|
+
import * as Option from "effect/Option";
|
|
48
|
+
import * as Schema from "effect/Schema";
|
|
49
|
+
import type { ResourceCacheSeam } from "./subtype.ts";
|
|
50
|
+
|
|
51
|
+
// ── The KV seam ───────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One stored entry: the rank it is ordered by, and the encoded resource, or
|
|
55
|
+
* none for an eviction (a tombstone that keeps the rank). `makeResourceCache`
|
|
56
|
+
* stores `2·version` for a value and `2·version + 1` for a tombstone, so a
|
|
57
|
+
* tombstone outranks a value at the same node version.
|
|
58
|
+
*
|
|
59
|
+
* @since 0.1.0
|
|
60
|
+
*/
|
|
61
|
+
export interface KvEntry {
|
|
62
|
+
readonly version: bigint;
|
|
63
|
+
readonly value: Option.Option<Schema.Json>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The minimal versioned key-value store an application supplies. A store
|
|
68
|
+
* seam: no policy, fails only with its `E`. See the package README → Seams.
|
|
69
|
+
*
|
|
70
|
+
* @since 0.1.0
|
|
71
|
+
*/
|
|
72
|
+
export interface VersionedKv<E, R> {
|
|
73
|
+
/** The entry at `key`, or none (never written, or expired). */
|
|
74
|
+
readonly get: (key: string) => Effect.Effect<Option.Option<KvEntry>, E, R>;
|
|
75
|
+
/**
|
|
76
|
+
* Write `entry` at `key` with a TTL of `ttlSeconds`, **in one atomic
|
|
77
|
+
* step**, unless the stored entry's version is above `entry.version`;
|
|
78
|
+
* whether it wrote. A store with no conditional write cannot fill this
|
|
79
|
+
* seam.
|
|
80
|
+
*/
|
|
81
|
+
readonly putIfNotBehind: (
|
|
82
|
+
key: string,
|
|
83
|
+
entry: KvEntry,
|
|
84
|
+
ttlSeconds: number,
|
|
85
|
+
) => Effect.Effect<boolean, E, R>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── The cache ─────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* {@link makeResourceCache}'s configuration.
|
|
92
|
+
*
|
|
93
|
+
* @since 0.1.0
|
|
94
|
+
*/
|
|
95
|
+
export interface ResourceCacheOptions<Resource, E, R> {
|
|
96
|
+
readonly kv: VersionedKv<E, R>;
|
|
97
|
+
/** The resource's schema: its JSON encoding in the store, decoded on every read. */
|
|
98
|
+
readonly schema: Schema.Codec<Resource, Schema.Json>;
|
|
99
|
+
/** The `CACHE-AUTH-005` serveability policy (typically `./publication`'s `serveablePolicy`). */
|
|
100
|
+
readonly serveable: (resource: Resource) => boolean;
|
|
101
|
+
/** The key prefix, typically the leaf's type and a schema generation (`posts:v3:`). */
|
|
102
|
+
readonly prefix: string;
|
|
103
|
+
/** The TTL backstop every value write carries, in seconds (clamped to at least 1). */
|
|
104
|
+
readonly ttlSeconds: number;
|
|
105
|
+
/**
|
|
106
|
+
* How long an eviction's tombstone is kept, in seconds: it must exceed the
|
|
107
|
+
* longest a write-through or read-through fill can be in flight, since a
|
|
108
|
+
* stale write that lands after its tombstone expired is no longer refused.
|
|
109
|
+
* Defaults to a day ({@link DEFAULT_TOMBSTONE_TTL_SECONDS}), and is never
|
|
110
|
+
* less than `ttlSeconds`.
|
|
111
|
+
*/
|
|
112
|
+
// oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is the default
|
|
113
|
+
readonly tombstoneTtlSeconds?: number | undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The default time an eviction's tombstone is kept: one day, far longer than
|
|
118
|
+
* any request can be in flight.
|
|
119
|
+
*
|
|
120
|
+
* @since 0.1.0
|
|
121
|
+
*/
|
|
122
|
+
export const DEFAULT_TOMBSTONE_TTL_SECONDS = 86_400;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A resource cache: the kernel's write-through seam plus the read-through
|
|
126
|
+
* `get`.
|
|
127
|
+
*
|
|
128
|
+
* @since 0.1.0
|
|
129
|
+
*/
|
|
130
|
+
export interface ResourceCache<Resource, R> extends ResourceCacheSeam<Resource, R> {
|
|
131
|
+
/**
|
|
132
|
+
* The cached resource for `id`: none on a miss, an eviction, a value that
|
|
133
|
+
* no longer decodes (evicted at its version) or one the policy no longer
|
|
134
|
+
* serves (evicted likewise). Never fails.
|
|
135
|
+
*/
|
|
136
|
+
readonly get: (id: string) => Effect.Effect<Option.Option<Resource>, never, R>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The reference {@link ResourceCacheSeam} over a {@link VersionedKv}.
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* ```ts
|
|
144
|
+
* import { deepStrictEqual } from "node:assert";
|
|
145
|
+
* import * as Effect from "effect/Effect";
|
|
146
|
+
* import * as Option from "effect/Option";
|
|
147
|
+
* import * as Schema from "effect/Schema";
|
|
148
|
+
* import { makeResourceCache } from "@thomasfosterau/pulpit/cache";
|
|
149
|
+
* import { makeInMemoryVersionedKv } from "@thomasfosterau/pulpit/testing";
|
|
150
|
+
*
|
|
151
|
+
* const Post = Schema.Struct({ id: Schema.String, visibility: Schema.String });
|
|
152
|
+
* const cache = makeResourceCache({
|
|
153
|
+
* kv: makeInMemoryVersionedKv(),
|
|
154
|
+
* schema: Post,
|
|
155
|
+
* serveable: (post) => post.visibility === "public",
|
|
156
|
+
* prefix: "posts:v1:",
|
|
157
|
+
* ttlSeconds: 3600,
|
|
158
|
+
* });
|
|
159
|
+
* const program = Effect.gen(function* () {
|
|
160
|
+
* yield* cache.set("p-1", { id: "p-1", visibility: "public" }, 1n);
|
|
161
|
+
* yield* cache.set("p-1", { id: "p-1", visibility: "private" }, 0n); // behind: no-op
|
|
162
|
+
* return yield* cache.get("p-1");
|
|
163
|
+
* });
|
|
164
|
+
* deepStrictEqual(Effect.runSync(program), Option.some({ id: "p-1", visibility: "public" }));
|
|
165
|
+
* ```
|
|
166
|
+
*
|
|
167
|
+
* @since 0.1.0
|
|
168
|
+
*/
|
|
169
|
+
export const makeResourceCache = <Resource, E, R>(
|
|
170
|
+
options: ResourceCacheOptions<Resource, E, R>,
|
|
171
|
+
): ResourceCache<Resource, R> => {
|
|
172
|
+
const { kv, serveable, prefix } = options;
|
|
173
|
+
const ttl = Math.max(1, Math.trunc(options.ttlSeconds) || 1);
|
|
174
|
+
const tombstoneTtl = Math.max(
|
|
175
|
+
ttl,
|
|
176
|
+
Math.trunc(options.tombstoneTtlSeconds ?? DEFAULT_TOMBSTONE_TTL_SECONDS) || ttl,
|
|
177
|
+
);
|
|
178
|
+
const encode = Schema.encodeOption(options.schema);
|
|
179
|
+
const decode = Schema.decodeUnknownOption(options.schema);
|
|
180
|
+
const keyOf = (id: string) => `${prefix}${id}`;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The conditional write at the entry's rank (`2·version`, plus one for a
|
|
184
|
+
* tombstone, so an eviction wins a tie), a KV failure skipped (the cache
|
|
185
|
+
* never fails). A tombstone carries the longer TTL.
|
|
186
|
+
*/
|
|
187
|
+
const write = (id: string, version: bigint, value: Option.Option<Schema.Json>) =>
|
|
188
|
+
Effect.ignore(
|
|
189
|
+
kv.putIfNotBehind(
|
|
190
|
+
keyOf(id),
|
|
191
|
+
{ version: version * 2n + (Option.isNone(value) ? 1n : 0n), value },
|
|
192
|
+
Option.isNone(value) ? tombstoneTtl : ttl,
|
|
193
|
+
),
|
|
194
|
+
);
|
|
195
|
+
const evict = (id: string, version: bigint) => write(id, version, Option.none());
|
|
196
|
+
|
|
197
|
+
const set = (id: string, resource: Resource, version: bigint) => {
|
|
198
|
+
// Refuse (and evict) what the policy doesn't serve, or what doesn't encode.
|
|
199
|
+
const encoded = serveable(resource) ? encode(resource) : Option.none();
|
|
200
|
+
return write(id, version, encoded);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const get = (id: string) =>
|
|
204
|
+
Effect.gen(function* () {
|
|
205
|
+
const stored = yield* Effect.orElseSucceed(kv.get(keyOf(id)), () => Option.none<KvEntry>());
|
|
206
|
+
if (Option.isNone(stored) || Option.isNone(stored.value.value))
|
|
207
|
+
return Option.none<Resource>();
|
|
208
|
+
const resource = decode(stored.value.value.value);
|
|
209
|
+
if (Option.isNone(resource) || !serveable(resource.value)) {
|
|
210
|
+
// Decode or evict, and never serve what the policy now refuses (CACHE-AUTH-005).
|
|
211
|
+
yield* evict(id, stored.value.version / 2n);
|
|
212
|
+
return Option.none<Resource>();
|
|
213
|
+
}
|
|
214
|
+
return resource;
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
return { set, delete: evict, get };
|
|
218
|
+
};
|