@thomasfosterau/pulpit 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1271 -1
- package/dist/blobs.d.ts +249 -0
- package/dist/blobs.d.ts.map +1 -0
- package/dist/blobs.js +193 -0
- package/dist/blobs.js.map +1 -0
- package/dist/budgets.d.ts +255 -0
- package/dist/budgets.d.ts.map +1 -0
- package/dist/budgets.js +211 -0
- package/dist/budgets.js.map +1 -0
- package/dist/cache.d.ts +154 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +118 -0
- package/dist/cache.js.map +1 -0
- package/dist/collections.d.ts +242 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +215 -0
- package/dist/collections.js.map +1 -0
- package/dist/comments.d.ts +212 -0
- package/dist/comments.d.ts.map +1 -0
- package/dist/comments.js +192 -0
- package/dist/comments.js.map +1 -0
- package/dist/content.d.ts +231 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +204 -0
- package/dist/content.js.map +1 -0
- package/dist/documents.d.ts +184 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +190 -0
- package/dist/documents.js.map +1 -0
- package/dist/edges.d.ts +645 -0
- package/dist/edges.d.ts.map +1 -0
- package/dist/edges.js +558 -0
- package/dist/edges.js.map +1 -0
- package/dist/filter.d.ts +461 -0
- package/dist/filter.d.ts.map +1 -0
- package/dist/filter.js +387 -0
- package/dist/filter.js.map +1 -0
- package/dist/index.d.ts +133 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +133 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/filter-columns.d.ts +8 -0
- package/dist/internal/filter-columns.d.ts.map +1 -0
- package/dist/internal/filter-columns.js +43 -0
- package/dist/internal/filter-columns.js.map +1 -0
- package/dist/internal/list-order.d.ts +26 -0
- package/dist/internal/list-order.d.ts.map +1 -0
- package/dist/internal/list-order.js +92 -0
- package/dist/internal/list-order.js.map +1 -0
- package/dist/internal/owned-columns.d.ts +93 -0
- package/dist/internal/owned-columns.d.ts.map +1 -0
- package/dist/internal/owned-columns.js +98 -0
- package/dist/internal/owned-columns.js.map +1 -0
- package/dist/internal/plain-rows.d.ts +32 -0
- package/dist/internal/plain-rows.d.ts.map +1 -0
- package/dist/internal/plain-rows.js +32 -0
- package/dist/internal/plain-rows.js.map +1 -0
- package/dist/internal/reference-columns.d.ts +80 -0
- package/dist/internal/reference-columns.d.ts.map +1 -0
- package/dist/internal/reference-columns.js +102 -0
- package/dist/internal/reference-columns.js.map +1 -0
- package/dist/moderation.d.ts +392 -0
- package/dist/moderation.d.ts.map +1 -0
- package/dist/moderation.js +375 -0
- package/dist/moderation.js.map +1 -0
- package/dist/paths.d.ts +204 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +165 -0
- package/dist/paths.js.map +1 -0
- package/dist/proposals.d.ts +523 -0
- package/dist/proposals.d.ts.map +1 -0
- package/dist/proposals.js +505 -0
- package/dist/proposals.js.map +1 -0
- package/dist/publication.d.ts +148 -0
- package/dist/publication.d.ts.map +1 -0
- package/dist/publication.js +183 -0
- package/dist/publication.js.map +1 -0
- package/dist/purge.d.ts +224 -0
- package/dist/purge.d.ts.map +1 -0
- package/dist/purge.js +151 -0
- package/dist/purge.js.map +1 -0
- package/dist/reactions.d.ts +246 -0
- package/dist/reactions.d.ts.map +1 -0
- package/dist/reactions.js +183 -0
- package/dist/reactions.js.map +1 -0
- package/dist/registry.d.ts +316 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +305 -0
- package/dist/registry.js.map +1 -0
- package/dist/search.d.ts +366 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +273 -0
- package/dist/search.js.map +1 -0
- package/dist/social.d.ts +311 -0
- package/dist/social.d.ts.map +1 -0
- package/dist/social.js +205 -0
- package/dist/social.js.map +1 -0
- package/dist/subtype.d.ts +1613 -0
- package/dist/subtype.d.ts.map +1 -0
- package/dist/subtype.js +1300 -0
- package/dist/subtype.js.map +1 -0
- package/dist/testing.d.ts +675 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +1022 -0
- package/dist/testing.js.map +1 -0
- package/dist/version-chain.d.ts +702 -0
- package/dist/version-chain.d.ts.map +1 -0
- package/dist/version-chain.js +489 -0
- package/dist/version-chain.js.map +1 -0
- package/dist/working-copy.d.ts +116 -0
- package/dist/working-copy.d.ts.map +1 -0
- package/dist/working-copy.js +95 -0
- package/dist/working-copy.js.map +1 -0
- package/package.json +138 -4
- package/src/blobs.ts +384 -0
- package/src/budgets.ts +338 -0
- package/src/cache.ts +218 -0
- package/src/collections.ts +427 -0
- package/src/comments.ts +392 -0
- package/src/content.ts +238 -0
- package/src/documents.ts +284 -0
- package/src/edges.ts +1185 -0
- package/src/filter.ts +523 -0
- package/src/index.ts +153 -0
- package/src/internal/filter-columns.ts +50 -0
- package/src/internal/list-order.ts +117 -0
- package/src/internal/owned-columns.ts +140 -0
- package/src/internal/plain-rows.ts +54 -0
- package/src/internal/reference-columns.ts +156 -0
- package/src/moderation.ts +728 -0
- package/src/paths.ts +267 -0
- package/src/proposals.ts +1014 -0
- package/src/publication.ts +225 -0
- package/src/purge.ts +313 -0
- package/src/reactions.ts +347 -0
- package/src/registry.ts +524 -0
- package/src/search.ts +540 -0
- package/src/social.ts +469 -0
- package/src/subtype.ts +3095 -0
- package/src/testing.ts +1873 -0
- package/src/version-chain.ts +1093 -0
- package/src/working-copy.ts +192 -0
package/dist/testing.js
ADDED
|
@@ -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
|