@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/testing.ts ADDED
@@ -0,0 +1,1873 @@
1
+ /**
2
+ * **In-memory fakes of the kernel's seams** — a store for each of the
3
+ * version-chain, leaf, document, edge, proposal, path, report, publication, purge and embedding seams, an
4
+ * endpoint view, a follower seam, a search branch (with a scope check) and a resource cache, all held in memory with no SQL. They are the reference semantics of
5
+ * each seam's contract in the package README → Seams, and the fakes the
6
+ * kernel's own suites run against; an adapter's or an application's tests can
7
+ * run the kernel's operations over them too.
8
+ *
9
+ * **Not for production.** Every store here keeps its rows in a closure for the
10
+ * life of the value, persists nothing, never opens a transaction and has no
11
+ * concurrency control beyond what a single JavaScript thread gives it. Each
12
+ * store enforces what the real table's schema enforces (a primary or unique
13
+ * key) and nothing the operations are responsible for, so a test of the
14
+ * operations over a fake exercises the operations' policy, not the store's.
15
+ * Each also carries a few test hooks (`rows`, `dropState`, `raceNextAppend`,
16
+ * …) that a real store has no counterpart for: they read what was written,
17
+ * or simulate a state a real database reaches only through a race or a
18
+ * missing row.
19
+ *
20
+ * The follower seam's `in` set is a `HashSet` of account ids
21
+ * ({@link InMemoryFollowing}) throughout, where a SQL store's is a subquery.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * import { deepStrictEqual } from "node:assert";
26
+ * import * as Effect from "effect/Effect";
27
+ * import * as Option from "effect/Option";
28
+ * import { defineVersionChain } from "@thomasfosterau/pulpit/version-chain";
29
+ * import { makeInMemoryChainStore } from "@thomasfosterau/pulpit/testing";
30
+ *
31
+ * const store = makeInMemoryChainStore();
32
+ * const chain = defineVersionChain("notes", store);
33
+ * const { id } = Effect.runSync(
34
+ * chain.create({ id: Option.none(), state: { title: "Hello" }, actorId: Option.none() }),
35
+ * );
36
+ *
37
+ * // The chain operations appended version 0, and the fake recorded it.
38
+ * deepStrictEqual(
39
+ * store.rows().map((row) => [row.id, row.version, row.action]),
40
+ * [[id, 0n, "create"]],
41
+ * );
42
+ * ```
43
+ *
44
+ * @copyright Thomas Foster
45
+ * @module pulpit/testing
46
+ * @since 0.1.0
47
+ */
48
+
49
+ import * as Arr from "effect/Array";
50
+ import * as Clock from "effect/Clock";
51
+ import * as DateTime from "effect/DateTime";
52
+ import * as Effect from "effect/Effect";
53
+ import * as HashMap from "effect/HashMap";
54
+ import * as HashSet from "effect/HashSet";
55
+ import * as Option from "effect/Option";
56
+ import * as Order from "effect/Order";
57
+ import * as Predicate from "effect/Predicate";
58
+ import * as Record from "effect/Record";
59
+ import * as Schema from "effect/Schema";
60
+ import { sha256Hex, type BlobHead, type BlobStore } from "./blobs.ts";
61
+ import type { BudgetCounter, BudgetRequest, WriteCountStore } from "./budgets.ts";
62
+ import { canonicalText, type DocumentHash, type DocumentStore } from "./documents.ts";
63
+ import {
64
+ endpointColumn,
65
+ type EdgeEnds,
66
+ type EdgeRow,
67
+ type EdgeStore,
68
+ type EdgeValues,
69
+ type EndpointView,
70
+ } from "./edges.ts";
71
+ import {
72
+ evaluate,
73
+ LOCALE_COLUMN,
74
+ type EvaluationContext,
75
+ type FilterRow,
76
+ type ListFilter,
77
+ } from "./filter.ts";
78
+ import { byCodePoint, rowOrder } from "./internal/list-order.ts";
79
+ import type { ErasedReferences, PurgeStore } from "./purge.ts";
80
+ import {
81
+ SearchScopeLeak,
82
+ type EmbeddingStore,
83
+ type RankedHit,
84
+ type SearchBranch,
85
+ } from "./search.ts";
86
+ import type { ReactionRow, ReactionStore } from "./reactions.ts";
87
+ import type { KvEntry, VersionedKv } from "./cache.ts";
88
+ import type { CollectionEntry, CollectionStore } from "./collections.ts";
89
+ import type { Relationship, SocialStore } from "./social.ts";
90
+ import { PathTaken, type PathEntry, type PathStore } from "./paths.ts";
91
+ import type { ReportRow, ReportStore } from "./moderation.ts";
92
+ import type { ProposalListing, ProposalRow, ProposalStore } from "./proposals.ts";
93
+ import type { WorkingCopy, WorkingCopyKey, WorkingCopyStore } from "./working-copy.ts";
94
+ import type {
95
+ FollowerSeam,
96
+ LeafRow,
97
+ LeafStore,
98
+ DistinctQuery,
99
+ ListOrder,
100
+ Page,
101
+ LiveView,
102
+ PublicationFact,
103
+ PublicationStore,
104
+ ResourceCacheSeam,
105
+ } from "./subtype.ts";
106
+ import {
107
+ localesOf,
108
+ VersionConflict,
109
+ type ColumnValue,
110
+ type LocaleStates,
111
+ type VersionChainEntry,
112
+ type VersionChainStore,
113
+ type VersionRow,
114
+ type VersionSnapshot,
115
+ } from "./version-chain.ts";
116
+
117
+ // ── The clock and the follower set ────────────────────────────────────
118
+
119
+ /**
120
+ * The follower seam's `in` set in every fake here: the ids of the accounts a
121
+ * viewer follows. A SQL store's is a subquery; the in-memory list filters
122
+ * test membership in this set instead.
123
+ *
124
+ * @since 0.1.0
125
+ */
126
+ export type InMemoryFollowing = HashSet.HashSet<string>;
127
+
128
+ /** The instant a fake's `published` / `notPublished` compare against: the fixed `now`, else the Effect `Clock`'s. */
129
+ const instantOf = (now: Option.Option<Date>): Effect.Effect<Date> =>
130
+ Option.match(now, {
131
+ onNone: () =>
132
+ Effect.map(Clock.currentTimeMillis, (millis) => DateTime.toDate(DateTime.makeUnsafe(millis))),
133
+ onSome: Effect.succeed,
134
+ });
135
+
136
+ /** The list filter's evaluation context at `now`, over an {@link InMemoryFollowing} set. */
137
+ const contextAt = (now: Date): EvaluationContext<InMemoryFollowing> => ({
138
+ now,
139
+ contains: (set, value: ColumnValue) => Predicate.isString(value) && HashSet.has(set, value),
140
+ });
141
+
142
+ /**
143
+ * A node's rows in a localised view (pulpit#56): one per locale its version
144
+ * carries, the locale's columns over the shared row and the tag in
145
+ * {@link LOCALE_COLUMN}; a version carrying none is its shared row alone,
146
+ * the `NULL`-locale row a left join gives.
147
+ */
148
+ const localeRows = (row: LeafRow, locales: LocaleStates): ReadonlyArray<LeafRow> =>
149
+ Record.isEmptyRecord(locales)
150
+ ? [row]
151
+ : Arr.map(Record.toEntries(locales), ([tag, columns]): LeafRow => ({
152
+ ...row,
153
+ ...columns,
154
+ [LOCALE_COLUMN]: tag,
155
+ }));
156
+
157
+ /** The locale level of `id` at `version` in an in-memory chain, `{}` for none. */
158
+ const localesAt = (chain: InMemoryChainStore, id: string, version: bigint): LocaleStates =>
159
+ Option.match(
160
+ Arr.findFirst(chain.rows(), (row) => row.id === id && row.version === version),
161
+ { onNone: () => ({}), onSome: localesOf },
162
+ );
163
+
164
+ /**
165
+ * The distinct string values of `column` among the rows matching `where` at
166
+ * `now`, in code-point order, paged: `LeafStore.distinct`'s contract.
167
+ */
168
+ const distinctPage = (
169
+ rows: ReadonlyArray<LeafRow>,
170
+ query: DistinctQuery<InMemoryFollowing>,
171
+ now: Date,
172
+ ): Page<string> => {
173
+ const values = Arr.sort(
174
+ Arr.dedupe(
175
+ Arr.flatMap(rows, (row) => {
176
+ const value = row[query.column];
177
+ return evaluate(query.where, row, contextAt(now)) && Predicate.isString(value)
178
+ ? [value]
179
+ : [];
180
+ }),
181
+ ),
182
+ byCodePoint,
183
+ );
184
+ return {
185
+ rows: values.slice(query.offset, query.offset + query.limit),
186
+ total: values.length,
187
+ };
188
+ };
189
+
190
+ /** A view page: the rows matching `where` at `now`, in the list order contract's order. */
191
+ const viewPage = (
192
+ rows: ReadonlyArray<LeafRow>,
193
+ query: {
194
+ readonly where: ListFilter<InMemoryFollowing>;
195
+ readonly orderBy: ReadonlyArray<ListOrder>;
196
+ readonly limit: number;
197
+ readonly offset: number;
198
+ },
199
+ now: Date,
200
+ ) => {
201
+ const matching = Arr.sort(
202
+ rows.filter((row) => evaluate(query.where, row, contextAt(now))),
203
+ rowOrder(query.orderBy),
204
+ );
205
+ return {
206
+ rows: matching.slice(query.offset, query.offset + query.limit),
207
+ total: matching.length,
208
+ };
209
+ };
210
+
211
+ /**
212
+ * An in-memory {@link FollowerSeam} over an explicit table of who follows
213
+ * whom: each entry is a follower's id and the ids of the accounts it
214
+ * follows. `isFollower` answers from the table and `following` returns the
215
+ * follower's row as an {@link InMemoryFollowing} set (empty for an account the
216
+ * table doesn't name), the in-memory reading of effect-auth's `IsFollower`
217
+ * (README → Seams → the follower seam). In memory only; not for production.
218
+ *
219
+ * @since 0.1.0
220
+ */
221
+ export const makeInMemoryFollowers = (
222
+ table: Iterable<readonly [follower: string, followed: ReadonlyArray<string>]>,
223
+ ): FollowerSeam<InMemoryFollowing, never> => {
224
+ const follows = HashMap.fromIterable(table);
225
+ const followedBy = (followerId: string): ReadonlyArray<string> =>
226
+ Option.getOrElse(HashMap.get(follows, followerId), Arr.empty);
227
+ return {
228
+ isFollower: (followerId, ownerId) => Effect.succeed(followedBy(followerId).includes(ownerId)),
229
+ following: (followerId) => HashSet.fromIterable(followedBy(followerId)),
230
+ };
231
+ };
232
+
233
+ // ── The version chain ─────────────────────────────────────────────────
234
+
235
+ /**
236
+ * An in-memory {@link VersionChainStore}, plus the hooks a test reads and
237
+ * steers it through. In memory only; not for production.
238
+ *
239
+ * @since 0.1.0
240
+ */
241
+ export interface InMemoryChainStore extends VersionChainStore<never, never> {
242
+ /** Every row ever appended, in append order — what the append-only assertions read. */
243
+ readonly rows: () => ReadonlyArray<VersionRow>;
244
+ /** Simulate a partial chain: the state at `(id, version)` is no longer reconstructable. */
245
+ readonly dropState: (id: string, version: bigint) => void;
246
+ /** Simulate a concurrent writer: the next append for `id` finds its version taken. */
247
+ readonly raceNextAppend: (id: string) => void;
248
+ /**
249
+ * Hard-delete every row of `id` and return them: what
250
+ * {@link makeInMemoryPurgeStore} erases a node through, and nothing else
251
+ * may call (`VERSION-CHAIN-001/002`).
252
+ */
253
+ readonly erase: (id: string) => ReadonlyArray<VersionRow>;
254
+ }
255
+
256
+ /**
257
+ * An in-memory {@link VersionChainStore}: the seam's contract (README → Seams
258
+ * → `VersionChainStore`) over an append log. It enforces what the Drizzle
259
+ * store's schema enforces, the `(id, version)` primary key (an `append` at a
260
+ * taken pair fails `VersionConflict`), and nothing the operations are
261
+ * responsible for. `mintId` mints sequential UUID-shaped ids.
262
+ *
263
+ * `dropState` simulates a partial chain (a level with no row at a version),
264
+ * which a real database reaches through a missing leaf row, and
265
+ * `raceNextAppend` a writer that takes the next version first. In memory
266
+ * only; not for production.
267
+ *
268
+ * @since 0.1.0
269
+ */
270
+ export const makeInMemoryChainStore = (): InMemoryChainStore => {
271
+ let log: ReadonlyArray<VersionRow> = [];
272
+ let dropped = HashSet.empty<string>();
273
+ let raced = HashSet.empty<string>();
274
+ let minted = 0;
275
+ const key = (id: string, version: bigint) => `${id}@${version}`;
276
+ const chain = (id: string) => log.filter((row) => row.id === id);
277
+
278
+ const entryOf = (row: VersionRow): VersionChainEntry => ({
279
+ version: row.version,
280
+ action: row.action,
281
+ timestamp: row.timestamp,
282
+ });
283
+
284
+ /** Whether the next append for `id` is raced, consuming the race. */
285
+ const takeRace = (id: string): boolean => {
286
+ const wasRaced = HashSet.has(raced, id);
287
+ raced = HashSet.remove(raced, id);
288
+ return wasRaced;
289
+ };
290
+
291
+ return {
292
+ head: (id) => Effect.sync(() => Option.map(Arr.last(chain(id)), entryOf)),
293
+ snapshot: (id, version) =>
294
+ Effect.sync(() =>
295
+ HashSet.has(dropped, key(id, version))
296
+ ? Option.none()
297
+ : Option.map(
298
+ Arr.findFirst(chain(id), (candidate) => candidate.version === version),
299
+ (row): VersionSnapshot =>
300
+ row.locales === undefined
301
+ ? { ...entryOf(row), state: row.state }
302
+ : { ...entryOf(row), state: row.state, locales: row.locales },
303
+ ),
304
+ ),
305
+ append: (row) =>
306
+ Effect.suspend(() => {
307
+ if (takeRace(row.id) || chain(row.id).some((taken) => taken.version === row.version)) {
308
+ return Effect.fail(new VersionConflict({ id: row.id, version: row.version }));
309
+ }
310
+ log = [...log, row];
311
+ return Effect.void;
312
+ }),
313
+ entries: (id, limit) => Effect.sync(() => chain(id).slice(0, limit).map(entryOf)),
314
+ mintId: Effect.sync(() => {
315
+ minted += 1;
316
+ return `00000000-0000-4000-8000-${String(minted).padStart(12, "0")}`;
317
+ }),
318
+ rows: () => log,
319
+ dropState: (id, version) => {
320
+ dropped = HashSet.add(dropped, key(id, version));
321
+ },
322
+ raceNextAppend: (id) => {
323
+ raced = HashSet.add(raced, id);
324
+ },
325
+ erase: (id) => {
326
+ const erased = chain(id);
327
+ log = log.filter((row) => row.id !== id);
328
+ return erased;
329
+ },
330
+ };
331
+ };
332
+
333
+ // ── The leaf view ─────────────────────────────────────────────────────
334
+
335
+ /**
336
+ * An in-memory {@link LeafStore} over an {@link InMemoryChainStore}, plus the
337
+ * hooks a test reads and steers its owner stamps through. In memory only; not
338
+ * for production.
339
+ *
340
+ * @since 0.1.0
341
+ */
342
+ export interface InMemoryLeafStore extends LeafStore<LeafRow, InMemoryFollowing, never, never> {
343
+ /** Every owner stamp, node id → owner id — what the owner-stamp assertions read. */
344
+ readonly owners: () => HashMap.HashMap<string, string>;
345
+ /** Simulate an ownerless owned node: drop `id`'s owner stamp (the apps' `core.node_owner` row). */
346
+ readonly dropOwner: (id: string) => void;
347
+ }
348
+
349
+ /**
350
+ * Options for {@link makeInMemoryLeafStore}.
351
+ *
352
+ * @since 0.1.0
353
+ */
354
+ export interface InMemoryLeafStoreOptions {
355
+ /** The view's leaf columns; `id`, `action` and `ownerId` are always in its `columns`. */
356
+ readonly columns: ReadonlyArray<string>;
357
+ /**
358
+ * A localised leaf's view (pulpit#56): its `columns` gain
359
+ * {@link LOCALE_COLUMN}, and `list` reads one row per `(id, locale)`.
360
+ * `get` / `getMany` read the shared row either way.
361
+ */
362
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is an unlocalised view
363
+ readonly localised?: boolean | undefined;
364
+ /** The instant `published` / `notPublished` compare against; the Effect `Clock`'s at each `list` when absent. */
365
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions), so a caller can forward its own `undefined` under `exactOptionalPropertyTypes`; the omitted key is the absence, read as such by every reader, so there is no value to model as an `Option`
366
+ readonly now?: Date | undefined;
367
+ }
368
+
369
+ /**
370
+ * An in-memory {@link LeafStore}: the seam's contract (README → Seams →
371
+ * `LeafStore`) as a current-version view over an {@link InMemoryChainStore}.
372
+ * Each node's row is its head version's state with its head `version` and `action`, and
373
+ * `ownerId` merged in from the owner stamps (the apps' `core.node_owner`
374
+ * join); `list` evaluates its {@link ListFilter} in memory, orders by its
375
+ * `orderBy` keys then `id` ascending as the seam's contract renders it
376
+ * (`NULLS LAST` in both directions, a string by code point as `COLLATE "C"`
377
+ * sorts it, a `Date` by its instant), and returns the unpaged total. Like a
378
+ * SQL store, it takes `orderBy` as already checked against `columns`. It
379
+ * applies no visibility policy, as a real view applies none. In memory only;
380
+ * not for production.
381
+ *
382
+ * @since 0.1.0
383
+ */
384
+ export const makeInMemoryLeafStore = (
385
+ chain: InMemoryChainStore,
386
+ options: InMemoryLeafStoreOptions,
387
+ ): InMemoryLeafStore => {
388
+ const fixedNow = Option.fromNullishOr(options.now);
389
+ let owners = HashMap.empty<string, string>();
390
+ const headOf = (row: VersionRow): LeafRow => {
391
+ const base: LeafRow = { ...row.state, id: row.id, version: row.version, action: row.action };
392
+ return Option.match(HashMap.get(owners, row.id), {
393
+ onNone: () => base,
394
+ onSome: (ownerId): LeafRow => ({ ...base, ownerId }),
395
+ });
396
+ };
397
+ /** The rows `list` reads: one per `(id, locale)` on a localised view. */
398
+ const listed = (): ReadonlyArray<LeafRow> =>
399
+ options.localised === true
400
+ ? Arr.flatMap(view(), (row) => localeRows(row, localesAt(chain, row.id, row.version)))
401
+ : view();
402
+ const view = (): ReadonlyArray<LeafRow> =>
403
+ Arr.sort(
404
+ HashMap.toValues(
405
+ Arr.reduce(chain.rows(), HashMap.empty<string, LeafRow>(), (heads, row) =>
406
+ HashMap.set(heads, row.id, headOf(row)),
407
+ ),
408
+ ),
409
+ rowOrder([]),
410
+ );
411
+ return {
412
+ // oxlint-disable-next-line effect/avoid-native-object-helpers -- the seam's column set is the native `ReadonlySet` `LeafStore.columns` names
413
+ columns: new Set([
414
+ "id",
415
+ "version",
416
+ "action",
417
+ "ownerId",
418
+ ...options.columns,
419
+ ...(options.localised === true ? [LOCALE_COLUMN] : []),
420
+ ]),
421
+ get: (id) => Effect.sync(() => Arr.findFirst(view(), (row) => row.id === id)),
422
+ getMany: (ids) => Effect.sync(() => view().filter((row) => ids.includes(row.id))),
423
+ list: (query) => Effect.map(instantOf(fixedNow), (now) => viewPage(listed(), query, now)),
424
+ distinct: (query) =>
425
+ Effect.map(instantOf(fixedNow), (now) => distinctPage(listed(), query, now)),
426
+ ownerOf: (id) => Effect.sync(() => HashMap.get(owners, id)),
427
+ stampOwner: (id, ownerId) =>
428
+ Effect.sync(() => {
429
+ owners = HashMap.set(owners, id, ownerId);
430
+ }),
431
+ owners: () => owners,
432
+ dropOwner: (id) => {
433
+ owners = HashMap.remove(owners, id);
434
+ },
435
+ };
436
+ };
437
+
438
+ // ── Paths ─────────────────────────────────────────────────────────────
439
+
440
+ /**
441
+ * An in-memory {@link PathStore}, plus the read a test checks it through. In
442
+ * memory only; not for production.
443
+ *
444
+ * @since 0.1.0
445
+ */
446
+ export interface InMemoryPathStore extends PathStore<never, never> {
447
+ /** Every stored path and its entry, aliases included. */
448
+ readonly entries: () => HashMap.HashMap<string, PathEntry>;
449
+ }
450
+
451
+ /**
452
+ * An in-memory {@link PathStore}: the seam's contract (README → Seams →
453
+ * `PathStore`) as one map from path to entry, its key the path table's
454
+ * primary key, so each path is stored once. `claim` fails {@link PathTaken}
455
+ * for another node's canonical path, replaces an alias at the path, and
456
+ * demotes the node's previous canonical path to its alias, in one step;
457
+ * `release` drops the node's canonical path and keeps its aliases. In memory
458
+ * only; not for production.
459
+ *
460
+ * @since 0.1.0
461
+ */
462
+ export const makeInMemoryPathStore = (): InMemoryPathStore => {
463
+ let entries = HashMap.empty<string, PathEntry>();
464
+ const canonicalOf = (nodeId: string) =>
465
+ Option.map(
466
+ Arr.findFirst(
467
+ HashMap.toEntries(entries),
468
+ ([, entry]) => entry.canonical && entry.nodeId === nodeId,
469
+ ),
470
+ ([path]) => path,
471
+ );
472
+ return {
473
+ lookup: (path) => Effect.sync(() => HashMap.get(entries, path)),
474
+ canonicalOf: (nodeId) => Effect.sync(() => canonicalOf(nodeId)),
475
+ claim: (nodeId, path) =>
476
+ Effect.suspend(() => {
477
+ const held = HashMap.get(entries, path);
478
+ if (Option.exists(held, (entry) => entry.canonical && entry.nodeId !== nodeId)) {
479
+ return Effect.fail(new PathTaken({ path }));
480
+ }
481
+ entries = Option.match(canonicalOf(nodeId), {
482
+ onNone: () => entries,
483
+ onSome: (previous) => HashMap.set(entries, previous, { nodeId, canonical: false }),
484
+ });
485
+ entries = HashMap.set(entries, path, { nodeId, canonical: true });
486
+ return Effect.void;
487
+ }),
488
+ release: (nodeId) =>
489
+ Effect.sync(() => {
490
+ entries = Option.match(canonicalOf(nodeId), {
491
+ onNone: () => entries,
492
+ onSome: (path) => HashMap.remove(entries, path),
493
+ });
494
+ }),
495
+ entries: () => entries,
496
+ };
497
+ };
498
+
499
+ // ── The resource cache ────────────────────────────────────────────────
500
+
501
+ /**
502
+ * An in-memory {@link ResourceCacheSeam}, plus the reads a test checks it
503
+ * through. In memory only; not for production.
504
+ *
505
+ * @since 0.1.0
506
+ */
507
+ export interface InMemoryResourceCache<Resource> extends ResourceCacheSeam<Resource, never> {
508
+ /** The resource the cache would serve for `id`: none when evicted, refused or never set. */
509
+ readonly get: (id: string) => Option.Option<Resource>;
510
+ /** The highest version the cache holds for `id`, set or evicted, or none. */
511
+ readonly versionOf: (id: string) => Option.Option<bigint>;
512
+ }
513
+
514
+ /**
515
+ * An in-memory {@link ResourceCacheSeam}: the seam's compare-on-set contract
516
+ * (README → Seams → the resource cache, pulpit#121). It keeps, per id, the
517
+ * highest version either call has given it: a `set` below it is a no-op, a
518
+ * `set` at or above it stores the resource when `serveable` admits it and
519
+ * evicts it otherwise, and a `delete` evicts and raises the version. Each
520
+ * call compares and writes in one step, as the real cache's conditional write
521
+ * must. In memory only; not for production.
522
+ *
523
+ * @since 0.1.0
524
+ */
525
+ export const makeInMemoryResourceCache = <Resource>(
526
+ serveable: (resource: Resource) => boolean,
527
+ ): InMemoryResourceCache<Resource> => {
528
+ let entries = HashMap.empty<
529
+ string,
530
+ { readonly version: bigint; readonly resource: Option.Option<Resource> }
531
+ >();
532
+ const versionOf = (id: string) => Option.map(HashMap.get(entries, id), (entry) => entry.version);
533
+ const behind = (id: string, version: bigint) =>
534
+ Option.exists(versionOf(id), (held) => version < held);
535
+ return {
536
+ set: (id, resource, version) =>
537
+ Effect.sync(() => {
538
+ if (behind(id, version)) return;
539
+ entries = HashMap.set(entries, id, {
540
+ version,
541
+ resource: serveable(resource) ? Option.some(resource) : Option.none(),
542
+ });
543
+ }),
544
+ delete: (id, version) =>
545
+ Effect.sync(() => {
546
+ if (behind(id, version)) return;
547
+ entries = HashMap.set(entries, id, { version, resource: Option.none() });
548
+ }),
549
+ get: (id) => Option.flatMap(HashMap.get(entries, id), (entry) => entry.resource),
550
+ versionOf,
551
+ };
552
+ };
553
+
554
+ // ── Documents ─────────────────────────────────────────────────────────
555
+
556
+ const utf8 = new TextEncoder();
557
+
558
+ /** SHA-256 over the canonical text's UTF-8 bytes, as 64 lowercase hex characters. */
559
+ const hashOf = (document: Schema.Json): Effect.Effect<DocumentHash> =>
560
+ sha256Hex(utf8.encode(canonicalText(document)));
561
+
562
+ /**
563
+ * An in-memory {@link DocumentStore}, plus the hooks a test reads it through.
564
+ * In memory only; not for production.
565
+ *
566
+ * @since 0.1.0
567
+ */
568
+ export interface InMemoryDocumentStore<Document extends Schema.Json> extends DocumentStore<
569
+ Document,
570
+ never,
571
+ never
572
+ > {
573
+ /** Every stored document by hash — what the never-rewritten assertions read. */
574
+ readonly stored: () => HashMap.HashMap<DocumentHash, Document>;
575
+ /**
576
+ * How many times `put` wrote a row: a real insert counts, a no-op `put` of a
577
+ * stored hash does not. The never-rewritten assertions read it, because
578
+ * `HashMap.set` with a structurally equal value is indistinguishable from no
579
+ * write in {@link InMemoryDocumentStore.stored}.
580
+ */
581
+ readonly writes: () => number;
582
+ /**
583
+ * Delete the documents at `hashes`: what {@link makeInMemoryPurgeStore}
584
+ * deletes an unreferenced document through, and nothing else may call
585
+ * (`DOC-001`).
586
+ */
587
+ readonly remove: (hashes: ReadonlyArray<DocumentHash>) => void;
588
+ }
589
+
590
+ /**
591
+ * An in-memory {@link DocumentStore}: the seam's contract (README → Seams →
592
+ * `DocumentStore`), hash contract included. It hashes exactly as a Postgres
593
+ * store does, SHA-256 over the UTF-8 bytes of the document's
594
+ * {@link canonicalText}, so its hashes are the ones the database generates
595
+ * (the kernel's suite pins it to hashes taken from Postgres). Like a store's
596
+ * primary key on the hash with `ON CONFLICT DO NOTHING`, it keeps the first
597
+ * document stored at a hash and never replaces it (`DOC-001`). It hashes with
598
+ * Web Crypto (`crypto.subtle`), which Node.js 22 and every browser provide.
599
+ * In memory only; not for production.
600
+ *
601
+ * @since 0.1.0
602
+ */
603
+ export const makeInMemoryDocumentStore = <
604
+ Document extends Schema.Json,
605
+ >(): InMemoryDocumentStore<Document> => {
606
+ let documents = HashMap.empty<DocumentHash, Document>();
607
+ let writes = 0;
608
+ return {
609
+ put: (document) =>
610
+ Effect.map(hashOf(document), (hash) => {
611
+ // `ON CONFLICT DO NOTHING`: the first document at a hash is kept.
612
+ const inserted = !HashMap.has(documents, hash);
613
+ if (inserted) {
614
+ documents = HashMap.set(documents, hash, document);
615
+ writes += 1;
616
+ }
617
+ return { hash, inserted };
618
+ }),
619
+ get: (hash) => Effect.sync(() => HashMap.get(documents, hash)),
620
+ getMany: (hashes) =>
621
+ Effect.sync(() => HashMap.filter(documents, (_document, hash) => hashes.includes(hash))),
622
+ stored: () => documents,
623
+ writes: () => writes,
624
+ remove: (hashes) => {
625
+ documents = HashMap.filter(documents, (_document, hash) => !hashes.includes(hash));
626
+ },
627
+ };
628
+ };
629
+
630
+ // ── Edges ─────────────────────────────────────────────────────────────
631
+
632
+ /**
633
+ * An in-memory {@link EndpointView} whose nodes a test writes directly. In
634
+ * memory only; not for production.
635
+ *
636
+ * @since 0.1.0
637
+ */
638
+ export interface InMemoryNodes extends EndpointView<never, never> {
639
+ /** Store (or replace) a node's current-version row. */
640
+ readonly put: (row: LeafRow) => void;
641
+ }
642
+
643
+ /**
644
+ * An in-memory {@link EndpointView}: an edge end's current-version view
645
+ * (README → Seams → `EdgeStore`) as rows a test puts by hand, for an edge
646
+ * suite that doesn't need the endpoint leaf's version chain. Its `columns`
647
+ * are `id`, `version`, `action` and `columns`. An {@link InMemoryLeafStore} is an
648
+ * endpoint view too. In memory only; not for production.
649
+ *
650
+ * @since 0.1.0
651
+ */
652
+ export const makeInMemoryNodes = (columns: ReadonlyArray<string>): InMemoryNodes => {
653
+ let nodes = HashMap.empty<string, LeafRow>();
654
+ return {
655
+ // oxlint-disable-next-line effect/avoid-native-object-helpers -- the seam's column set is a `ReadonlySet`, the type `LeafStore.columns` already has, so a `LeafStore` satisfies `EndpointView`
656
+ columns: new Set(["id", "version", "action", ...columns]),
657
+ get: (id) => Effect.sync(() => HashMap.get(nodes, id)),
658
+ put: (row) => {
659
+ nodes = HashMap.set(nodes, row.id, row);
660
+ },
661
+ };
662
+ };
663
+
664
+ /**
665
+ * An in-memory {@link EdgeStore}, plus the hooks a test reads and steers it
666
+ * through. In memory only; not for production.
667
+ *
668
+ * @since 0.1.0
669
+ */
670
+ export interface InMemoryEdgeStore extends EdgeStore<EdgeRow, InMemoryFollowing, never, never> {
671
+ /** Every stored edge, hidden ones included, in insertion order. */
672
+ readonly rows: () => ReadonlyArray<EdgeRow>;
673
+ /**
674
+ * Simulate a concurrent writer: the next insert finds its unique key just
675
+ * taken by `winner`, which is stored in its place (the racing writer's
676
+ * values, which share the key and may differ elsewhere).
677
+ */
678
+ readonly raceNextInsert: (winner: EdgeValues) => void;
679
+ }
680
+
681
+ /**
682
+ * Options for {@link makeInMemoryEdgeStore}.
683
+ *
684
+ * @since 0.1.0
685
+ */
686
+ export interface InMemoryEdgeStoreOptions {
687
+ /** The ends the list joins, by alias (the family declaration's `ends`). */
688
+ readonly ends: EdgeEnds<never, never>;
689
+ /** The unique key's columns; empty for a family with none. */
690
+ readonly uniqueKey: ReadonlyArray<string>;
691
+ /**
692
+ * The instant `published` / `notPublished` compare against, and the base of
693
+ * the minted `createdAt`s; the Effect `Clock`'s at each call when absent.
694
+ */
695
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions), so a caller can forward its own `undefined` under `exactOptionalPropertyTypes`; the omitted key is the absence, read as such by every reader, so there is no value to model as an `Option`
696
+ readonly now?: Date | undefined;
697
+ }
698
+
699
+ /** Two column values the unique key treats as equal: `NULLS NOT DISTINCT`, an absent key read as `NULL`. */
700
+ const sameKey = (row: EdgeRow, values: EdgeValues, column: string): boolean =>
701
+ (row[column] ?? null) === (values[column] ?? null);
702
+
703
+ /** The edge list's total order: `createdAt`, then `id`. */
704
+ const byCreation = Order.combine(
705
+ Order.mapInput(Order.Date, (row: EdgeRow) => row.createdAt),
706
+ Order.mapInput(Order.String, (row: EdgeRow) => row.id),
707
+ );
708
+
709
+ /**
710
+ * An in-memory {@link EdgeStore}: the seam's contract (README → Seams →
711
+ * `EdgeStore`) over one edge family's rows. It enforces what an edge table's
712
+ * schema enforces, the family's unique key (with `NULLS NOT DISTINCT`, as
713
+ * gardeneria's `page_slugs_unique_variant`), and nothing the operations are
714
+ * responsible for. `list`, `countAt`, `members` and `countBy` join each end's view the way the SQL
715
+ * store's INNER JOIN does: under the end's alias, dropping an edge whose end
716
+ * has no row. `list` orders by `createdAt` then `id` and reports the unpaged
717
+ * total as the SQL store's window column does (`0` for an empty page).
718
+ *
719
+ * Ids are minted `edge-0001`, `edge-0002`, …; the n-th insert is stamped
720
+ * `now + n` milliseconds, except that every third shares its predecessor's
721
+ * instant, so the `id` tie-break of the list order is exercised. In memory
722
+ * only; not for production.
723
+ *
724
+ * @since 0.1.0
725
+ */
726
+ export const makeInMemoryEdgeStore = (options: InMemoryEdgeStoreOptions): InMemoryEdgeStore => {
727
+ const fixedNow = Option.fromNullishOr(options.now);
728
+ let stored: ReadonlyArray<EdgeRow> = [];
729
+ let minted = 0;
730
+ let race = Option.none<EdgeValues>();
731
+
732
+ const collides = (values: EdgeValues) =>
733
+ Arr.findFirst(
734
+ stored,
735
+ (row) =>
736
+ Arr.isReadonlyArrayNonEmpty(options.uniqueKey) &&
737
+ options.uniqueKey.every((column) => sameKey(row, values, column)),
738
+ );
739
+
740
+ const write = (values: EdgeValues, now: Date): string => {
741
+ minted += 1;
742
+ const id = `edge-${String(minted).padStart(4, "0")}`;
743
+ const createdAt = DateTime.toDate(
744
+ DateTime.makeUnsafe(now.getTime() + minted - (minted % 3 === 0 ? 1 : 0)),
745
+ );
746
+ stored = [...stored, { ...values, id, createdAt }];
747
+ return id;
748
+ };
749
+
750
+ /** The edge joined with each end's node under its alias, or none when an end has no row (INNER JOIN). */
751
+ const joined = (row: EdgeRow): Effect.Effect<Option.Option<FilterRow>> =>
752
+ Effect.map(
753
+ Effect.forEach(
754
+ Record.toEntries(options.ends),
755
+ ([alias, end]) => {
756
+ const nodeId = row[end.column];
757
+ return Predicate.isString(nodeId)
758
+ ? Effect.map(
759
+ end.nodes.get(nodeId),
760
+ Option.map((node) =>
761
+ Record.mapKeys(node, (column): string => endpointColumn(alias, column)),
762
+ ),
763
+ )
764
+ : Effect.succeedNone;
765
+ },
766
+ { concurrency: 1 },
767
+ ),
768
+ (nodes) => Option.map(Option.all(nodes), (all): FilterRow => Object.assign({}, row, ...all)),
769
+ );
770
+
771
+ /** The stored edges whose join satisfies `where`, in the list's total order. */
772
+ const matching = (where: ListFilter<InMemoryFollowing>) =>
773
+ Effect.flatMap(instantOf(fixedNow), (now) =>
774
+ Effect.filter(Arr.sort(stored, byCreation), (row) =>
775
+ Effect.map(
776
+ joined(row),
777
+ Option.exists((present) => evaluate(where, present, contextAt(now))),
778
+ ),
779
+ ),
780
+ );
781
+
782
+ return {
783
+ get: (id) => Effect.sync(() => Arr.findFirst(stored, (row) => row.id === id)),
784
+ // The total as the SQL store's window column reads it: every matching
785
+ // row when the page has one, and 0 for an empty page (past the end too).
786
+ list: ({ where, limit, offset }) =>
787
+ Effect.map(matching(where), (rows) => {
788
+ const page = rows.slice(offset, offset + limit);
789
+ return { rows: page, total: Arr.isReadonlyArrayNonEmpty(page) ? rows.length : 0 };
790
+ }),
791
+ // The count over the list's joins, as the SQL store's `count(*)` reads
792
+ // them: the edges at the node that satisfy `where`.
793
+ countAt: ({ column, nodeId, where }) =>
794
+ Effect.map(matching(where), (rows) => rows.filter((row) => row[column] === nodeId).length),
795
+ existing: (values) => Effect.sync(() => Option.map(collides(values), (row) => row.id)),
796
+ insert: (values) =>
797
+ Effect.map(instantOf(fixedNow), (now) => {
798
+ if (Option.isSome(race)) {
799
+ write(race.value, now);
800
+ race = Option.none();
801
+ return Option.none();
802
+ }
803
+ return Option.isSome(collides(values)) ? Option.none() : Option.some(write(values, now));
804
+ }),
805
+ delete: (id) =>
806
+ Effect.sync(() => {
807
+ const before = stored.length;
808
+ stored = stored.filter((row) => row.id !== id);
809
+ return stored.length < before;
810
+ }),
811
+ closeValidity: (id, column, to) =>
812
+ Effect.sync(() => {
813
+ const found = stored.some((row) => row.id === id);
814
+ stored = stored.map((row) => (row.id === id ? { ...row, [column]: to } : row));
815
+ return found;
816
+ }),
817
+ // The SQL store's subquery, computed eagerly over the list's joins: the
818
+ // member end's ids of the edges whose other end is named and that
819
+ // satisfy `where`. A non-string member id is never in the set.
820
+ members: ({ column, otherColumn, ids, where }) =>
821
+ Effect.map(matching(where), (rows) =>
822
+ HashSet.fromIterable(
823
+ Arr.flatMap(rows, (row) => {
824
+ const member = row[column];
825
+ const other = row[otherColumn];
826
+ return Predicate.isString(member) && Predicate.isString(other) && ids.includes(other)
827
+ ? [member]
828
+ : [];
829
+ }),
830
+ ),
831
+ ),
832
+ // The SQL store's grouped count over the list's joins: per requested
833
+ // node, the edges at it that satisfy `where`; a node with none, no key.
834
+ countBy: ({ column, ids, where }) =>
835
+ Effect.map(matching(where), (rows) =>
836
+ Arr.reduce(rows, HashMap.empty<string, number>(), (counts, row) => {
837
+ const node = row[column];
838
+ return Predicate.isString(node) && ids.includes(node)
839
+ ? HashMap.set(counts, node, Option.getOrElse(HashMap.get(counts, node), () => 0) + 1)
840
+ : counts;
841
+ }),
842
+ ),
843
+ rows: () => stored,
844
+ raceNextInsert: (winner) => {
845
+ race = Option.some(winner);
846
+ },
847
+ };
848
+ };
849
+
850
+ /** One plain row (a proposal, a report): an id, a status, a filing instant. */
851
+ interface PlainRow {
852
+ readonly id: string;
853
+ readonly status: string;
854
+ }
855
+
856
+ /**
857
+ * The table both plain-row fakes keep: insert order, sequential UUID-shaped
858
+ * ids under `prefix`, `close` on an `open`-status row only, and a page
859
+ * newest first by `at`, `id` descending on a tie, evaluated in memory.
860
+ */
861
+ const plainRowTable = <Row extends PlainRow>(options: {
862
+ readonly prefix: string;
863
+ readonly open: Row["status"];
864
+ readonly at: (row: Row) => Date;
865
+ readonly filterRow: (row: Row) => FilterRow;
866
+ }) => {
867
+ let rows: ReadonlyArray<Row> = [];
868
+ let minted = 0;
869
+ const newestFirst = Order.combine(
870
+ Order.flip(Order.mapInput(Order.Date, options.at)),
871
+ Order.flip(Order.mapInput(Order.String, (row: Row) => row.id)),
872
+ );
873
+ // No plain-row column is a publication stamp, so `now` is never read.
874
+ const context: EvaluationContext<never> = {
875
+ now: DateTime.toDate(DateTime.makeUnsafe(0)),
876
+ contains: () => false,
877
+ };
878
+ const find = (id: string) => Arr.findFirst(rows, (row) => row.id === id);
879
+ return {
880
+ rows: () => rows,
881
+ find,
882
+ mintId: Effect.sync(() => {
883
+ minted += 1;
884
+ return `00000000-0000-4000-${options.prefix}-${String(minted).padStart(12, "0")}`;
885
+ }),
886
+ insert: (row: Row) =>
887
+ Effect.sync(() => {
888
+ rows = [...rows, row];
889
+ }),
890
+ lock: (id: string) => Effect.sync(() => find(id)),
891
+ close: (id: string, closed: (row: Row) => Row) =>
892
+ Effect.sync(() => {
893
+ rows = Arr.map(rows, (row) =>
894
+ row.id === id && row.status === options.open ? closed(row) : row,
895
+ );
896
+ }),
897
+ matching: (where: ListFilter<never>) =>
898
+ Arr.sort(
899
+ Arr.filter(rows, (row) => evaluate(where, options.filterRow(row), context)),
900
+ newestFirst,
901
+ ),
902
+ };
903
+ };
904
+
905
+ // ── Proposals ─────────────────────────────────────────────────────────
906
+
907
+ /**
908
+ * An in-memory {@link ProposalStore}, plus the hook a test reads it through.
909
+ * In memory only; not for production.
910
+ *
911
+ * @since 0.1.0
912
+ */
913
+ export interface InMemoryProposalStore extends ProposalStore<never, never> {
914
+ /** Every row, in insert order, as it now stands. */
915
+ readonly rows: () => ReadonlyArray<ProposalRow>;
916
+ }
917
+
918
+ /**
919
+ * An in-memory {@link ProposalStore}: the seam's contract (README → Seams →
920
+ * `ProposalStore`). A listing's `targetVersion` is the `chain`'s head for
921
+ * the target, and the `targetOwnerId` its `list` filters on is `leaves`'
922
+ * owner stamp (or `null`), as the SQL store's joins read them. `lock`
923
+ * locks nothing (one JavaScript thread has no concurrent decision to
924
+ * serialise). `mintId` mints sequential UUID-shaped ids. In memory only;
925
+ * not for production.
926
+ *
927
+ * @since 0.1.0
928
+ */
929
+ export const makeInMemoryProposalStore = (options: {
930
+ readonly chain: InMemoryChainStore;
931
+ readonly leaves: InMemoryLeafStore;
932
+ }): InMemoryProposalStore => {
933
+ const table = plainRowTable<ProposalRow>({
934
+ prefix: "9000",
935
+ open: "pending",
936
+ at: (row) => row.submittedAt,
937
+ filterRow: (row) => ({
938
+ id: row.id,
939
+ authorId: row.authorId,
940
+ targetId: row.targetId,
941
+ targetType: row.targetType,
942
+ status: row.status,
943
+ submittedAt: row.submittedAt,
944
+ targetOwnerId: Option.getOrElse(
945
+ HashMap.get(options.leaves.owners(), row.targetId),
946
+ () => null,
947
+ ),
948
+ }),
949
+ });
950
+ const listingOf = (row: ProposalRow): Effect.Effect<ProposalListing> =>
951
+ Effect.map(options.chain.head(row.targetId), (head) => ({
952
+ row,
953
+ targetVersion: Option.map(head, (entry) => entry.version),
954
+ }));
955
+ return {
956
+ mintId: table.mintId,
957
+ insert: table.insert,
958
+ get: (id) =>
959
+ Effect.suspend(() =>
960
+ Option.match(table.find(id), {
961
+ onNone: () => Effect.succeedNone,
962
+ onSome: (row) => Effect.asSome(listingOf(row)),
963
+ }),
964
+ ),
965
+ lock: table.lock,
966
+ close: (id, decision) =>
967
+ table.close(id, (row) => ({
968
+ ...row,
969
+ status: decision.status,
970
+ decidedAt: Option.some(decision.decidedAt),
971
+ decidedBy: Option.some(decision.decidedBy),
972
+ decisionNote: decision.decisionNote,
973
+ appliedVersion: decision.appliedVersion,
974
+ })),
975
+ list: ({ where, limit, offset }) =>
976
+ Effect.suspend(() => {
977
+ const matching = table.matching(where);
978
+ return Effect.map(
979
+ Effect.forEach(matching.slice(offset, offset + limit), listingOf, { concurrency: 1 }),
980
+ (page) => ({ rows: page, total: matching.length }),
981
+ );
982
+ }),
983
+ rows: table.rows,
984
+ };
985
+ };
986
+
987
+ // ── Member reports ────────────────────────────────────────────────────
988
+
989
+ /**
990
+ * An in-memory {@link ReportStore}, plus the hook a test reads it through.
991
+ * In memory only; not for production.
992
+ *
993
+ * @since 0.1.0
994
+ */
995
+ export interface InMemoryReportStore extends ReportStore<never, never> {
996
+ /** Every report, in filing order, as it now stands. */
997
+ readonly rows: () => ReadonlyArray<ReportRow>;
998
+ }
999
+
1000
+ /**
1001
+ * An in-memory {@link ReportStore}: the seam's contract (README → Seams →
1002
+ * `ReportStore`). `list` evaluates the filter in memory, newest first with
1003
+ * `id` descending on a tie; `lock` locks nothing (one JavaScript thread has
1004
+ * no concurrent resolution to serialise); `mintId` mints sequential
1005
+ * UUID-shaped ids. In memory only; not for production.
1006
+ *
1007
+ * @since 0.1.0
1008
+ */
1009
+ export const makeInMemoryReportStore = (): InMemoryReportStore => {
1010
+ const table = plainRowTable<ReportRow>({
1011
+ prefix: "a000",
1012
+ open: "open",
1013
+ at: (row) => row.filedAt,
1014
+ filterRow: (row) => ({
1015
+ id: row.id,
1016
+ reporterId: row.reporterId,
1017
+ targetId: row.targetId,
1018
+ targetType: row.targetType,
1019
+ status: row.status,
1020
+ }),
1021
+ });
1022
+ return {
1023
+ mintId: table.mintId,
1024
+ insert: table.insert,
1025
+ get: (id) => Effect.sync(() => table.find(id)),
1026
+ lock: table.lock,
1027
+ close: (id, resolution) =>
1028
+ table.close(id, (row) => ({
1029
+ ...row,
1030
+ status: resolution.status,
1031
+ resolvedAt: Option.some(resolution.resolvedAt),
1032
+ resolvedBy: Option.some(resolution.resolvedBy),
1033
+ resolutionNote: resolution.resolutionNote,
1034
+ })),
1035
+ list: ({ where, limit, offset }) =>
1036
+ Effect.sync(() => {
1037
+ const matching = table.matching(where);
1038
+ return { rows: matching.slice(offset, offset + limit), total: matching.length };
1039
+ }),
1040
+ rows: table.rows,
1041
+ };
1042
+ };
1043
+
1044
+ // ── Blobs ─────────────────────────────────────────────────────────────
1045
+
1046
+ /**
1047
+ * The in-memory blob store's one failure: a simulated outage.
1048
+ *
1049
+ * @since 0.1.0
1050
+ */
1051
+ export class InMemoryBlobFailure extends Schema.TaggedError<
1052
+ InMemoryBlobFailure,
1053
+ { readonly brand: unique symbol }
1054
+ >()("InMemoryBlobFailure", { key: Schema.String }) {}
1055
+
1056
+ /**
1057
+ * One object in an {@link InMemoryBlobStore}.
1058
+ *
1059
+ * @since 0.1.0
1060
+ */
1061
+ export interface InMemoryBlob {
1062
+ readonly bytes: Uint8Array;
1063
+ readonly head: BlobHead;
1064
+ }
1065
+
1066
+ /**
1067
+ * An in-memory {@link BlobStore}, plus the hooks a test reads and steers it
1068
+ * through. In memory only; not for production.
1069
+ *
1070
+ * @since 0.1.0
1071
+ */
1072
+ export interface InMemoryBlobStore extends BlobStore<InMemoryBlobFailure, never> {
1073
+ /** Every stored object by key. */
1074
+ readonly objects: () => HashMap.HashMap<string, InMemoryBlob>;
1075
+ /** Simulate an outage: the next `put` fails with {@link InMemoryBlobFailure}. */
1076
+ readonly failNextPut: () => void;
1077
+ }
1078
+
1079
+ /**
1080
+ * An in-memory {@link BlobStore}: the seam's contract (README → Seams →
1081
+ * `BlobStore`). `publicUrl` is `memory://<key>`; there is no signed upload
1082
+ * URL. `failNextPut` simulates the byte write failing after the commit. In
1083
+ * memory only; not for production.
1084
+ *
1085
+ * @since 0.1.0
1086
+ */
1087
+ export const makeInMemoryBlobStore = (): InMemoryBlobStore => {
1088
+ let objects = HashMap.empty<string, InMemoryBlob>();
1089
+ let failing = false;
1090
+ return {
1091
+ put: (key, bytes, head) =>
1092
+ Effect.suspend(() => {
1093
+ if (failing) {
1094
+ failing = false;
1095
+ return Effect.fail(new InMemoryBlobFailure({ key }));
1096
+ }
1097
+ objects = HashMap.set(objects, key, { bytes: Uint8Array.from(bytes), head });
1098
+ return Effect.void;
1099
+ }),
1100
+ get: (key) =>
1101
+ Effect.sync(() => Option.map(HashMap.get(objects, key), (object) => object.bytes)),
1102
+ head: (key) =>
1103
+ Effect.sync(() => Option.map(HashMap.get(objects, key), (object) => object.head)),
1104
+ delete: (key) =>
1105
+ Effect.sync(() => {
1106
+ objects = HashMap.remove(objects, key);
1107
+ }),
1108
+ publicUrl: (key) => Option.some(`memory://${key}`),
1109
+ signedUploadUrl: Option.none(),
1110
+ objects: () => objects,
1111
+ failNextPut: () => {
1112
+ failing = true;
1113
+ },
1114
+ };
1115
+ };
1116
+
1117
+ // ── Publication ───────────────────────────────────────────────────────
1118
+
1119
+ /**
1120
+ * A publish-gated leaf's in-memory seams, over an in-memory chain and leaf
1121
+ * store, plus the hook a test reads the log through. In memory only; not
1122
+ * for production.
1123
+ *
1124
+ * @since 0.1.0
1125
+ */
1126
+ export interface InMemoryPublication {
1127
+ /** The publication log. */
1128
+ readonly facts: PublicationStore<never, never>;
1129
+ /** The live-version view. */
1130
+ readonly live: LiveView<LeafRow, InMemoryFollowing, never, never>;
1131
+ /** The leaf's current-version view, with `draft` computed from the log. */
1132
+ readonly leaves: LeafStore<LeafRow, InMemoryFollowing, never, never>;
1133
+ /** Every fact, in record order. */
1134
+ readonly recorded: () => ReadonlyArray<PublicationFact>;
1135
+ }
1136
+
1137
+ /**
1138
+ * The in-memory seams of a publish-gated leaf: the seam contracts (README →
1139
+ * Seams → `PublicationStore`, `LiveView`). The effective fact is the last
1140
+ * recorded one whose `at` has arrived at `now` (the Effect `Clock`'s when
1141
+ * absent, so `TestClock` steers a schedule). A live row is the state at the
1142
+ * effective fact's version, with that version as its `version`, the
1143
+ * head's `action` and owner, `draft: false` and the fact's `at` as
1144
+ * `publishedAt`; a head row's `draft`
1145
+ * is whether the node has no live version. In memory only; not for
1146
+ * production.
1147
+ *
1148
+ * @since 0.1.0
1149
+ */
1150
+ export const makeInMemoryPublication = (options: {
1151
+ readonly chain: InMemoryChainStore;
1152
+ readonly leaves: InMemoryLeafStore;
1153
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is the Effect `Clock`
1154
+ readonly now?: Date | undefined;
1155
+ }): InMemoryPublication => {
1156
+ const fixedNow = Option.fromNullishOr(options.now);
1157
+ const localised = options.leaves.columns.has(LOCALE_COLUMN);
1158
+ /** A row as a localised view lists it: one per locale its version carries. */
1159
+ const listed = (row: LeafRow): ReadonlyArray<LeafRow> =>
1160
+ localised ? localeRows(row, localesAt(options.chain, row.id, row.version)) : [row];
1161
+ let log: ReadonlyArray<PublicationFact> = [];
1162
+ const effectiveAt = (id: string, now: Date) =>
1163
+ Arr.findLast(log, (fact) => fact.id === id && fact.at.getTime() <= now.getTime());
1164
+ const liveVersion = (id: string, now: Date) =>
1165
+ Option.flatMap(effectiveAt(id, now), (fact) =>
1166
+ Option.map(fact.version, (version) => ({ version, at: fact.at })),
1167
+ );
1168
+ const liveOf = (head: LeafRow, now: Date): Option.Option<LeafRow> =>
1169
+ Option.flatMap(liveVersion(head.id, now), ({ version, at }) =>
1170
+ Option.map(
1171
+ Arr.findFirst(options.chain.rows(), (row) => row.id === head.id && row.version === version),
1172
+ (row): LeafRow => {
1173
+ const base: LeafRow = {
1174
+ ...row.state,
1175
+ id: head.id,
1176
+ version,
1177
+ action: head.action,
1178
+ draft: false,
1179
+ publishedAt: at,
1180
+ };
1181
+ return Option.match(Option.fromUndefinedOr(head["ownerId"]), {
1182
+ onNone: () => base,
1183
+ onSome: (ownerId): LeafRow => ({ ...base, ownerId }),
1184
+ });
1185
+ },
1186
+ ),
1187
+ );
1188
+ const headOf = (head: LeafRow, now: Date): LeafRow => ({
1189
+ ...head,
1190
+ draft: Option.isNone(liveVersion(head.id, now)),
1191
+ });
1192
+ const atNow = <A>(read: (now: Date) => Effect.Effect<A>) =>
1193
+ Effect.flatMap(instantOf(fixedNow), read);
1194
+ const heads = () =>
1195
+ options.leaves.getMany(Arr.dedupe(Arr.map(options.chain.rows(), (row) => row.id)));
1196
+ /** The rows a live list reads: per node, its head for `headFor`, else its live row, else none. */
1197
+ const chosen = (headFor: ListFilter<InMemoryFollowing>, now: Date) =>
1198
+ Effect.map(heads(), (rows) =>
1199
+ Arr.flatMap(
1200
+ Arr.getSomes(
1201
+ Arr.map(rows, (head) =>
1202
+ evaluate(headFor, head, contextAt(now))
1203
+ ? Option.some(headOf(head, now))
1204
+ : liveOf(head, now),
1205
+ ),
1206
+ ),
1207
+ listed,
1208
+ ),
1209
+ );
1210
+ const facts: PublicationStore<never, never> = {
1211
+ record: (fact) =>
1212
+ Effect.sync(() => {
1213
+ log = [...log, fact];
1214
+ }),
1215
+ effective: (id) => Effect.map(instantOf(fixedNow), (now) => effectiveAt(id, now)),
1216
+ facts: (id, limit) => Effect.sync(() => log.filter((fact) => fact.id === id).slice(0, limit)),
1217
+ };
1218
+ const live: LiveView<LeafRow, InMemoryFollowing, never, never> = {
1219
+ get: (id) =>
1220
+ atNow((now) =>
1221
+ Effect.map(options.leaves.get(id), (head) =>
1222
+ Option.flatMap(head, (row) => liveOf(row, now)),
1223
+ ),
1224
+ ),
1225
+ getMany: (ids) =>
1226
+ atNow((now) =>
1227
+ Effect.map(options.leaves.getMany(ids), (rows) =>
1228
+ Arr.getSomes(Arr.map(rows, (row) => liveOf(row, now))),
1229
+ ),
1230
+ ),
1231
+ list: (query) =>
1232
+ atNow((now) => Effect.map(chosen(query.headFor, now), (rows) => viewPage(rows, query, now))),
1233
+ distinct: (query) =>
1234
+ atNow((now) =>
1235
+ Effect.map(chosen(query.headFor, now), (rows) => distinctPage(rows, query, now)),
1236
+ ),
1237
+ };
1238
+ const leaves: LeafStore<LeafRow, InMemoryFollowing, never, never> = {
1239
+ ...options.leaves,
1240
+ get: (id) =>
1241
+ atNow((now) =>
1242
+ Effect.map(options.leaves.get(id), (head) => Option.map(head, (row) => headOf(row, now))),
1243
+ ),
1244
+ getMany: (ids) =>
1245
+ atNow((now) =>
1246
+ Effect.map(options.leaves.getMany(ids), (rows) => Arr.map(rows, (row) => headOf(row, now))),
1247
+ ),
1248
+ list: (query) =>
1249
+ atNow((now) =>
1250
+ Effect.map(heads(), (rows) =>
1251
+ viewPage(
1252
+ Arr.flatMap(rows, (row) => listed(headOf(row, now))),
1253
+ query,
1254
+ now,
1255
+ ),
1256
+ ),
1257
+ ),
1258
+ distinct: (query) =>
1259
+ atNow((now) =>
1260
+ Effect.map(heads(), (rows) =>
1261
+ distinctPage(
1262
+ Arr.flatMap(rows, (row) => listed(headOf(row, now))),
1263
+ query,
1264
+ now,
1265
+ ),
1266
+ ),
1267
+ ),
1268
+ };
1269
+ return { facts, live, leaves, recorded: () => log };
1270
+ };
1271
+
1272
+ // ── Working copies ────────────────────────────────────────────────────
1273
+
1274
+ /**
1275
+ * An in-memory {@link WorkingCopyStore}, plus the hook a test reads it
1276
+ * through. In memory only; not for production.
1277
+ *
1278
+ * @since 0.1.0
1279
+ */
1280
+ export interface InMemoryWorkingCopyStore extends WorkingCopyStore<never, never> {
1281
+ /** Every copy, keyed `id:userId`. */
1282
+ readonly copies: () => HashMap.HashMap<string, WorkingCopy>;
1283
+ }
1284
+
1285
+ /**
1286
+ * An in-memory {@link WorkingCopyStore}: the seam's contract (README →
1287
+ * Seams → `WorkingCopyStore`), one copy per `(id, userId)`. In memory only;
1288
+ * not for production.
1289
+ *
1290
+ * @since 0.1.0
1291
+ */
1292
+ export const makeInMemoryWorkingCopyStore = (): InMemoryWorkingCopyStore => {
1293
+ let copies = HashMap.empty<string, WorkingCopy>();
1294
+ const keyOf = (key: WorkingCopyKey) => `${key.id}:${key.userId}`;
1295
+ return {
1296
+ put: (key, copy) =>
1297
+ Effect.sync(() => {
1298
+ copies = HashMap.set(copies, keyOf(key), copy);
1299
+ }),
1300
+ get: (key) => Effect.sync(() => HashMap.get(copies, keyOf(key))),
1301
+ discard: (key) =>
1302
+ Effect.sync(() => {
1303
+ copies = HashMap.remove(copies, keyOf(key));
1304
+ }),
1305
+ copies: () => copies,
1306
+ };
1307
+ };
1308
+
1309
+ // ── Purge ─────────────────────────────────────────────────────────────
1310
+
1311
+ /**
1312
+ * Options for {@link makeInMemoryPurgeStore}.
1313
+ *
1314
+ * @since 0.1.0
1315
+ */
1316
+ export interface InMemoryPurgeStoreOptions {
1317
+ /**
1318
+ * Every chain whose version rows may reference a document or bytes: the
1319
+ * purged node's rows are erased from each, and a hash is unreferenced when
1320
+ * no row left in any of them carries it.
1321
+ */
1322
+ readonly chains: ReadonlyArray<InMemoryChainStore>;
1323
+ /** The documents an unreferenced hash is deleted from; omitted, none is deleted (or reported). */
1324
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "no document store"
1325
+ readonly documents?: Pick<InMemoryDocumentStore<Schema.Json>, "remove"> | undefined;
1326
+ /** The state columns that carry a `DocumentHash`; omitted, none do. */
1327
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "no document columns"
1328
+ readonly documentColumns?: ReadonlyArray<string> | undefined;
1329
+ /** The state columns that carry a `ContentHash` (a file leaf's `contentHash`); omitted, none do. */
1330
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "no content columns"
1331
+ readonly blobColumns?: ReadonlyArray<string> | undefined;
1332
+ /**
1333
+ * What else `eraseNode` deletes for the node: the rows other fakes keep
1334
+ * for it (an owner stamp through `InMemoryLeafStore.dropOwner`, working
1335
+ * copies, edges, …), as a real store's `ON DELETE CASCADE` would.
1336
+ */
1337
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "nothing else"
1338
+ readonly dependents?: ReadonlyArray<(id: string) => void> | undefined;
1339
+ }
1340
+
1341
+ /** The string values `columns` hold across `rows`, each once. */
1342
+ const hashesIn = (
1343
+ rows: ReadonlyArray<VersionRow>,
1344
+ columns: ReadonlyArray<string>,
1345
+ ): ReadonlyArray<string> =>
1346
+ Arr.dedupe(
1347
+ Arr.flatMap(rows, (row) =>
1348
+ Arr.flatMap(columns, (column) => {
1349
+ const value = row.state[column];
1350
+ return Predicate.isString(value) ? [value] : [];
1351
+ }),
1352
+ ),
1353
+ );
1354
+
1355
+ /**
1356
+ * An in-memory {@link PurgeStore} over in-memory chains and a document
1357
+ * store: the seam's contract (README → Seams → `PurgeStore`). `eraseNode`
1358
+ * erases the node's rows from every chain and runs the `dependents`;
1359
+ * a hash is referenced while any row left in any chain carries it in a
1360
+ * declared column. In memory only; not for production.
1361
+ *
1362
+ * @since 0.1.0
1363
+ */
1364
+ export const makeInMemoryPurgeStore = (
1365
+ options: InMemoryPurgeStoreOptions,
1366
+ ): PurgeStore<never, never> => {
1367
+ const documentColumns = options.documentColumns ?? [];
1368
+ const blobColumns = options.blobColumns ?? [];
1369
+ const remaining = () => Arr.flatMap(options.chains, (chain) => chain.rows());
1370
+ const unreferenced = <A extends string>(
1371
+ hashes: ReadonlyArray<A>,
1372
+ columns: ReadonlyArray<string>,
1373
+ ) => {
1374
+ const referenced = hashesIn(remaining(), columns);
1375
+ return hashes.filter((hash) => !referenced.includes(hash));
1376
+ };
1377
+ return {
1378
+ eraseNode: (id) =>
1379
+ Effect.sync((): ErasedReferences => {
1380
+ const erased = Arr.flatMap(options.chains, (chain) => chain.erase(id));
1381
+ Arr.forEach(options.dependents ?? [], (dependent) => dependent(id));
1382
+ return {
1383
+ documents: hashesIn(erased, documentColumns),
1384
+ blobs: hashesIn(erased, blobColumns),
1385
+ };
1386
+ }),
1387
+ deleteUnreferencedDocuments: (hashes) =>
1388
+ Effect.sync(() => {
1389
+ if (options.documents === undefined) return [];
1390
+ const deleted = unreferenced(hashes, documentColumns);
1391
+ options.documents.remove(deleted);
1392
+ return deleted;
1393
+ }),
1394
+ unreferencedBlobs: (hashes) => Effect.sync(() => unreferenced(hashes, blobColumns)),
1395
+ };
1396
+ };
1397
+
1398
+ // ── Search (pulpit#204) ───────────────────────────────────────────────
1399
+
1400
+ /** A scored row, as the in-memory branch ranks it. */
1401
+ interface ScoredRow {
1402
+ readonly row: { readonly id: string };
1403
+ readonly score: number;
1404
+ }
1405
+
1406
+ /** Best score first, ties by id in code-point order. */
1407
+ const scoredOrder: Order.Order<ScoredRow> = Order.combine(
1408
+ Order.mapInput(Order.flip(Order.Number), (hit: ScoredRow) => hit.score),
1409
+ Order.mapInput(byCodePoint, (hit: ScoredRow) => hit.row.id),
1410
+ );
1411
+
1412
+ /**
1413
+ * An in-memory {@link SearchBranch} over `rows`: each row the scope admits
1414
+ * (evaluated at `now` over {@link InMemoryFollowing}) and `score` gives a
1415
+ * score for the query is a hit, best score first, ties by id, at most
1416
+ * `request.limit`. The reference semantics of the branch contract: it
1417
+ * applies the scope it is handed in the same pass that ranks.
1418
+ *
1419
+ * @since 0.1.0
1420
+ */
1421
+ export const makeInMemorySearchBranch = (options: {
1422
+ readonly name: string;
1423
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is weight 1
1424
+ readonly weight?: number | undefined;
1425
+ readonly rows: () => ReadonlyArray<FilterRow & { readonly id: string }>;
1426
+ /** The row's score for `query`, or none when it doesn't match. */
1427
+ readonly score: (row: FilterRow, query: string) => Option.Option<number>;
1428
+ readonly now: Date;
1429
+ }): SearchBranch<InMemoryFollowing, never, never> => ({
1430
+ name: options.name,
1431
+ weight: options.weight,
1432
+ search: (request) =>
1433
+ Effect.sync(() =>
1434
+ Arr.take(
1435
+ Arr.sort(
1436
+ Arr.getSomes(
1437
+ Arr.map(
1438
+ Arr.filter(options.rows(), (row) =>
1439
+ evaluate(request.scope, row, contextAt(options.now)),
1440
+ ),
1441
+ (row) => Option.map(options.score(row, request.query), (score) => ({ row, score })),
1442
+ ),
1443
+ ),
1444
+ scoredOrder,
1445
+ ).map(({ row }): RankedHit => ({ id: row.id })),
1446
+ Math.max(0, request.limit),
1447
+ ),
1448
+ ),
1449
+ });
1450
+
1451
+ /**
1452
+ * Wrap a {@link SearchBranch} so it fails {@link SearchScopeLeak} when it
1453
+ * returns a hit whose row (looked up in `rows` by id) the scope it was
1454
+ * handed does not admit, or that `rows` lacks: the test seam an
1455
+ * application wraps around its own SQL branches to prove each applies the
1456
+ * scope (`SEC-AUTH-ENUM-001`).
1457
+ *
1458
+ * @since 0.1.0
1459
+ */
1460
+ export const scopeCheckedBranch = <E, R>(
1461
+ branch: SearchBranch<InMemoryFollowing, E, R>,
1462
+ options: {
1463
+ readonly rows: () => ReadonlyArray<FilterRow & { readonly id: string }>;
1464
+ readonly now: Date;
1465
+ },
1466
+ ): SearchBranch<InMemoryFollowing, E | SearchScopeLeak, R> => ({
1467
+ ...branch,
1468
+ search: (request) =>
1469
+ Effect.flatMap(branch.search(request), (hits) => {
1470
+ const byId = HashMap.fromIterable(Arr.map(options.rows(), (row) => [row.id, row] as const));
1471
+ const leak = Arr.findFirst(
1472
+ hits,
1473
+ (hit) =>
1474
+ !Option.exists(HashMap.get(byId, hit.id), (row) =>
1475
+ evaluate(request.scope, row, contextAt(options.now)),
1476
+ ),
1477
+ );
1478
+ return Option.match(leak, {
1479
+ onNone: () => Effect.succeed(hits),
1480
+ onSome: (hit) => Effect.fail(new SearchScopeLeak({ branch: branch.name, id: hit.id })),
1481
+ });
1482
+ }),
1483
+ });
1484
+
1485
+ /**
1486
+ * {@link makeInMemoryEmbeddingStore}'s fake: the seam plus test hooks.
1487
+ *
1488
+ * @since 0.1.0
1489
+ */
1490
+ export interface InMemoryEmbeddingStore extends EmbeddingStore<never, never> {
1491
+ /** The queued hashes, in queue order. */
1492
+ readonly queued: () => ReadonlyArray<DocumentHash>;
1493
+ /** The written vectors, by hash. */
1494
+ readonly vectors: () => HashMap.HashMap<DocumentHash, ReadonlyArray<number>>;
1495
+ }
1496
+
1497
+ /**
1498
+ * An in-memory {@link EmbeddingStore}: a queue in insertion order
1499
+ * (`enqueue` is idempotent, and a hash with a vector is never queued again),
1500
+ * `pending` the oldest `limit` queued, `write` storing each vector and
1501
+ * dequeuing it, and `discard` dequeuing.
1502
+ *
1503
+ * @since 0.1.0
1504
+ */
1505
+ export const makeInMemoryEmbeddingStore = (): InMemoryEmbeddingStore => {
1506
+ let queue: ReadonlyArray<DocumentHash> = [];
1507
+ let vectors = HashMap.empty<DocumentHash, ReadonlyArray<number>>();
1508
+ const dequeue = (hashes: ReadonlyArray<DocumentHash>) => {
1509
+ queue = Arr.filter(queue, (hash) => !Arr.contains(hashes, hash));
1510
+ };
1511
+ return {
1512
+ enqueue: (hash) =>
1513
+ Effect.sync(() => {
1514
+ if (!Arr.contains(queue, hash) && !HashMap.has(vectors, hash)) queue = [...queue, hash];
1515
+ }),
1516
+ pending: (limit) => Effect.sync(() => Arr.take(queue, Math.max(0, limit))),
1517
+ write: (written) =>
1518
+ Effect.sync(() => {
1519
+ vectors = Arr.reduce(written, vectors, (acc, { hash, vector }) =>
1520
+ HashMap.set(acc, hash, vector),
1521
+ );
1522
+ dequeue(Arr.map(written, ({ hash }) => hash));
1523
+ }),
1524
+ discard: (hashes) => Effect.sync(() => dequeue(hashes)),
1525
+ queued: () => queue,
1526
+ vectors: () => vectors,
1527
+ };
1528
+ };
1529
+
1530
+ // ── Write budgets ─────────────────────────────────────────────────────
1531
+
1532
+ /**
1533
+ * One write {@link makeInMemoryWriteCountStore} has recorded.
1534
+ *
1535
+ * @since 0.1.0
1536
+ */
1537
+ export interface RecordedWrite {
1538
+ readonly type: string;
1539
+ readonly action: string;
1540
+ readonly accountId: string;
1541
+ readonly at: Date;
1542
+ }
1543
+
1544
+ type BudgetKey = Pick<RecordedWrite, "type" | "action" | "accountId">;
1545
+ const sameBudgetKey = (left: BudgetKey, right: BudgetKey) =>
1546
+ left.type === right.type && left.action === right.action && left.accountId === right.accountId;
1547
+
1548
+ /**
1549
+ * An in-memory atomic {@link BudgetCounter}, plus the hook a test reads it
1550
+ * through. In memory only; not for production.
1551
+ *
1552
+ * @since 0.1.0
1553
+ */
1554
+ export interface InMemoryBudgetCounter extends BudgetCounter<never, never> {
1555
+ /** Every spend it admitted, in order. */
1556
+ readonly spends: () => ReadonlyArray<BudgetRequest>;
1557
+ }
1558
+
1559
+ /**
1560
+ * An in-memory atomic {@link BudgetCounter} (README → Seams →
1561
+ * `BudgetCounter`): it counts the spends it admitted at or after the
1562
+ * request's `since`, and records a spend only when it answers `true`. One
1563
+ * JavaScript thread makes its check-and-record atomic. In memory only; not
1564
+ * for production.
1565
+ *
1566
+ * @since 0.1.0
1567
+ */
1568
+ export const makeInMemoryBudgetCounter = (): InMemoryBudgetCounter => {
1569
+ let spends: ReadonlyArray<BudgetRequest> = [];
1570
+ return {
1571
+ take: (request) =>
1572
+ Effect.sync(() => {
1573
+ const used = Arr.filter(
1574
+ spends,
1575
+ (spend) =>
1576
+ sameBudgetKey(spend, request) && spend.now.getTime() >= request.since.getTime(),
1577
+ ).length;
1578
+ if (used >= request.limit) return false;
1579
+ spends = [...spends, request];
1580
+ return true;
1581
+ }),
1582
+ spends: () => spends,
1583
+ };
1584
+ };
1585
+
1586
+ /**
1587
+ * An in-memory {@link WriteCountStore}, plus `record`, the row a write
1588
+ * inserts. In memory only; not for production.
1589
+ *
1590
+ * @since 0.1.0
1591
+ */
1592
+ export interface InMemoryWriteCountStore extends WriteCountStore<never, never> {
1593
+ /** Record one write, as the family's own row would. */
1594
+ readonly record: (write: RecordedWrite) => void;
1595
+ }
1596
+
1597
+ /**
1598
+ * An in-memory {@link WriteCountStore} (README → Seams →
1599
+ * `WriteCountStore`): `countSince` counts the recorded writes of the key at
1600
+ * or after `since`. In memory only; not for production.
1601
+ *
1602
+ * @since 0.1.0
1603
+ */
1604
+ export const makeInMemoryWriteCountStore = (): InMemoryWriteCountStore => {
1605
+ let writes: ReadonlyArray<RecordedWrite> = [];
1606
+ return {
1607
+ countSince: (query) =>
1608
+ Effect.sync(
1609
+ () =>
1610
+ Arr.filter(
1611
+ writes,
1612
+ (write) => sameBudgetKey(write, query) && write.at.getTime() >= query.since.getTime(),
1613
+ ).length,
1614
+ ),
1615
+ record: (write) => {
1616
+ writes = [...writes, write];
1617
+ },
1618
+ };
1619
+ };
1620
+
1621
+ // ── Reactions (pulpit#196) ────────────────────────────────────────────
1622
+
1623
+ /**
1624
+ * {@link makeInMemoryReactionStore}'s fake: the seam plus a test hook.
1625
+ *
1626
+ * @since 0.1.0
1627
+ */
1628
+ export interface InMemoryReactionStore extends ReactionStore<never, never> {
1629
+ /** Every stored reaction. */
1630
+ readonly rows: () => ReadonlyArray<ReactionRow>;
1631
+ }
1632
+
1633
+ /**
1634
+ * An in-memory {@link ReactionStore}, keyed `(nodeId, accountId)` as the
1635
+ * real table is: `upsert` replaces, `tally` groups by node and kind,
1636
+ * `byAccount` is newest first (`nodeId` breaking a tie).
1637
+ *
1638
+ * @since 0.1.0
1639
+ */
1640
+ export const makeInMemoryReactionStore = (): InMemoryReactionStore => {
1641
+ let rows: ReadonlyArray<ReactionRow> = [];
1642
+ const same = (nodeId: string, accountId: string) => (row: ReactionRow) =>
1643
+ row.nodeId === nodeId && row.accountId === accountId;
1644
+ const newestFirst = Order.combine(
1645
+ Order.mapInput(Order.flip(Order.Number), (row: ReactionRow) => row.reactedAt.getTime()),
1646
+ Order.mapInput(byCodePoint, (row: ReactionRow) => row.nodeId),
1647
+ );
1648
+ return {
1649
+ upsert: (row) =>
1650
+ Effect.sync(() => {
1651
+ const before = Arr.findFirst(rows, same(row.nodeId, row.accountId));
1652
+ rows = [...Arr.filter(rows, (held) => !same(row.nodeId, row.accountId)(held)), row];
1653
+ return Option.map(before, (held) => held.kind);
1654
+ }),
1655
+ delete: (nodeId, accountId) =>
1656
+ Effect.sync(() => {
1657
+ const before = rows.length;
1658
+ rows = Arr.filter(rows, (held) => !same(nodeId, accountId)(held));
1659
+ return rows.length < before;
1660
+ }),
1661
+ tally: (nodeIds) =>
1662
+ Effect.sync(() =>
1663
+ Arr.reduce(
1664
+ Arr.filter(rows, (row) => Arr.contains(nodeIds, row.nodeId)),
1665
+ HashMap.empty<string, { readonly [kind: string]: number }>(),
1666
+ (acc, row) => {
1667
+ const counts = Option.getOrElse(HashMap.get(acc, row.nodeId), () => ({}));
1668
+ return HashMap.set(acc, row.nodeId, {
1669
+ ...counts,
1670
+ [row.kind]: Option.getOrElse(Record.get(counts, row.kind), () => 0) + 1,
1671
+ });
1672
+ },
1673
+ ),
1674
+ ),
1675
+ byAccount: ({ accountId, limit, offset }) =>
1676
+ Effect.sync(() =>
1677
+ Arr.sort(
1678
+ Arr.filter(rows, (row) => row.accountId === accountId),
1679
+ newestFirst,
1680
+ ).slice(offset, offset + limit),
1681
+ ),
1682
+ eraseAccount: (accountId) =>
1683
+ Effect.sync(() => {
1684
+ const before = rows.length;
1685
+ rows = Arr.filter(rows, (row) => row.accountId !== accountId);
1686
+ return before - rows.length;
1687
+ }),
1688
+ rows: () => rows,
1689
+ };
1690
+ };
1691
+
1692
+ // ── The versioned KV (pulpit#205) ─────────────────────────────────────
1693
+
1694
+ /**
1695
+ * {@link makeInMemoryVersionedKv}'s fake: the seam plus test hooks.
1696
+ *
1697
+ * @since 0.1.0
1698
+ */
1699
+ export interface InMemoryVersionedKv extends VersionedKv<never, never> {
1700
+ /** The stored entry at `key` with its expiry instant (ms), expired or not. */
1701
+ readonly peek: (key: string) => Option.Option<KvEntry & { readonly expiresAt: number }>;
1702
+ /** Overwrite `key` unconditionally (a corrupt or foreign value, for a decode test). */
1703
+ readonly poke: (key: string, entry: KvEntry) => void;
1704
+ }
1705
+
1706
+ /**
1707
+ * An in-memory {@link VersionedKv}: `putIfNotBehind` compares and writes in
1708
+ * one step, and an entry past its TTL reads as absent (the Effect `Clock`,
1709
+ * so `TestClock` steers expiry). In memory only; not for production.
1710
+ *
1711
+ * @since 0.1.0
1712
+ */
1713
+ export const makeInMemoryVersionedKv = (): InMemoryVersionedKv => {
1714
+ let entries = HashMap.empty<string, KvEntry & { readonly expiresAt: number }>();
1715
+ const live = (key: string, now: number) =>
1716
+ Option.filter(HashMap.get(entries, key), (entry) => entry.expiresAt > now);
1717
+ return {
1718
+ get: (key) =>
1719
+ Effect.map(Clock.currentTimeMillis, (now) =>
1720
+ Option.map(live(key, now), ({ version, value }) => ({ version, value })),
1721
+ ),
1722
+ putIfNotBehind: (key, entry, ttlSeconds) =>
1723
+ Effect.map(Clock.currentTimeMillis, (now) => {
1724
+ if (Option.exists(live(key, now), (held) => held.version > entry.version)) return false;
1725
+ entries = HashMap.set(entries, key, { ...entry, expiresAt: now + ttlSeconds * 1000 });
1726
+ return true;
1727
+ }),
1728
+ peek: (key) => HashMap.get(entries, key),
1729
+ poke: (key, entry) => {
1730
+ entries = HashMap.set(entries, key, { ...entry, expiresAt: Number.POSITIVE_INFINITY });
1731
+ },
1732
+ };
1733
+ };
1734
+
1735
+ // ── Ordered collections (pulpit#193) ─────────────────────────────────
1736
+
1737
+ /**
1738
+ * {@link makeInMemoryCollectionStore}'s fake: the seam plus a test hook.
1739
+ *
1740
+ * @since 0.1.0
1741
+ */
1742
+ export interface InMemoryCollectionStore extends CollectionStore<never, never> {
1743
+ /** Every parent's entries, as stored. */
1744
+ readonly rows: () => HashMap.HashMap<string, ReadonlyArray<CollectionEntry>>;
1745
+ }
1746
+
1747
+ /**
1748
+ * An in-memory {@link CollectionStore}: each parent's entries, replaced
1749
+ * whole, read back by position then member id. In memory only; not for
1750
+ * production.
1751
+ *
1752
+ * @since 0.1.0
1753
+ */
1754
+ export const makeInMemoryCollectionStore = (): InMemoryCollectionStore => {
1755
+ let byParent = HashMap.empty<string, ReadonlyArray<CollectionEntry>>();
1756
+ const entryOrder = Order.combine(
1757
+ Order.mapInput(Order.Number, (entry: CollectionEntry) => entry.position),
1758
+ Order.mapInput(byCodePoint, (entry: CollectionEntry) => entry.memberId),
1759
+ );
1760
+ return {
1761
+ entries: (parentId, limit) =>
1762
+ Effect.sync(() =>
1763
+ Arr.take(
1764
+ Arr.sort(Option.getOrElse(HashMap.get(byParent, parentId), Arr.empty), entryOrder),
1765
+ Math.max(0, limit),
1766
+ ),
1767
+ ),
1768
+ replace: (parentId, entries) =>
1769
+ Effect.sync(() => {
1770
+ byParent = HashMap.set(byParent, parentId, [...entries]);
1771
+ }),
1772
+ rows: () => byParent,
1773
+ };
1774
+ };
1775
+
1776
+ // ── The social graph (pulpit#195) ─────────────────────────────────────
1777
+
1778
+ /**
1779
+ * {@link makeInMemorySocialStore}'s fake: the seam plus a test hook.
1780
+ *
1781
+ * @since 0.1.0
1782
+ */
1783
+ export interface InMemorySocialStore extends SocialStore<InMemoryFollowing, never, never> {
1784
+ /** Every stored relationship. */
1785
+ readonly rows: () => ReadonlyArray<Relationship>;
1786
+ }
1787
+
1788
+ /**
1789
+ * An in-memory {@link SocialStore}, keyed `(kind, from, to)` as the real
1790
+ * table is; `followingOf` and `silencedBy` return {@link InMemoryFollowing}
1791
+ * sets, where SQL's are subqueries. `addFollow` and `addBlock` each run as
1792
+ * one synchronous step, so they are atomic against each other as the seam
1793
+ * requires. In memory only; not for production.
1794
+ *
1795
+ * @since 0.1.0
1796
+ */
1797
+ export const makeInMemorySocialStore = (): InMemorySocialStore => {
1798
+ let rows: ReadonlyArray<Relationship> = [];
1799
+ const matches = (kind: string, from: string, to: string) => (row: Relationship) =>
1800
+ row.kind === kind && row.from === from && row.to === to;
1801
+ const toIds = (from: string, kinds: ReadonlyArray<string>) =>
1802
+ HashSet.fromIterable(
1803
+ Arr.map(
1804
+ Arr.filter(rows, (row) => row.from === from && kinds.includes(row.kind)),
1805
+ (row) => row.to,
1806
+ ),
1807
+ );
1808
+ type Related = { readonly accountId: string; readonly at: Date };
1809
+ const newestFirst = Order.combine(
1810
+ Order.mapInput(Order.flip(Order.Number), (row: Related) => row.at.getTime()),
1811
+ Order.mapInput(byCodePoint, (row: Related) => row.accountId),
1812
+ );
1813
+ return {
1814
+ add: (relationship) =>
1815
+ Effect.sync(() => {
1816
+ if (rows.some(matches(relationship.kind, relationship.from, relationship.to))) return false;
1817
+ rows = [...rows, relationship];
1818
+ return true;
1819
+ }),
1820
+ addFollow: (relationship) =>
1821
+ Effect.sync(() => {
1822
+ const { from, to } = relationship;
1823
+ if (rows.some(matches("block", from, to)) || rows.some(matches("block", to, from))) {
1824
+ return "blocked" as const;
1825
+ }
1826
+ if (rows.some(matches("follow", from, to))) return "present" as const;
1827
+ rows = [...rows, relationship];
1828
+ return "added" as const;
1829
+ }),
1830
+ addBlock: (relationship) =>
1831
+ Effect.sync(() => {
1832
+ const { from, to } = relationship;
1833
+ const written = !rows.some(matches("block", from, to));
1834
+ rows = [
1835
+ ...Arr.filter(
1836
+ rows,
1837
+ (row) => !matches("follow", from, to)(row) && !matches("follow", to, from)(row),
1838
+ ),
1839
+ ...(written ? [relationship] : []),
1840
+ ];
1841
+ return written;
1842
+ }),
1843
+ remove: (kind, from, to) =>
1844
+ Effect.sync(() => {
1845
+ const before = rows.length;
1846
+ rows = Arr.filter(rows, (row) => !matches(kind, from, to)(row));
1847
+ return rows.length < before;
1848
+ }),
1849
+ has: (kind, from, to) => Effect.sync(() => rows.some(matches(kind, from, to))),
1850
+ list: ({ kind, side, accountId, limit, offset }) =>
1851
+ Effect.sync(() => {
1852
+ const related = Arr.sort(
1853
+ Arr.map(
1854
+ Arr.filter(rows, (row) => row.kind === kind && row[side] === accountId),
1855
+ (row) => ({ accountId: side === "from" ? row.to : row.from, at: row.at }),
1856
+ ),
1857
+ newestFirst,
1858
+ );
1859
+ return { rows: related.slice(offset, offset + limit), total: related.length };
1860
+ }),
1861
+ isFollowing: (follower, followed) =>
1862
+ Effect.sync(() => rows.some(matches("follow", follower, followed))),
1863
+ followingOf: (follower) => toIds(follower, ["follow"]),
1864
+ silencedBy: (viewer) => toIds(viewer, ["mute", "block"]),
1865
+ eraseAccount: (accountId) =>
1866
+ Effect.sync(() => {
1867
+ const before = rows.length;
1868
+ rows = Arr.filter(rows, (row) => row.from !== accountId && row.to !== accountId);
1869
+ return before - rows.length;
1870
+ }),
1871
+ rows: () => rows,
1872
+ };
1873
+ };