@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.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1272 -2
  3. package/dist/blobs.d.ts +249 -0
  4. package/dist/blobs.d.ts.map +1 -0
  5. package/dist/blobs.js +193 -0
  6. package/dist/blobs.js.map +1 -0
  7. package/dist/budgets.d.ts +255 -0
  8. package/dist/budgets.d.ts.map +1 -0
  9. package/dist/budgets.js +211 -0
  10. package/dist/budgets.js.map +1 -0
  11. package/dist/cache.d.ts +154 -0
  12. package/dist/cache.d.ts.map +1 -0
  13. package/dist/cache.js +118 -0
  14. package/dist/cache.js.map +1 -0
  15. package/dist/collections.d.ts +242 -0
  16. package/dist/collections.d.ts.map +1 -0
  17. package/dist/collections.js +215 -0
  18. package/dist/collections.js.map +1 -0
  19. package/dist/comments.d.ts +212 -0
  20. package/dist/comments.d.ts.map +1 -0
  21. package/dist/comments.js +192 -0
  22. package/dist/comments.js.map +1 -0
  23. package/dist/content.d.ts +231 -0
  24. package/dist/content.d.ts.map +1 -0
  25. package/dist/content.js +204 -0
  26. package/dist/content.js.map +1 -0
  27. package/dist/documents.d.ts +184 -0
  28. package/dist/documents.d.ts.map +1 -0
  29. package/dist/documents.js +190 -0
  30. package/dist/documents.js.map +1 -0
  31. package/dist/edges.d.ts +645 -0
  32. package/dist/edges.d.ts.map +1 -0
  33. package/dist/edges.js +558 -0
  34. package/dist/edges.js.map +1 -0
  35. package/dist/filter.d.ts +461 -0
  36. package/dist/filter.d.ts.map +1 -0
  37. package/dist/filter.js +387 -0
  38. package/dist/filter.js.map +1 -0
  39. package/dist/index.d.ts +133 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +133 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/internal/filter-columns.d.ts +8 -0
  44. package/dist/internal/filter-columns.d.ts.map +1 -0
  45. package/dist/internal/filter-columns.js +43 -0
  46. package/dist/internal/filter-columns.js.map +1 -0
  47. package/dist/internal/list-order.d.ts +26 -0
  48. package/dist/internal/list-order.d.ts.map +1 -0
  49. package/dist/internal/list-order.js +92 -0
  50. package/dist/internal/list-order.js.map +1 -0
  51. package/dist/internal/owned-columns.d.ts +93 -0
  52. package/dist/internal/owned-columns.d.ts.map +1 -0
  53. package/dist/internal/owned-columns.js +98 -0
  54. package/dist/internal/owned-columns.js.map +1 -0
  55. package/dist/internal/plain-rows.d.ts +32 -0
  56. package/dist/internal/plain-rows.d.ts.map +1 -0
  57. package/dist/internal/plain-rows.js +32 -0
  58. package/dist/internal/plain-rows.js.map +1 -0
  59. package/dist/internal/reference-columns.d.ts +80 -0
  60. package/dist/internal/reference-columns.d.ts.map +1 -0
  61. package/dist/internal/reference-columns.js +102 -0
  62. package/dist/internal/reference-columns.js.map +1 -0
  63. package/dist/moderation.d.ts +392 -0
  64. package/dist/moderation.d.ts.map +1 -0
  65. package/dist/moderation.js +375 -0
  66. package/dist/moderation.js.map +1 -0
  67. package/dist/paths.d.ts +204 -0
  68. package/dist/paths.d.ts.map +1 -0
  69. package/dist/paths.js +165 -0
  70. package/dist/paths.js.map +1 -0
  71. package/dist/proposals.d.ts +523 -0
  72. package/dist/proposals.d.ts.map +1 -0
  73. package/dist/proposals.js +505 -0
  74. package/dist/proposals.js.map +1 -0
  75. package/dist/publication.d.ts +148 -0
  76. package/dist/publication.d.ts.map +1 -0
  77. package/dist/publication.js +183 -0
  78. package/dist/publication.js.map +1 -0
  79. package/dist/purge.d.ts +224 -0
  80. package/dist/purge.d.ts.map +1 -0
  81. package/dist/purge.js +151 -0
  82. package/dist/purge.js.map +1 -0
  83. package/dist/reactions.d.ts +246 -0
  84. package/dist/reactions.d.ts.map +1 -0
  85. package/dist/reactions.js +183 -0
  86. package/dist/reactions.js.map +1 -0
  87. package/dist/registry.d.ts +316 -0
  88. package/dist/registry.d.ts.map +1 -0
  89. package/dist/registry.js +305 -0
  90. package/dist/registry.js.map +1 -0
  91. package/dist/search.d.ts +366 -0
  92. package/dist/search.d.ts.map +1 -0
  93. package/dist/search.js +273 -0
  94. package/dist/search.js.map +1 -0
  95. package/dist/social.d.ts +311 -0
  96. package/dist/social.d.ts.map +1 -0
  97. package/dist/social.js +205 -0
  98. package/dist/social.js.map +1 -0
  99. package/dist/subtype.d.ts +1613 -0
  100. package/dist/subtype.d.ts.map +1 -0
  101. package/dist/subtype.js +1300 -0
  102. package/dist/subtype.js.map +1 -0
  103. package/dist/testing.d.ts +675 -0
  104. package/dist/testing.d.ts.map +1 -0
  105. package/dist/testing.js +1022 -0
  106. package/dist/testing.js.map +1 -0
  107. package/dist/version-chain.d.ts +702 -0
  108. package/dist/version-chain.d.ts.map +1 -0
  109. package/dist/version-chain.js +489 -0
  110. package/dist/version-chain.js.map +1 -0
  111. package/dist/working-copy.d.ts +116 -0
  112. package/dist/working-copy.d.ts.map +1 -0
  113. package/dist/working-copy.js +95 -0
  114. package/dist/working-copy.js.map +1 -0
  115. package/package.json +145 -3
  116. package/src/blobs.ts +384 -0
  117. package/src/budgets.ts +338 -0
  118. package/src/cache.ts +218 -0
  119. package/src/collections.ts +427 -0
  120. package/src/comments.ts +392 -0
  121. package/src/content.ts +238 -0
  122. package/src/documents.ts +284 -0
  123. package/src/edges.ts +1185 -0
  124. package/src/filter.ts +523 -0
  125. package/src/index.ts +153 -0
  126. package/src/internal/filter-columns.ts +50 -0
  127. package/src/internal/list-order.ts +117 -0
  128. package/src/internal/owned-columns.ts +140 -0
  129. package/src/internal/plain-rows.ts +54 -0
  130. package/src/internal/reference-columns.ts +156 -0
  131. package/src/moderation.ts +728 -0
  132. package/src/paths.ts +267 -0
  133. package/src/proposals.ts +1014 -0
  134. package/src/publication.ts +225 -0
  135. package/src/purge.ts +313 -0
  136. package/src/reactions.ts +347 -0
  137. package/src/registry.ts +524 -0
  138. package/src/search.ts +540 -0
  139. package/src/social.ts +469 -0
  140. package/src/subtype.ts +3095 -0
  141. package/src/testing.ts +1873 -0
  142. package/src/version-chain.ts +1093 -0
  143. 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
+ };