@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.
- package/LICENSE +21 -0
- package/README.md +1272 -2
- 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 +145 -3
- 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
|
@@ -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
|
+
};
|