@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.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1271 -1
  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 +138 -4
  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,427 @@
1
+ /**
2
+ * **Ordered collections** (pulpit#193) — positioned members of a parent
3
+ * node: a list's items, an album's media, a byline's authors, a menu's
4
+ * entries.
5
+ *
6
+ * The behavioural spec is website's ordered list items (a reorder appends
7
+ * a parent version) and in-the-dark's list entries (an updatable position
8
+ * beside the chain), with their tests.
9
+ *
10
+ * ## The model (owner decision on pulpit#193)
11
+ *
12
+ * Both models, opt-in per family, over **one store seam**
13
+ * ({@link CollectionStore}), which holds each member's position:
14
+ *
15
+ * - **`mutable`** (the default): the order is state beside the chain, like
16
+ * an edge. A write changes the store and nothing else.
17
+ * - **`versioned`**: every write also appends a parent version through the
18
+ * parent leaf's own `update`, recording the whole order (the member ids,
19
+ * in order) in a declared parent column, so `VERSION-CHAIN-001` covers it
20
+ * and history shows each reorder. After the parent's `restoreVersion`,
21
+ * {@link CollectionOps.resync} replaces the store's order with the one
22
+ * the restored version records, keeping only the members the writer can
23
+ * read, as `append` does.
24
+ *
25
+ * That column holds the id of every member, the ones a reader of the
26
+ * parent may not be able to read included, so it must stay out of the
27
+ * parent's viewer-facing resource (`EDGE-001`): wrap the parent leaf's
28
+ * `toResource` with {@link hideRecordedOrder}. The parent's history
29
+ * (`getVersion`, `compare`) still shows it, behind the write gate
30
+ * (`READ-009`).
31
+ *
32
+ * ## The gates
33
+ *
34
+ * - **Reads** go through the parent's read gate (`get`): a hidden parent is
35
+ * `NodeNotFound`, never `Forbidden` (`SEC-AUTH-ENUM-001`). Each member is
36
+ * read through the member leaf's own gate (`getMany`), and one the viewer
37
+ * can't read is omitted, from the page and from its total, as `EDGE-001`
38
+ * omits an edge with a hidden end.
39
+ * - **Writes** pass the parent's write gate (`authorize`), and a member must
40
+ * be one the writer can read (`NodeNotFound` otherwise).
41
+ * - A collection holds at most {@link MAX_COLLECTION_SIZE} members, so every
42
+ * read and reorder is one bounded read (`SEC-INPUT-003`).
43
+ *
44
+ * @copyright Thomas Foster
45
+ * @module pulpit/collections
46
+ * @since 0.1.0
47
+ */
48
+
49
+ import * as Arr from "effect/Array";
50
+ import * as Effect from "effect/Effect";
51
+ import * as HashMap from "effect/HashMap";
52
+ import * as Option from "effect/Option";
53
+ import * as Order from "effect/Order";
54
+ import * as Predicate from "effect/Predicate";
55
+ import * as Schema from "effect/Schema";
56
+ import { byCodePoint } from "./internal/list-order.ts";
57
+ import {
58
+ MAX_PAGE_LIMIT,
59
+ type Forbidden,
60
+ type LeafRow,
61
+ type Page,
62
+ type SubtypeOps,
63
+ type Unauthorized,
64
+ type WriteRejected,
65
+ } from "./subtype.ts";
66
+ import {
67
+ NodeNotFound,
68
+ type StaleVersion,
69
+ type VersionChainStore,
70
+ type VersionConflict,
71
+ } from "./version-chain.ts";
72
+
73
+ /**
74
+ * The most members a collection holds (`SEC-INPUT-003`): every read and
75
+ * reorder reads the whole order once, so it is bounded.
76
+ *
77
+ * @since 0.1.0
78
+ */
79
+ export const MAX_COLLECTION_SIZE = MAX_PAGE_LIMIT;
80
+
81
+ // ── Errors ────────────────────────────────────────────────────────────
82
+
83
+ /**
84
+ * The member is already in the collection: the wire's 409.
85
+ *
86
+ * @since 0.1.0
87
+ */
88
+ export class AlreadyMember extends Schema.TaggedError<
89
+ AlreadyMember,
90
+ { readonly brand: unique symbol }
91
+ >()("AlreadyMember", {
92
+ parentId: Schema.String,
93
+ memberId: Schema.String,
94
+ }) {}
95
+
96
+ /**
97
+ * The member is not in the collection: the wire's 404.
98
+ *
99
+ * @since 0.1.0
100
+ */
101
+ export class NotMember extends Schema.TaggedError<NotMember, { readonly brand: unique symbol }>()(
102
+ "NotMember",
103
+ {
104
+ parentId: Schema.String,
105
+ memberId: Schema.String,
106
+ },
107
+ ) {}
108
+
109
+ /**
110
+ * The collection already holds {@link MAX_COLLECTION_SIZE} members: the
111
+ * wire's 409.
112
+ *
113
+ * @since 0.1.0
114
+ */
115
+ export class CollectionFull extends Schema.TaggedError<
116
+ CollectionFull,
117
+ { readonly brand: unique symbol }
118
+ >()("CollectionFull", {
119
+ parentId: Schema.String,
120
+ max: Schema.Finite,
121
+ }) {}
122
+
123
+ // ── The seam ──────────────────────────────────────────────────────────
124
+
125
+ /**
126
+ * One member's place in a collection.
127
+ *
128
+ * @since 0.1.0
129
+ */
130
+ export interface CollectionEntry {
131
+ readonly memberId: string;
132
+ readonly position: number;
133
+ }
134
+
135
+ /**
136
+ * The membership table an application supplies, keyed
137
+ * `(parent_id, member_id)` with both foreign keys cascading. A store seam:
138
+ * no policy, never opens a transaction, fails only with its `E`. See the
139
+ * package README → Seams.
140
+ *
141
+ * @since 0.1.0
142
+ */
143
+ export interface CollectionStore<E, R> {
144
+ /**
145
+ * The parent's members, by position then member id, at most `limit`
146
+ * (never more than {@link MAX_COLLECTION_SIZE} + 1, so the caller can
147
+ * tell a full collection).
148
+ */
149
+ readonly entries: (
150
+ parentId: string,
151
+ limit: number,
152
+ ) => Effect.Effect<ReadonlyArray<CollectionEntry>, E, R>;
153
+ /** Replace the parent's whole order with `entries` (positions `0…n-1`), in one statement. */
154
+ readonly replace: (
155
+ parentId: string,
156
+ entries: ReadonlyArray<CollectionEntry>,
157
+ ) => Effect.Effect<void, E, R>;
158
+ }
159
+
160
+ // ── The operations ────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * How a family keeps its order: beside the chain (`mutable`, the default)
164
+ * or in a parent column, versioned with the parent (`versioned`).
165
+ *
166
+ * @since 0.1.0
167
+ */
168
+ export type CollectionMode<E, R> =
169
+ | { readonly kind: "mutable" }
170
+ | {
171
+ readonly kind: "versioned";
172
+ /**
173
+ * The parent column recording the order: the member ids, in order (a
174
+ * `jsonb` array). Keep it out of the parent's resource with
175
+ * {@link hideRecordedOrder} (`EDGE-001`).
176
+ */
177
+ readonly column: string;
178
+ /** The parent leaf's chain store, read (ungated) by `resync` for the restored order. */
179
+ readonly chain: VersionChainStore<E, R>;
180
+ };
181
+
182
+ /**
183
+ * One family's collection: the parent and member leaves' gated operations,
184
+ * the store and the mode.
185
+ *
186
+ * @since 0.1.0
187
+ */
188
+ export interface CollectionDefinition<Parent, Member, E, R> {
189
+ /** The family's name, traced as `${type}.<operation>`. */
190
+ readonly type: string;
191
+ /** The parent leaf: `get` gates a read, `authorize` a write, `update` records a versioned order. */
192
+ readonly parent: Pick<SubtypeOps<Parent, E, R>, "get" | "authorize" | "update">;
193
+ /** The member leaf's gated batch read. */
194
+ readonly members: Pick<SubtypeOps<Member, E, R>, "getMany">;
195
+ readonly store: CollectionStore<E, R>;
196
+ /** The order's model; `mutable` when omitted. */
197
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is the default model
198
+ readonly mode?: CollectionMode<E, R> | undefined;
199
+ }
200
+
201
+ /**
202
+ * One member as a read returns it: its id, its position among the members
203
+ * the viewer can see (from 0), and the member resource.
204
+ *
205
+ * @since 0.1.0
206
+ */
207
+ export interface CollectionMember<Member> {
208
+ readonly memberId: string;
209
+ readonly index: number;
210
+ readonly resource: Member;
211
+ }
212
+
213
+ type WriteFailure =
214
+ | Unauthorized
215
+ | Forbidden
216
+ | NodeNotFound
217
+ | WriteRejected
218
+ | StaleVersion
219
+ | VersionConflict;
220
+
221
+ /**
222
+ * The operations {@link defineCollection} produces.
223
+ *
224
+ * @since 0.1.0
225
+ */
226
+ export interface CollectionOps<Member, E, R> {
227
+ /**
228
+ * One page of the members the viewer can read, in order, with their
229
+ * total: the parent's read gate first (`NodeNotFound`), each member
230
+ * through its own leaf's, a hidden one omitted. The limit is clamped to
231
+ * `[1, MAX_PAGE_LIMIT]`.
232
+ */
233
+ readonly list: (
234
+ parentId: string,
235
+ request: { readonly limit: number; readonly offset: number },
236
+ ) => Effect.Effect<Page<CollectionMember<Member>>, NodeNotFound | E, R>;
237
+ /**
238
+ * Add `memberId` at the end. Fails the parent's write gate, `NodeNotFound`
239
+ * for a member the writer can't read, {@link AlreadyMember} and
240
+ * {@link CollectionFull}; on a versioned family, the parent `update`'s
241
+ * failures too.
242
+ */
243
+ readonly append: (
244
+ parentId: string,
245
+ memberId: string,
246
+ ) => Effect.Effect<void, WriteFailure | AlreadyMember | CollectionFull | E, R>;
247
+ /**
248
+ * Move `memberId` to `index` (clamped to the order's bounds), shifting the
249
+ * members between. Fails the parent's write gate and {@link NotMember}.
250
+ */
251
+ readonly move: (
252
+ parentId: string,
253
+ memberId: string,
254
+ index: number,
255
+ ) => Effect.Effect<void, WriteFailure | NotMember | E, R>;
256
+ /** Remove `memberId`. Fails the parent's write gate and {@link NotMember}. */
257
+ readonly remove: (
258
+ parentId: string,
259
+ memberId: string,
260
+ ) => Effect.Effect<void, WriteFailure | NotMember | E, R>;
261
+ /**
262
+ * A versioned family's store order replaced by the order the parent's
263
+ * head records (after the parent's `restoreVersion`), behind the parent's
264
+ * write gate, keeping only the members the writer can read (as `append`
265
+ * checks). A mutable family, or a head recording no order, leaves the
266
+ * store as it is.
267
+ */
268
+ readonly resync: (
269
+ parentId: string,
270
+ ) => Effect.Effect<void, Unauthorized | Forbidden | NodeNotFound | E, R>;
271
+ }
272
+
273
+ /**
274
+ * Wrap a parent leaf's `toResource` so the row it maps no longer carries a
275
+ * versioned collection's order `column`: the recorded order names every
276
+ * member, the ones the viewer can't read included, so it must never reach
277
+ * a reader of the parent (`EDGE-001`). The chain still records it, so
278
+ * {@link CollectionOps.resync} reads it back.
279
+ *
280
+ * @example
281
+ * ```ts
282
+ * import { deepStrictEqual } from "node:assert";
283
+ * import { hideRecordedOrder } from "@thomasfosterau/pulpit/collections";
284
+ *
285
+ * const toResource = hideRecordedOrder("memberOrder", (row) => row);
286
+ * const row = { id: "l-1", version: 2n, action: "update", title: "Ferns", memberOrder: ["i-1"] } as const;
287
+ * deepStrictEqual(toResource(row), { id: "l-1", version: 2n, action: "update", title: "Ferns" });
288
+ * ```
289
+ *
290
+ * @since 0.1.0
291
+ */
292
+ export const hideRecordedOrder =
293
+ <Resource>(
294
+ column: string,
295
+ toResource: (row: LeafRow) => Resource,
296
+ ): ((row: LeafRow) => Resource) =>
297
+ (row) => {
298
+ const { [column]: _recorded, ...rest } = row;
299
+ return toResource({ ...rest, id: row.id, version: row.version, action: row.action });
300
+ };
301
+
302
+ /** The order with positions `0…n-1`. */
303
+ const renumber = (ids: ReadonlyArray<string>): ReadonlyArray<CollectionEntry> =>
304
+ Arr.map(ids, (memberId, position) => ({ memberId, position }));
305
+
306
+ /** By position, then member id: the store's total order. */
307
+ const entryOrder: Order.Order<CollectionEntry> = Order.combine(
308
+ Order.mapInput(Order.Number, (entry: CollectionEntry) => entry.position),
309
+ Order.mapInput(byCodePoint, (entry: CollectionEntry) => entry.memberId),
310
+ );
311
+
312
+ /**
313
+ * Bind one family's collection operations. Every operation is traced as
314
+ * `${type}.<operation>`, and the writes run in the caller's transaction (a
315
+ * versioned family's store write and parent append commit together).
316
+ *
317
+ * @since 0.1.0
318
+ */
319
+ export const defineCollection = <Parent, Member, E, R>(
320
+ definition: CollectionDefinition<Parent, Member, E, R>,
321
+ ): CollectionOps<Member, E, R> => {
322
+ const { type, parent, members, store } = definition;
323
+ const mode: CollectionMode<E, R> = definition.mode ?? { kind: "mutable" };
324
+
325
+ /** The whole order, as ids (one bounded read). */
326
+ const orderOf = (parentId: string) =>
327
+ Effect.map(store.entries(parentId, MAX_COLLECTION_SIZE + 1), (entries) =>
328
+ Arr.map(Arr.sort(entries, entryOrder), (entry) => entry.memberId),
329
+ );
330
+
331
+ /** Write the new order to the store and, on a versioned family, to the parent's chain. */
332
+ const commit = (parentId: string, ids: ReadonlyArray<string>) =>
333
+ Effect.gen(function* () {
334
+ yield* store.replace(parentId, renumber(ids));
335
+ if (mode.kind === "versioned") {
336
+ yield* parent.update({ id: parentId, patch: { [mode.column]: [...ids] } });
337
+ }
338
+ });
339
+
340
+ const list = Effect.fn(`${type}.list`)(function* (
341
+ parentId: string,
342
+ request: { readonly limit: number; readonly offset: number },
343
+ ) {
344
+ // A parent the viewer can't read is missing to them (SEC-AUTH-ENUM-001).
345
+ yield* parent.get(parentId);
346
+ const ids = Arr.take(yield* orderOf(parentId), MAX_COLLECTION_SIZE);
347
+ const readable = Arr.isReadonlyArrayNonEmpty(ids)
348
+ ? yield* members.getMany(ids)
349
+ : HashMap.empty<string, Member>();
350
+ const shown = Arr.getSomes(
351
+ Arr.map(ids, (memberId) =>
352
+ Option.map(HashMap.get(readable, memberId), (resource) => ({ memberId, resource })),
353
+ ),
354
+ );
355
+ const limit = Math.max(1, Math.min(Math.trunc(request.limit), MAX_PAGE_LIMIT));
356
+ const offset = Math.max(0, Math.trunc(request.offset));
357
+ return {
358
+ rows: Arr.map(shown.slice(offset, offset + limit), (member, at) => ({
359
+ ...member,
360
+ index: offset + at,
361
+ })),
362
+ total: shown.length,
363
+ };
364
+ });
365
+
366
+ const append = Effect.fn(`${type}.append`)(function* (parentId: string, memberId: string) {
367
+ yield* parent.authorize(parentId);
368
+ const readable = yield* members.getMany([memberId]);
369
+ if (!HashMap.has(readable, memberId)) {
370
+ return yield* new NodeNotFound({ type, id: memberId });
371
+ }
372
+ const ids = yield* orderOf(parentId);
373
+ if (ids.includes(memberId)) return yield* new AlreadyMember({ parentId, memberId });
374
+ if (ids.length >= MAX_COLLECTION_SIZE) {
375
+ return yield* new CollectionFull({ parentId, max: MAX_COLLECTION_SIZE });
376
+ }
377
+ yield* commit(parentId, [...ids, memberId]);
378
+ });
379
+
380
+ const move = Effect.fn(`${type}.move`)(function* (
381
+ parentId: string,
382
+ memberId: string,
383
+ index: number,
384
+ ) {
385
+ yield* parent.authorize(parentId);
386
+ const ids = yield* orderOf(parentId);
387
+ if (!ids.includes(memberId)) return yield* new NotMember({ parentId, memberId });
388
+ const rest = Arr.filter(ids, (id) => id !== memberId);
389
+ const at = Math.max(0, Math.min(Number.isFinite(index) ? Math.trunc(index) : 0, rest.length));
390
+ yield* commit(parentId, [...rest.slice(0, at), memberId, ...rest.slice(at)]);
391
+ });
392
+
393
+ const remove = Effect.fn(`${type}.remove`)(function* (parentId: string, memberId: string) {
394
+ yield* parent.authorize(parentId);
395
+ const ids = yield* orderOf(parentId);
396
+ if (!ids.includes(memberId)) return yield* new NotMember({ parentId, memberId });
397
+ yield* commit(
398
+ parentId,
399
+ Arr.filter(ids, (id) => id !== memberId),
400
+ );
401
+ });
402
+
403
+ const resync = Effect.fn(`${type}.resync`)(function* (parentId: string) {
404
+ yield* parent.authorize(parentId);
405
+ if (mode.kind !== "versioned") return;
406
+ const head = yield* mode.chain.head(parentId);
407
+ if (Option.isNone(head)) return;
408
+ const snapshot = yield* mode.chain.snapshot(parentId, head.value.version);
409
+ const recorded = Option.flatMap(snapshot, (version) => {
410
+ const value = version.state[mode.column] ?? null;
411
+ return Arr.isArray<unknown>(value) ? Option.some(value) : Option.none();
412
+ });
413
+ if (Option.isNone(recorded)) return;
414
+ const ids = Arr.take(
415
+ Arr.dedupe(Arr.filter(recorded.value, Predicate.isString)),
416
+ MAX_COLLECTION_SIZE,
417
+ );
418
+ // Gate parity with `append`: the column is writable through the parent's
419
+ // own `update`, so keep only the members the writer can read.
420
+ const readable = Arr.isReadonlyArrayNonEmpty(ids)
421
+ ? yield* members.getMany(ids)
422
+ : HashMap.empty<string, Member>();
423
+ yield* store.replace(parentId, renumber(Arr.filter(ids, (id) => HashMap.has(readable, id))));
424
+ });
425
+
426
+ return { list, append, move, remove, resync };
427
+ };