@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
@@ -0,0 +1,1022 @@
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
+ import * as Arr from "effect/Array";
49
+ import * as Clock from "effect/Clock";
50
+ import * as DateTime from "effect/DateTime";
51
+ import * as Effect from "effect/Effect";
52
+ import * as HashMap from "effect/HashMap";
53
+ import * as HashSet from "effect/HashSet";
54
+ import * as Option from "effect/Option";
55
+ import * as Order from "effect/Order";
56
+ import * as Predicate from "effect/Predicate";
57
+ import * as Record from "effect/Record";
58
+ import * as Schema from "effect/Schema";
59
+ import { sha256Hex } from "./blobs.js";
60
+ import { canonicalText } from "./documents.js";
61
+ import { endpointColumn, } from "./edges.js";
62
+ import { evaluate, LOCALE_COLUMN, } from "./filter.js";
63
+ import { byCodePoint, rowOrder } from "./internal/list-order.js";
64
+ import { SearchScopeLeak, } from "./search.js";
65
+ import { PathTaken } from "./paths.js";
66
+ import { localesOf, VersionConflict, } from "./version-chain.js";
67
+ /** The instant a fake's `published` / `notPublished` compare against: the fixed `now`, else the Effect `Clock`'s. */
68
+ const instantOf = (now) => Option.match(now, {
69
+ onNone: () => Effect.map(Clock.currentTimeMillis, (millis) => DateTime.toDate(DateTime.makeUnsafe(millis))),
70
+ onSome: Effect.succeed,
71
+ });
72
+ /** The list filter's evaluation context at `now`, over an {@link InMemoryFollowing} set. */
73
+ const contextAt = (now) => ({
74
+ now,
75
+ contains: (set, value) => Predicate.isString(value) && HashSet.has(set, value),
76
+ });
77
+ /**
78
+ * A node's rows in a localised view (pulpit#56): one per locale its version
79
+ * carries, the locale's columns over the shared row and the tag in
80
+ * {@link LOCALE_COLUMN}; a version carrying none is its shared row alone,
81
+ * the `NULL`-locale row a left join gives.
82
+ */
83
+ const localeRows = (row, locales) => Record.isEmptyRecord(locales)
84
+ ? [row]
85
+ : Arr.map(Record.toEntries(locales), ([tag, columns]) => ({
86
+ ...row,
87
+ ...columns,
88
+ [LOCALE_COLUMN]: tag,
89
+ }));
90
+ /** The locale level of `id` at `version` in an in-memory chain, `{}` for none. */
91
+ const localesAt = (chain, id, version) => Option.match(Arr.findFirst(chain.rows(), (row) => row.id === id && row.version === version), { onNone: () => ({}), onSome: localesOf });
92
+ /**
93
+ * The distinct string values of `column` among the rows matching `where` at
94
+ * `now`, in code-point order, paged: `LeafStore.distinct`'s contract.
95
+ */
96
+ const distinctPage = (rows, query, now) => {
97
+ const values = Arr.sort(Arr.dedupe(Arr.flatMap(rows, (row) => {
98
+ const value = row[query.column];
99
+ return evaluate(query.where, row, contextAt(now)) && Predicate.isString(value)
100
+ ? [value]
101
+ : [];
102
+ })), byCodePoint);
103
+ return {
104
+ rows: values.slice(query.offset, query.offset + query.limit),
105
+ total: values.length,
106
+ };
107
+ };
108
+ /** A view page: the rows matching `where` at `now`, in the list order contract's order. */
109
+ const viewPage = (rows, query, now) => {
110
+ const matching = Arr.sort(rows.filter((row) => evaluate(query.where, row, contextAt(now))), rowOrder(query.orderBy));
111
+ return {
112
+ rows: matching.slice(query.offset, query.offset + query.limit),
113
+ total: matching.length,
114
+ };
115
+ };
116
+ /**
117
+ * An in-memory {@link FollowerSeam} over an explicit table of who follows
118
+ * whom: each entry is a follower's id and the ids of the accounts it
119
+ * follows. `isFollower` answers from the table and `following` returns the
120
+ * follower's row as an {@link InMemoryFollowing} set (empty for an account the
121
+ * table doesn't name), the in-memory reading of effect-auth's `IsFollower`
122
+ * (README → Seams → the follower seam). In memory only; not for production.
123
+ *
124
+ * @since 0.1.0
125
+ */
126
+ export const makeInMemoryFollowers = (table) => {
127
+ const follows = HashMap.fromIterable(table);
128
+ const followedBy = (followerId) => Option.getOrElse(HashMap.get(follows, followerId), Arr.empty);
129
+ return {
130
+ isFollower: (followerId, ownerId) => Effect.succeed(followedBy(followerId).includes(ownerId)),
131
+ following: (followerId) => HashSet.fromIterable(followedBy(followerId)),
132
+ };
133
+ };
134
+ /**
135
+ * An in-memory {@link VersionChainStore}: the seam's contract (README → Seams
136
+ * → `VersionChainStore`) over an append log. It enforces what the Drizzle
137
+ * store's schema enforces, the `(id, version)` primary key (an `append` at a
138
+ * taken pair fails `VersionConflict`), and nothing the operations are
139
+ * responsible for. `mintId` mints sequential UUID-shaped ids.
140
+ *
141
+ * `dropState` simulates a partial chain (a level with no row at a version),
142
+ * which a real database reaches through a missing leaf row, and
143
+ * `raceNextAppend` a writer that takes the next version first. In memory
144
+ * only; not for production.
145
+ *
146
+ * @since 0.1.0
147
+ */
148
+ export const makeInMemoryChainStore = () => {
149
+ let log = [];
150
+ let dropped = HashSet.empty();
151
+ let raced = HashSet.empty();
152
+ let minted = 0;
153
+ const key = (id, version) => `${id}@${version}`;
154
+ const chain = (id) => log.filter((row) => row.id === id);
155
+ const entryOf = (row) => ({
156
+ version: row.version,
157
+ action: row.action,
158
+ timestamp: row.timestamp,
159
+ });
160
+ /** Whether the next append for `id` is raced, consuming the race. */
161
+ const takeRace = (id) => {
162
+ const wasRaced = HashSet.has(raced, id);
163
+ raced = HashSet.remove(raced, id);
164
+ return wasRaced;
165
+ };
166
+ return {
167
+ head: (id) => Effect.sync(() => Option.map(Arr.last(chain(id)), entryOf)),
168
+ snapshot: (id, version) => Effect.sync(() => HashSet.has(dropped, key(id, version))
169
+ ? Option.none()
170
+ : Option.map(Arr.findFirst(chain(id), (candidate) => candidate.version === version), (row) => row.locales === undefined
171
+ ? { ...entryOf(row), state: row.state }
172
+ : { ...entryOf(row), state: row.state, locales: row.locales })),
173
+ append: (row) => Effect.suspend(() => {
174
+ if (takeRace(row.id) || chain(row.id).some((taken) => taken.version === row.version)) {
175
+ return Effect.fail(new VersionConflict({ id: row.id, version: row.version }));
176
+ }
177
+ log = [...log, row];
178
+ return Effect.void;
179
+ }),
180
+ entries: (id, limit) => Effect.sync(() => chain(id).slice(0, limit).map(entryOf)),
181
+ mintId: Effect.sync(() => {
182
+ minted += 1;
183
+ return `00000000-0000-4000-8000-${String(minted).padStart(12, "0")}`;
184
+ }),
185
+ rows: () => log,
186
+ dropState: (id, version) => {
187
+ dropped = HashSet.add(dropped, key(id, version));
188
+ },
189
+ raceNextAppend: (id) => {
190
+ raced = HashSet.add(raced, id);
191
+ },
192
+ erase: (id) => {
193
+ const erased = chain(id);
194
+ log = log.filter((row) => row.id !== id);
195
+ return erased;
196
+ },
197
+ };
198
+ };
199
+ /**
200
+ * An in-memory {@link LeafStore}: the seam's contract (README → Seams →
201
+ * `LeafStore`) as a current-version view over an {@link InMemoryChainStore}.
202
+ * Each node's row is its head version's state with its head `version` and `action`, and
203
+ * `ownerId` merged in from the owner stamps (the apps' `core.node_owner`
204
+ * join); `list` evaluates its {@link ListFilter} in memory, orders by its
205
+ * `orderBy` keys then `id` ascending as the seam's contract renders it
206
+ * (`NULLS LAST` in both directions, a string by code point as `COLLATE "C"`
207
+ * sorts it, a `Date` by its instant), and returns the unpaged total. Like a
208
+ * SQL store, it takes `orderBy` as already checked against `columns`. It
209
+ * applies no visibility policy, as a real view applies none. In memory only;
210
+ * not for production.
211
+ *
212
+ * @since 0.1.0
213
+ */
214
+ export const makeInMemoryLeafStore = (chain, options) => {
215
+ const fixedNow = Option.fromNullishOr(options.now);
216
+ let owners = HashMap.empty();
217
+ const headOf = (row) => {
218
+ const base = { ...row.state, id: row.id, version: row.version, action: row.action };
219
+ return Option.match(HashMap.get(owners, row.id), {
220
+ onNone: () => base,
221
+ onSome: (ownerId) => ({ ...base, ownerId }),
222
+ });
223
+ };
224
+ /** The rows `list` reads: one per `(id, locale)` on a localised view. */
225
+ const listed = () => options.localised === true
226
+ ? Arr.flatMap(view(), (row) => localeRows(row, localesAt(chain, row.id, row.version)))
227
+ : view();
228
+ const view = () => Arr.sort(HashMap.toValues(Arr.reduce(chain.rows(), HashMap.empty(), (heads, row) => HashMap.set(heads, row.id, headOf(row)))), rowOrder([]));
229
+ return {
230
+ // oxlint-disable-next-line effect/avoid-native-object-helpers -- the seam's column set is the native `ReadonlySet` `LeafStore.columns` names
231
+ columns: new Set([
232
+ "id",
233
+ "version",
234
+ "action",
235
+ "ownerId",
236
+ ...options.columns,
237
+ ...(options.localised === true ? [LOCALE_COLUMN] : []),
238
+ ]),
239
+ get: (id) => Effect.sync(() => Arr.findFirst(view(), (row) => row.id === id)),
240
+ getMany: (ids) => Effect.sync(() => view().filter((row) => ids.includes(row.id))),
241
+ list: (query) => Effect.map(instantOf(fixedNow), (now) => viewPage(listed(), query, now)),
242
+ distinct: (query) => Effect.map(instantOf(fixedNow), (now) => distinctPage(listed(), query, now)),
243
+ ownerOf: (id) => Effect.sync(() => HashMap.get(owners, id)),
244
+ stampOwner: (id, ownerId) => Effect.sync(() => {
245
+ owners = HashMap.set(owners, id, ownerId);
246
+ }),
247
+ owners: () => owners,
248
+ dropOwner: (id) => {
249
+ owners = HashMap.remove(owners, id);
250
+ },
251
+ };
252
+ };
253
+ /**
254
+ * An in-memory {@link PathStore}: the seam's contract (README → Seams →
255
+ * `PathStore`) as one map from path to entry, its key the path table's
256
+ * primary key, so each path is stored once. `claim` fails {@link PathTaken}
257
+ * for another node's canonical path, replaces an alias at the path, and
258
+ * demotes the node's previous canonical path to its alias, in one step;
259
+ * `release` drops the node's canonical path and keeps its aliases. In memory
260
+ * only; not for production.
261
+ *
262
+ * @since 0.1.0
263
+ */
264
+ export const makeInMemoryPathStore = () => {
265
+ let entries = HashMap.empty();
266
+ const canonicalOf = (nodeId) => Option.map(Arr.findFirst(HashMap.toEntries(entries), ([, entry]) => entry.canonical && entry.nodeId === nodeId), ([path]) => path);
267
+ return {
268
+ lookup: (path) => Effect.sync(() => HashMap.get(entries, path)),
269
+ canonicalOf: (nodeId) => Effect.sync(() => canonicalOf(nodeId)),
270
+ claim: (nodeId, path) => Effect.suspend(() => {
271
+ const held = HashMap.get(entries, path);
272
+ if (Option.exists(held, (entry) => entry.canonical && entry.nodeId !== nodeId)) {
273
+ return Effect.fail(new PathTaken({ path }));
274
+ }
275
+ entries = Option.match(canonicalOf(nodeId), {
276
+ onNone: () => entries,
277
+ onSome: (previous) => HashMap.set(entries, previous, { nodeId, canonical: false }),
278
+ });
279
+ entries = HashMap.set(entries, path, { nodeId, canonical: true });
280
+ return Effect.void;
281
+ }),
282
+ release: (nodeId) => Effect.sync(() => {
283
+ entries = Option.match(canonicalOf(nodeId), {
284
+ onNone: () => entries,
285
+ onSome: (path) => HashMap.remove(entries, path),
286
+ });
287
+ }),
288
+ entries: () => entries,
289
+ };
290
+ };
291
+ /**
292
+ * An in-memory {@link ResourceCacheSeam}: the seam's compare-on-set contract
293
+ * (README → Seams → the resource cache, pulpit#121). It keeps, per id, the
294
+ * highest version either call has given it: a `set` below it is a no-op, a
295
+ * `set` at or above it stores the resource when `serveable` admits it and
296
+ * evicts it otherwise, and a `delete` evicts and raises the version. Each
297
+ * call compares and writes in one step, as the real cache's conditional write
298
+ * must. In memory only; not for production.
299
+ *
300
+ * @since 0.1.0
301
+ */
302
+ export const makeInMemoryResourceCache = (serveable) => {
303
+ let entries = HashMap.empty();
304
+ const versionOf = (id) => Option.map(HashMap.get(entries, id), (entry) => entry.version);
305
+ const behind = (id, version) => Option.exists(versionOf(id), (held) => version < held);
306
+ return {
307
+ set: (id, resource, version) => Effect.sync(() => {
308
+ if (behind(id, version))
309
+ return;
310
+ entries = HashMap.set(entries, id, {
311
+ version,
312
+ resource: serveable(resource) ? Option.some(resource) : Option.none(),
313
+ });
314
+ }),
315
+ delete: (id, version) => Effect.sync(() => {
316
+ if (behind(id, version))
317
+ return;
318
+ entries = HashMap.set(entries, id, { version, resource: Option.none() });
319
+ }),
320
+ get: (id) => Option.flatMap(HashMap.get(entries, id), (entry) => entry.resource),
321
+ versionOf,
322
+ };
323
+ };
324
+ // ── Documents ─────────────────────────────────────────────────────────
325
+ const utf8 = new TextEncoder();
326
+ /** SHA-256 over the canonical text's UTF-8 bytes, as 64 lowercase hex characters. */
327
+ const hashOf = (document) => sha256Hex(utf8.encode(canonicalText(document)));
328
+ /**
329
+ * An in-memory {@link DocumentStore}: the seam's contract (README → Seams →
330
+ * `DocumentStore`), hash contract included. It hashes exactly as a Postgres
331
+ * store does, SHA-256 over the UTF-8 bytes of the document's
332
+ * {@link canonicalText}, so its hashes are the ones the database generates
333
+ * (the kernel's suite pins it to hashes taken from Postgres). Like a store's
334
+ * primary key on the hash with `ON CONFLICT DO NOTHING`, it keeps the first
335
+ * document stored at a hash and never replaces it (`DOC-001`). It hashes with
336
+ * Web Crypto (`crypto.subtle`), which Node.js 22 and every browser provide.
337
+ * In memory only; not for production.
338
+ *
339
+ * @since 0.1.0
340
+ */
341
+ export const makeInMemoryDocumentStore = () => {
342
+ let documents = HashMap.empty();
343
+ let writes = 0;
344
+ return {
345
+ put: (document) => Effect.map(hashOf(document), (hash) => {
346
+ // `ON CONFLICT DO NOTHING`: the first document at a hash is kept.
347
+ const inserted = !HashMap.has(documents, hash);
348
+ if (inserted) {
349
+ documents = HashMap.set(documents, hash, document);
350
+ writes += 1;
351
+ }
352
+ return { hash, inserted };
353
+ }),
354
+ get: (hash) => Effect.sync(() => HashMap.get(documents, hash)),
355
+ getMany: (hashes) => Effect.sync(() => HashMap.filter(documents, (_document, hash) => hashes.includes(hash))),
356
+ stored: () => documents,
357
+ writes: () => writes,
358
+ remove: (hashes) => {
359
+ documents = HashMap.filter(documents, (_document, hash) => !hashes.includes(hash));
360
+ },
361
+ };
362
+ };
363
+ /**
364
+ * An in-memory {@link EndpointView}: an edge end's current-version view
365
+ * (README → Seams → `EdgeStore`) as rows a test puts by hand, for an edge
366
+ * suite that doesn't need the endpoint leaf's version chain. Its `columns`
367
+ * are `id`, `version`, `action` and `columns`. An {@link InMemoryLeafStore} is an
368
+ * endpoint view too. In memory only; not for production.
369
+ *
370
+ * @since 0.1.0
371
+ */
372
+ export const makeInMemoryNodes = (columns) => {
373
+ let nodes = HashMap.empty();
374
+ return {
375
+ // 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`
376
+ columns: new Set(["id", "version", "action", ...columns]),
377
+ get: (id) => Effect.sync(() => HashMap.get(nodes, id)),
378
+ put: (row) => {
379
+ nodes = HashMap.set(nodes, row.id, row);
380
+ },
381
+ };
382
+ };
383
+ /** Two column values the unique key treats as equal: `NULLS NOT DISTINCT`, an absent key read as `NULL`. */
384
+ const sameKey = (row, values, column) => (row[column] ?? null) === (values[column] ?? null);
385
+ /** The edge list's total order: `createdAt`, then `id`. */
386
+ const byCreation = Order.combine(Order.mapInput(Order.Date, (row) => row.createdAt), Order.mapInput(Order.String, (row) => row.id));
387
+ /**
388
+ * An in-memory {@link EdgeStore}: the seam's contract (README → Seams →
389
+ * `EdgeStore`) over one edge family's rows. It enforces what an edge table's
390
+ * schema enforces, the family's unique key (with `NULLS NOT DISTINCT`, as
391
+ * gardeneria's `page_slugs_unique_variant`), and nothing the operations are
392
+ * responsible for. `list`, `countAt`, `members` and `countBy` join each end's view the way the SQL
393
+ * store's INNER JOIN does: under the end's alias, dropping an edge whose end
394
+ * has no row. `list` orders by `createdAt` then `id` and reports the unpaged
395
+ * total as the SQL store's window column does (`0` for an empty page).
396
+ *
397
+ * Ids are minted `edge-0001`, `edge-0002`, …; the n-th insert is stamped
398
+ * `now + n` milliseconds, except that every third shares its predecessor's
399
+ * instant, so the `id` tie-break of the list order is exercised. In memory
400
+ * only; not for production.
401
+ *
402
+ * @since 0.1.0
403
+ */
404
+ export const makeInMemoryEdgeStore = (options) => {
405
+ const fixedNow = Option.fromNullishOr(options.now);
406
+ let stored = [];
407
+ let minted = 0;
408
+ let race = Option.none();
409
+ const collides = (values) => Arr.findFirst(stored, (row) => Arr.isReadonlyArrayNonEmpty(options.uniqueKey) &&
410
+ options.uniqueKey.every((column) => sameKey(row, values, column)));
411
+ const write = (values, now) => {
412
+ minted += 1;
413
+ const id = `edge-${String(minted).padStart(4, "0")}`;
414
+ const createdAt = DateTime.toDate(DateTime.makeUnsafe(now.getTime() + minted - (minted % 3 === 0 ? 1 : 0)));
415
+ stored = [...stored, { ...values, id, createdAt }];
416
+ return id;
417
+ };
418
+ /** The edge joined with each end's node under its alias, or none when an end has no row (INNER JOIN). */
419
+ const joined = (row) => Effect.map(Effect.forEach(Record.toEntries(options.ends), ([alias, end]) => {
420
+ const nodeId = row[end.column];
421
+ return Predicate.isString(nodeId)
422
+ ? Effect.map(end.nodes.get(nodeId), Option.map((node) => Record.mapKeys(node, (column) => endpointColumn(alias, column))))
423
+ : Effect.succeedNone;
424
+ }, { concurrency: 1 }), (nodes) => Option.map(Option.all(nodes), (all) => Object.assign({}, row, ...all)));
425
+ /** The stored edges whose join satisfies `where`, in the list's total order. */
426
+ const matching = (where) => Effect.flatMap(instantOf(fixedNow), (now) => Effect.filter(Arr.sort(stored, byCreation), (row) => Effect.map(joined(row), Option.exists((present) => evaluate(where, present, contextAt(now))))));
427
+ return {
428
+ get: (id) => Effect.sync(() => Arr.findFirst(stored, (row) => row.id === id)),
429
+ // The total as the SQL store's window column reads it: every matching
430
+ // row when the page has one, and 0 for an empty page (past the end too).
431
+ list: ({ where, limit, offset }) => Effect.map(matching(where), (rows) => {
432
+ const page = rows.slice(offset, offset + limit);
433
+ return { rows: page, total: Arr.isReadonlyArrayNonEmpty(page) ? rows.length : 0 };
434
+ }),
435
+ // The count over the list's joins, as the SQL store's `count(*)` reads
436
+ // them: the edges at the node that satisfy `where`.
437
+ countAt: ({ column, nodeId, where }) => Effect.map(matching(where), (rows) => rows.filter((row) => row[column] === nodeId).length),
438
+ existing: (values) => Effect.sync(() => Option.map(collides(values), (row) => row.id)),
439
+ insert: (values) => Effect.map(instantOf(fixedNow), (now) => {
440
+ if (Option.isSome(race)) {
441
+ write(race.value, now);
442
+ race = Option.none();
443
+ return Option.none();
444
+ }
445
+ return Option.isSome(collides(values)) ? Option.none() : Option.some(write(values, now));
446
+ }),
447
+ delete: (id) => Effect.sync(() => {
448
+ const before = stored.length;
449
+ stored = stored.filter((row) => row.id !== id);
450
+ return stored.length < before;
451
+ }),
452
+ closeValidity: (id, column, to) => Effect.sync(() => {
453
+ const found = stored.some((row) => row.id === id);
454
+ stored = stored.map((row) => (row.id === id ? { ...row, [column]: to } : row));
455
+ return found;
456
+ }),
457
+ // The SQL store's subquery, computed eagerly over the list's joins: the
458
+ // member end's ids of the edges whose other end is named and that
459
+ // satisfy `where`. A non-string member id is never in the set.
460
+ members: ({ column, otherColumn, ids, where }) => Effect.map(matching(where), (rows) => HashSet.fromIterable(Arr.flatMap(rows, (row) => {
461
+ const member = row[column];
462
+ const other = row[otherColumn];
463
+ return Predicate.isString(member) && Predicate.isString(other) && ids.includes(other)
464
+ ? [member]
465
+ : [];
466
+ }))),
467
+ // The SQL store's grouped count over the list's joins: per requested
468
+ // node, the edges at it that satisfy `where`; a node with none, no key.
469
+ countBy: ({ column, ids, where }) => Effect.map(matching(where), (rows) => Arr.reduce(rows, HashMap.empty(), (counts, row) => {
470
+ const node = row[column];
471
+ return Predicate.isString(node) && ids.includes(node)
472
+ ? HashMap.set(counts, node, Option.getOrElse(HashMap.get(counts, node), () => 0) + 1)
473
+ : counts;
474
+ })),
475
+ rows: () => stored,
476
+ raceNextInsert: (winner) => {
477
+ race = Option.some(winner);
478
+ },
479
+ };
480
+ };
481
+ /**
482
+ * The table both plain-row fakes keep: insert order, sequential UUID-shaped
483
+ * ids under `prefix`, `close` on an `open`-status row only, and a page
484
+ * newest first by `at`, `id` descending on a tie, evaluated in memory.
485
+ */
486
+ const plainRowTable = (options) => {
487
+ let rows = [];
488
+ let minted = 0;
489
+ const newestFirst = Order.combine(Order.flip(Order.mapInput(Order.Date, options.at)), Order.flip(Order.mapInput(Order.String, (row) => row.id)));
490
+ // No plain-row column is a publication stamp, so `now` is never read.
491
+ const context = {
492
+ now: DateTime.toDate(DateTime.makeUnsafe(0)),
493
+ contains: () => false,
494
+ };
495
+ const find = (id) => Arr.findFirst(rows, (row) => row.id === id);
496
+ return {
497
+ rows: () => rows,
498
+ find,
499
+ mintId: Effect.sync(() => {
500
+ minted += 1;
501
+ return `00000000-0000-4000-${options.prefix}-${String(minted).padStart(12, "0")}`;
502
+ }),
503
+ insert: (row) => Effect.sync(() => {
504
+ rows = [...rows, row];
505
+ }),
506
+ lock: (id) => Effect.sync(() => find(id)),
507
+ close: (id, closed) => Effect.sync(() => {
508
+ rows = Arr.map(rows, (row) => row.id === id && row.status === options.open ? closed(row) : row);
509
+ }),
510
+ matching: (where) => Arr.sort(Arr.filter(rows, (row) => evaluate(where, options.filterRow(row), context)), newestFirst),
511
+ };
512
+ };
513
+ /**
514
+ * An in-memory {@link ProposalStore}: the seam's contract (README → Seams →
515
+ * `ProposalStore`). A listing's `targetVersion` is the `chain`'s head for
516
+ * the target, and the `targetOwnerId` its `list` filters on is `leaves`'
517
+ * owner stamp (or `null`), as the SQL store's joins read them. `lock`
518
+ * locks nothing (one JavaScript thread has no concurrent decision to
519
+ * serialise). `mintId` mints sequential UUID-shaped ids. In memory only;
520
+ * not for production.
521
+ *
522
+ * @since 0.1.0
523
+ */
524
+ export const makeInMemoryProposalStore = (options) => {
525
+ const table = plainRowTable({
526
+ prefix: "9000",
527
+ open: "pending",
528
+ at: (row) => row.submittedAt,
529
+ filterRow: (row) => ({
530
+ id: row.id,
531
+ authorId: row.authorId,
532
+ targetId: row.targetId,
533
+ targetType: row.targetType,
534
+ status: row.status,
535
+ submittedAt: row.submittedAt,
536
+ targetOwnerId: Option.getOrElse(HashMap.get(options.leaves.owners(), row.targetId), () => null),
537
+ }),
538
+ });
539
+ const listingOf = (row) => Effect.map(options.chain.head(row.targetId), (head) => ({
540
+ row,
541
+ targetVersion: Option.map(head, (entry) => entry.version),
542
+ }));
543
+ return {
544
+ mintId: table.mintId,
545
+ insert: table.insert,
546
+ get: (id) => Effect.suspend(() => Option.match(table.find(id), {
547
+ onNone: () => Effect.succeedNone,
548
+ onSome: (row) => Effect.asSome(listingOf(row)),
549
+ })),
550
+ lock: table.lock,
551
+ close: (id, decision) => table.close(id, (row) => ({
552
+ ...row,
553
+ status: decision.status,
554
+ decidedAt: Option.some(decision.decidedAt),
555
+ decidedBy: Option.some(decision.decidedBy),
556
+ decisionNote: decision.decisionNote,
557
+ appliedVersion: decision.appliedVersion,
558
+ })),
559
+ list: ({ where, limit, offset }) => Effect.suspend(() => {
560
+ const matching = table.matching(where);
561
+ return Effect.map(Effect.forEach(matching.slice(offset, offset + limit), listingOf, { concurrency: 1 }), (page) => ({ rows: page, total: matching.length }));
562
+ }),
563
+ rows: table.rows,
564
+ };
565
+ };
566
+ /**
567
+ * An in-memory {@link ReportStore}: the seam's contract (README → Seams →
568
+ * `ReportStore`). `list` evaluates the filter in memory, newest first with
569
+ * `id` descending on a tie; `lock` locks nothing (one JavaScript thread has
570
+ * no concurrent resolution to serialise); `mintId` mints sequential
571
+ * UUID-shaped ids. In memory only; not for production.
572
+ *
573
+ * @since 0.1.0
574
+ */
575
+ export const makeInMemoryReportStore = () => {
576
+ const table = plainRowTable({
577
+ prefix: "a000",
578
+ open: "open",
579
+ at: (row) => row.filedAt,
580
+ filterRow: (row) => ({
581
+ id: row.id,
582
+ reporterId: row.reporterId,
583
+ targetId: row.targetId,
584
+ targetType: row.targetType,
585
+ status: row.status,
586
+ }),
587
+ });
588
+ return {
589
+ mintId: table.mintId,
590
+ insert: table.insert,
591
+ get: (id) => Effect.sync(() => table.find(id)),
592
+ lock: table.lock,
593
+ close: (id, resolution) => table.close(id, (row) => ({
594
+ ...row,
595
+ status: resolution.status,
596
+ resolvedAt: Option.some(resolution.resolvedAt),
597
+ resolvedBy: Option.some(resolution.resolvedBy),
598
+ resolutionNote: resolution.resolutionNote,
599
+ })),
600
+ list: ({ where, limit, offset }) => Effect.sync(() => {
601
+ const matching = table.matching(where);
602
+ return { rows: matching.slice(offset, offset + limit), total: matching.length };
603
+ }),
604
+ rows: table.rows,
605
+ };
606
+ };
607
+ // ── Blobs ─────────────────────────────────────────────────────────────
608
+ /**
609
+ * The in-memory blob store's one failure: a simulated outage.
610
+ *
611
+ * @since 0.1.0
612
+ */
613
+ export class InMemoryBlobFailure extends Schema.TaggedError()("InMemoryBlobFailure", { key: Schema.String }) {
614
+ }
615
+ /**
616
+ * An in-memory {@link BlobStore}: the seam's contract (README → Seams →
617
+ * `BlobStore`). `publicUrl` is `memory://<key>`; there is no signed upload
618
+ * URL. `failNextPut` simulates the byte write failing after the commit. In
619
+ * memory only; not for production.
620
+ *
621
+ * @since 0.1.0
622
+ */
623
+ export const makeInMemoryBlobStore = () => {
624
+ let objects = HashMap.empty();
625
+ let failing = false;
626
+ return {
627
+ put: (key, bytes, head) => Effect.suspend(() => {
628
+ if (failing) {
629
+ failing = false;
630
+ return Effect.fail(new InMemoryBlobFailure({ key }));
631
+ }
632
+ objects = HashMap.set(objects, key, { bytes: Uint8Array.from(bytes), head });
633
+ return Effect.void;
634
+ }),
635
+ get: (key) => Effect.sync(() => Option.map(HashMap.get(objects, key), (object) => object.bytes)),
636
+ head: (key) => Effect.sync(() => Option.map(HashMap.get(objects, key), (object) => object.head)),
637
+ delete: (key) => Effect.sync(() => {
638
+ objects = HashMap.remove(objects, key);
639
+ }),
640
+ publicUrl: (key) => Option.some(`memory://${key}`),
641
+ signedUploadUrl: Option.none(),
642
+ objects: () => objects,
643
+ failNextPut: () => {
644
+ failing = true;
645
+ },
646
+ };
647
+ };
648
+ /**
649
+ * The in-memory seams of a publish-gated leaf: the seam contracts (README →
650
+ * Seams → `PublicationStore`, `LiveView`). The effective fact is the last
651
+ * recorded one whose `at` has arrived at `now` (the Effect `Clock`'s when
652
+ * absent, so `TestClock` steers a schedule). A live row is the state at the
653
+ * effective fact's version, with that version as its `version`, the
654
+ * head's `action` and owner, `draft: false` and the fact's `at` as
655
+ * `publishedAt`; a head row's `draft`
656
+ * is whether the node has no live version. In memory only; not for
657
+ * production.
658
+ *
659
+ * @since 0.1.0
660
+ */
661
+ export const makeInMemoryPublication = (options) => {
662
+ const fixedNow = Option.fromNullishOr(options.now);
663
+ const localised = options.leaves.columns.has(LOCALE_COLUMN);
664
+ /** A row as a localised view lists it: one per locale its version carries. */
665
+ const listed = (row) => localised ? localeRows(row, localesAt(options.chain, row.id, row.version)) : [row];
666
+ let log = [];
667
+ const effectiveAt = (id, now) => Arr.findLast(log, (fact) => fact.id === id && fact.at.getTime() <= now.getTime());
668
+ const liveVersion = (id, now) => Option.flatMap(effectiveAt(id, now), (fact) => Option.map(fact.version, (version) => ({ version, at: fact.at })));
669
+ const liveOf = (head, now) => Option.flatMap(liveVersion(head.id, now), ({ version, at }) => Option.map(Arr.findFirst(options.chain.rows(), (row) => row.id === head.id && row.version === version), (row) => {
670
+ const base = {
671
+ ...row.state,
672
+ id: head.id,
673
+ version,
674
+ action: head.action,
675
+ draft: false,
676
+ publishedAt: at,
677
+ };
678
+ return Option.match(Option.fromUndefinedOr(head["ownerId"]), {
679
+ onNone: () => base,
680
+ onSome: (ownerId) => ({ ...base, ownerId }),
681
+ });
682
+ }));
683
+ const headOf = (head, now) => ({
684
+ ...head,
685
+ draft: Option.isNone(liveVersion(head.id, now)),
686
+ });
687
+ const atNow = (read) => Effect.flatMap(instantOf(fixedNow), read);
688
+ const heads = () => options.leaves.getMany(Arr.dedupe(Arr.map(options.chain.rows(), (row) => row.id)));
689
+ /** The rows a live list reads: per node, its head for `headFor`, else its live row, else none. */
690
+ const chosen = (headFor, now) => Effect.map(heads(), (rows) => Arr.flatMap(Arr.getSomes(Arr.map(rows, (head) => evaluate(headFor, head, contextAt(now))
691
+ ? Option.some(headOf(head, now))
692
+ : liveOf(head, now))), listed));
693
+ const facts = {
694
+ record: (fact) => Effect.sync(() => {
695
+ log = [...log, fact];
696
+ }),
697
+ effective: (id) => Effect.map(instantOf(fixedNow), (now) => effectiveAt(id, now)),
698
+ facts: (id, limit) => Effect.sync(() => log.filter((fact) => fact.id === id).slice(0, limit)),
699
+ };
700
+ const live = {
701
+ get: (id) => atNow((now) => Effect.map(options.leaves.get(id), (head) => Option.flatMap(head, (row) => liveOf(row, now)))),
702
+ getMany: (ids) => atNow((now) => Effect.map(options.leaves.getMany(ids), (rows) => Arr.getSomes(Arr.map(rows, (row) => liveOf(row, now))))),
703
+ list: (query) => atNow((now) => Effect.map(chosen(query.headFor, now), (rows) => viewPage(rows, query, now))),
704
+ distinct: (query) => atNow((now) => Effect.map(chosen(query.headFor, now), (rows) => distinctPage(rows, query, now))),
705
+ };
706
+ const leaves = {
707
+ ...options.leaves,
708
+ get: (id) => atNow((now) => Effect.map(options.leaves.get(id), (head) => Option.map(head, (row) => headOf(row, now)))),
709
+ getMany: (ids) => atNow((now) => Effect.map(options.leaves.getMany(ids), (rows) => Arr.map(rows, (row) => headOf(row, now)))),
710
+ list: (query) => atNow((now) => Effect.map(heads(), (rows) => viewPage(Arr.flatMap(rows, (row) => listed(headOf(row, now))), query, now))),
711
+ distinct: (query) => atNow((now) => Effect.map(heads(), (rows) => distinctPage(Arr.flatMap(rows, (row) => listed(headOf(row, now))), query, now))),
712
+ };
713
+ return { facts, live, leaves, recorded: () => log };
714
+ };
715
+ /**
716
+ * An in-memory {@link WorkingCopyStore}: the seam's contract (README →
717
+ * Seams → `WorkingCopyStore`), one copy per `(id, userId)`. In memory only;
718
+ * not for production.
719
+ *
720
+ * @since 0.1.0
721
+ */
722
+ export const makeInMemoryWorkingCopyStore = () => {
723
+ let copies = HashMap.empty();
724
+ const keyOf = (key) => `${key.id}:${key.userId}`;
725
+ return {
726
+ put: (key, copy) => Effect.sync(() => {
727
+ copies = HashMap.set(copies, keyOf(key), copy);
728
+ }),
729
+ get: (key) => Effect.sync(() => HashMap.get(copies, keyOf(key))),
730
+ discard: (key) => Effect.sync(() => {
731
+ copies = HashMap.remove(copies, keyOf(key));
732
+ }),
733
+ copies: () => copies,
734
+ };
735
+ };
736
+ /** The string values `columns` hold across `rows`, each once. */
737
+ const hashesIn = (rows, columns) => Arr.dedupe(Arr.flatMap(rows, (row) => Arr.flatMap(columns, (column) => {
738
+ const value = row.state[column];
739
+ return Predicate.isString(value) ? [value] : [];
740
+ })));
741
+ /**
742
+ * An in-memory {@link PurgeStore} over in-memory chains and a document
743
+ * store: the seam's contract (README → Seams → `PurgeStore`). `eraseNode`
744
+ * erases the node's rows from every chain and runs the `dependents`;
745
+ * a hash is referenced while any row left in any chain carries it in a
746
+ * declared column. In memory only; not for production.
747
+ *
748
+ * @since 0.1.0
749
+ */
750
+ export const makeInMemoryPurgeStore = (options) => {
751
+ const documentColumns = options.documentColumns ?? [];
752
+ const blobColumns = options.blobColumns ?? [];
753
+ const remaining = () => Arr.flatMap(options.chains, (chain) => chain.rows());
754
+ const unreferenced = (hashes, columns) => {
755
+ const referenced = hashesIn(remaining(), columns);
756
+ return hashes.filter((hash) => !referenced.includes(hash));
757
+ };
758
+ return {
759
+ eraseNode: (id) => Effect.sync(() => {
760
+ const erased = Arr.flatMap(options.chains, (chain) => chain.erase(id));
761
+ Arr.forEach(options.dependents ?? [], (dependent) => dependent(id));
762
+ return {
763
+ documents: hashesIn(erased, documentColumns),
764
+ blobs: hashesIn(erased, blobColumns),
765
+ };
766
+ }),
767
+ deleteUnreferencedDocuments: (hashes) => Effect.sync(() => {
768
+ if (options.documents === undefined)
769
+ return [];
770
+ const deleted = unreferenced(hashes, documentColumns);
771
+ options.documents.remove(deleted);
772
+ return deleted;
773
+ }),
774
+ unreferencedBlobs: (hashes) => Effect.sync(() => unreferenced(hashes, blobColumns)),
775
+ };
776
+ };
777
+ /** Best score first, ties by id in code-point order. */
778
+ const scoredOrder = Order.combine(Order.mapInput(Order.flip(Order.Number), (hit) => hit.score), Order.mapInput(byCodePoint, (hit) => hit.row.id));
779
+ /**
780
+ * An in-memory {@link SearchBranch} over `rows`: each row the scope admits
781
+ * (evaluated at `now` over {@link InMemoryFollowing}) and `score` gives a
782
+ * score for the query is a hit, best score first, ties by id, at most
783
+ * `request.limit`. The reference semantics of the branch contract: it
784
+ * applies the scope it is handed in the same pass that ranks.
785
+ *
786
+ * @since 0.1.0
787
+ */
788
+ export const makeInMemorySearchBranch = (options) => ({
789
+ name: options.name,
790
+ weight: options.weight,
791
+ search: (request) => Effect.sync(() => Arr.take(Arr.sort(Arr.getSomes(Arr.map(Arr.filter(options.rows(), (row) => evaluate(request.scope, row, contextAt(options.now))), (row) => Option.map(options.score(row, request.query), (score) => ({ row, score })))), scoredOrder).map(({ row }) => ({ id: row.id })), Math.max(0, request.limit))),
792
+ });
793
+ /**
794
+ * Wrap a {@link SearchBranch} so it fails {@link SearchScopeLeak} when it
795
+ * returns a hit whose row (looked up in `rows` by id) the scope it was
796
+ * handed does not admit, or that `rows` lacks: the test seam an
797
+ * application wraps around its own SQL branches to prove each applies the
798
+ * scope (`SEC-AUTH-ENUM-001`).
799
+ *
800
+ * @since 0.1.0
801
+ */
802
+ export const scopeCheckedBranch = (branch, options) => ({
803
+ ...branch,
804
+ search: (request) => Effect.flatMap(branch.search(request), (hits) => {
805
+ const byId = HashMap.fromIterable(Arr.map(options.rows(), (row) => [row.id, row]));
806
+ const leak = Arr.findFirst(hits, (hit) => !Option.exists(HashMap.get(byId, hit.id), (row) => evaluate(request.scope, row, contextAt(options.now))));
807
+ return Option.match(leak, {
808
+ onNone: () => Effect.succeed(hits),
809
+ onSome: (hit) => Effect.fail(new SearchScopeLeak({ branch: branch.name, id: hit.id })),
810
+ });
811
+ }),
812
+ });
813
+ /**
814
+ * An in-memory {@link EmbeddingStore}: a queue in insertion order
815
+ * (`enqueue` is idempotent, and a hash with a vector is never queued again),
816
+ * `pending` the oldest `limit` queued, `write` storing each vector and
817
+ * dequeuing it, and `discard` dequeuing.
818
+ *
819
+ * @since 0.1.0
820
+ */
821
+ export const makeInMemoryEmbeddingStore = () => {
822
+ let queue = [];
823
+ let vectors = HashMap.empty();
824
+ const dequeue = (hashes) => {
825
+ queue = Arr.filter(queue, (hash) => !Arr.contains(hashes, hash));
826
+ };
827
+ return {
828
+ enqueue: (hash) => Effect.sync(() => {
829
+ if (!Arr.contains(queue, hash) && !HashMap.has(vectors, hash))
830
+ queue = [...queue, hash];
831
+ }),
832
+ pending: (limit) => Effect.sync(() => Arr.take(queue, Math.max(0, limit))),
833
+ write: (written) => Effect.sync(() => {
834
+ vectors = Arr.reduce(written, vectors, (acc, { hash, vector }) => HashMap.set(acc, hash, vector));
835
+ dequeue(Arr.map(written, ({ hash }) => hash));
836
+ }),
837
+ discard: (hashes) => Effect.sync(() => dequeue(hashes)),
838
+ queued: () => queue,
839
+ vectors: () => vectors,
840
+ };
841
+ };
842
+ const sameBudgetKey = (left, right) => left.type === right.type && left.action === right.action && left.accountId === right.accountId;
843
+ /**
844
+ * An in-memory atomic {@link BudgetCounter} (README → Seams →
845
+ * `BudgetCounter`): it counts the spends it admitted at or after the
846
+ * request's `since`, and records a spend only when it answers `true`. One
847
+ * JavaScript thread makes its check-and-record atomic. In memory only; not
848
+ * for production.
849
+ *
850
+ * @since 0.1.0
851
+ */
852
+ export const makeInMemoryBudgetCounter = () => {
853
+ let spends = [];
854
+ return {
855
+ take: (request) => Effect.sync(() => {
856
+ const used = Arr.filter(spends, (spend) => sameBudgetKey(spend, request) && spend.now.getTime() >= request.since.getTime()).length;
857
+ if (used >= request.limit)
858
+ return false;
859
+ spends = [...spends, request];
860
+ return true;
861
+ }),
862
+ spends: () => spends,
863
+ };
864
+ };
865
+ /**
866
+ * An in-memory {@link WriteCountStore} (README → Seams →
867
+ * `WriteCountStore`): `countSince` counts the recorded writes of the key at
868
+ * or after `since`. In memory only; not for production.
869
+ *
870
+ * @since 0.1.0
871
+ */
872
+ export const makeInMemoryWriteCountStore = () => {
873
+ let writes = [];
874
+ return {
875
+ countSince: (query) => Effect.sync(() => Arr.filter(writes, (write) => sameBudgetKey(write, query) && write.at.getTime() >= query.since.getTime()).length),
876
+ record: (write) => {
877
+ writes = [...writes, write];
878
+ },
879
+ };
880
+ };
881
+ /**
882
+ * An in-memory {@link ReactionStore}, keyed `(nodeId, accountId)` as the
883
+ * real table is: `upsert` replaces, `tally` groups by node and kind,
884
+ * `byAccount` is newest first (`nodeId` breaking a tie).
885
+ *
886
+ * @since 0.1.0
887
+ */
888
+ export const makeInMemoryReactionStore = () => {
889
+ let rows = [];
890
+ const same = (nodeId, accountId) => (row) => row.nodeId === nodeId && row.accountId === accountId;
891
+ const newestFirst = Order.combine(Order.mapInput(Order.flip(Order.Number), (row) => row.reactedAt.getTime()), Order.mapInput(byCodePoint, (row) => row.nodeId));
892
+ return {
893
+ upsert: (row) => Effect.sync(() => {
894
+ const before = Arr.findFirst(rows, same(row.nodeId, row.accountId));
895
+ rows = [...Arr.filter(rows, (held) => !same(row.nodeId, row.accountId)(held)), row];
896
+ return Option.map(before, (held) => held.kind);
897
+ }),
898
+ delete: (nodeId, accountId) => Effect.sync(() => {
899
+ const before = rows.length;
900
+ rows = Arr.filter(rows, (held) => !same(nodeId, accountId)(held));
901
+ return rows.length < before;
902
+ }),
903
+ tally: (nodeIds) => Effect.sync(() => Arr.reduce(Arr.filter(rows, (row) => Arr.contains(nodeIds, row.nodeId)), HashMap.empty(), (acc, row) => {
904
+ const counts = Option.getOrElse(HashMap.get(acc, row.nodeId), () => ({}));
905
+ return HashMap.set(acc, row.nodeId, {
906
+ ...counts,
907
+ [row.kind]: Option.getOrElse(Record.get(counts, row.kind), () => 0) + 1,
908
+ });
909
+ })),
910
+ byAccount: ({ accountId, limit, offset }) => Effect.sync(() => Arr.sort(Arr.filter(rows, (row) => row.accountId === accountId), newestFirst).slice(offset, offset + limit)),
911
+ eraseAccount: (accountId) => Effect.sync(() => {
912
+ const before = rows.length;
913
+ rows = Arr.filter(rows, (row) => row.accountId !== accountId);
914
+ return before - rows.length;
915
+ }),
916
+ rows: () => rows,
917
+ };
918
+ };
919
+ /**
920
+ * An in-memory {@link VersionedKv}: `putIfNotBehind` compares and writes in
921
+ * one step, and an entry past its TTL reads as absent (the Effect `Clock`,
922
+ * so `TestClock` steers expiry). In memory only; not for production.
923
+ *
924
+ * @since 0.1.0
925
+ */
926
+ export const makeInMemoryVersionedKv = () => {
927
+ let entries = HashMap.empty();
928
+ const live = (key, now) => Option.filter(HashMap.get(entries, key), (entry) => entry.expiresAt > now);
929
+ return {
930
+ get: (key) => Effect.map(Clock.currentTimeMillis, (now) => Option.map(live(key, now), ({ version, value }) => ({ version, value }))),
931
+ putIfNotBehind: (key, entry, ttlSeconds) => Effect.map(Clock.currentTimeMillis, (now) => {
932
+ if (Option.exists(live(key, now), (held) => held.version > entry.version))
933
+ return false;
934
+ entries = HashMap.set(entries, key, { ...entry, expiresAt: now + ttlSeconds * 1000 });
935
+ return true;
936
+ }),
937
+ peek: (key) => HashMap.get(entries, key),
938
+ poke: (key, entry) => {
939
+ entries = HashMap.set(entries, key, { ...entry, expiresAt: Number.POSITIVE_INFINITY });
940
+ },
941
+ };
942
+ };
943
+ /**
944
+ * An in-memory {@link CollectionStore}: each parent's entries, replaced
945
+ * whole, read back by position then member id. In memory only; not for
946
+ * production.
947
+ *
948
+ * @since 0.1.0
949
+ */
950
+ export const makeInMemoryCollectionStore = () => {
951
+ let byParent = HashMap.empty();
952
+ const entryOrder = Order.combine(Order.mapInput(Order.Number, (entry) => entry.position), Order.mapInput(byCodePoint, (entry) => entry.memberId));
953
+ return {
954
+ entries: (parentId, limit) => Effect.sync(() => Arr.take(Arr.sort(Option.getOrElse(HashMap.get(byParent, parentId), Arr.empty), entryOrder), Math.max(0, limit))),
955
+ replace: (parentId, entries) => Effect.sync(() => {
956
+ byParent = HashMap.set(byParent, parentId, [...entries]);
957
+ }),
958
+ rows: () => byParent,
959
+ };
960
+ };
961
+ /**
962
+ * An in-memory {@link SocialStore}, keyed `(kind, from, to)` as the real
963
+ * table is; `followingOf` and `silencedBy` return {@link InMemoryFollowing}
964
+ * sets, where SQL's are subqueries. `addFollow` and `addBlock` each run as
965
+ * one synchronous step, so they are atomic against each other as the seam
966
+ * requires. In memory only; not for production.
967
+ *
968
+ * @since 0.1.0
969
+ */
970
+ export const makeInMemorySocialStore = () => {
971
+ let rows = [];
972
+ const matches = (kind, from, to) => (row) => row.kind === kind && row.from === from && row.to === to;
973
+ const toIds = (from, kinds) => HashSet.fromIterable(Arr.map(Arr.filter(rows, (row) => row.from === from && kinds.includes(row.kind)), (row) => row.to));
974
+ const newestFirst = Order.combine(Order.mapInput(Order.flip(Order.Number), (row) => row.at.getTime()), Order.mapInput(byCodePoint, (row) => row.accountId));
975
+ return {
976
+ add: (relationship) => Effect.sync(() => {
977
+ if (rows.some(matches(relationship.kind, relationship.from, relationship.to)))
978
+ return false;
979
+ rows = [...rows, relationship];
980
+ return true;
981
+ }),
982
+ addFollow: (relationship) => Effect.sync(() => {
983
+ const { from, to } = relationship;
984
+ if (rows.some(matches("block", from, to)) || rows.some(matches("block", to, from))) {
985
+ return "blocked";
986
+ }
987
+ if (rows.some(matches("follow", from, to)))
988
+ return "present";
989
+ rows = [...rows, relationship];
990
+ return "added";
991
+ }),
992
+ addBlock: (relationship) => Effect.sync(() => {
993
+ const { from, to } = relationship;
994
+ const written = !rows.some(matches("block", from, to));
995
+ rows = [
996
+ ...Arr.filter(rows, (row) => !matches("follow", from, to)(row) && !matches("follow", to, from)(row)),
997
+ ...(written ? [relationship] : []),
998
+ ];
999
+ return written;
1000
+ }),
1001
+ remove: (kind, from, to) => Effect.sync(() => {
1002
+ const before = rows.length;
1003
+ rows = Arr.filter(rows, (row) => !matches(kind, from, to)(row));
1004
+ return rows.length < before;
1005
+ }),
1006
+ has: (kind, from, to) => Effect.sync(() => rows.some(matches(kind, from, to))),
1007
+ list: ({ kind, side, accountId, limit, offset }) => Effect.sync(() => {
1008
+ const related = Arr.sort(Arr.map(Arr.filter(rows, (row) => row.kind === kind && row[side] === accountId), (row) => ({ accountId: side === "from" ? row.to : row.from, at: row.at })), newestFirst);
1009
+ return { rows: related.slice(offset, offset + limit), total: related.length };
1010
+ }),
1011
+ isFollowing: (follower, followed) => Effect.sync(() => rows.some(matches("follow", follower, followed))),
1012
+ followingOf: (follower) => toIds(follower, ["follow"]),
1013
+ silencedBy: (viewer) => toIds(viewer, ["mute", "block"]),
1014
+ eraseAccount: (accountId) => Effect.sync(() => {
1015
+ const before = rows.length;
1016
+ rows = Arr.filter(rows, (row) => row.from !== accountId && row.to !== accountId);
1017
+ return before - rows.length;
1018
+ }),
1019
+ rows: () => rows,
1020
+ };
1021
+ };
1022
+ //# sourceMappingURL=testing.js.map