@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/subtype.js
ADDED
|
@@ -0,0 +1,1300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **The subtype operation factory** — a leaf's CRUD, version history and
|
|
3
|
+
* list, gated by its access kind, over the version chain and a
|
|
4
|
+
* current-version view.
|
|
5
|
+
*
|
|
6
|
+
* The behavioural spec is gardeneria's `apps/api/src/operations/subtype.ts`
|
|
7
|
+
* (`defineNodeSubtypeE`, `LeafAccess`, the gates `requireWriterE` /
|
|
8
|
+
* `requireCreatorE` / `stampOwnerE` / `ownedListFilterE`), its
|
|
9
|
+
* `packages/database/src/guards.ts` (`canViewOwnedE`, `ownedReadScopeE`) and
|
|
10
|
+
* website's `apps/api/src/operations/to-resource.ts`
|
|
11
|
+
* (`loadListVisibilityFilters`, the two-state list predicate). A `#NNN` here
|
|
12
|
+
* names a gardeneria issue.
|
|
13
|
+
*
|
|
14
|
+
* ## Access kinds
|
|
15
|
+
*
|
|
16
|
+
* - `reference` — corpus material. Create / update / delete / restore gate on
|
|
17
|
+
* the corpus-write rung; when the view has a `visibility` column, reads
|
|
18
|
+
* show only `public` rows below the view-private rung, apart from the
|
|
19
|
+
* soft-delete axis, and the `followers` rows whose declared
|
|
20
|
+
* `followersOfColumn` names the viewer or an account they follow
|
|
21
|
+
* (pulpit#92). A
|
|
22
|
+
* write or history read to an existing row also needs the row to be one
|
|
23
|
+
* `get` would serve the caller (pulpit#101).
|
|
24
|
+
* - `owned` — a member's own row. Create gates on any signed-in account and
|
|
25
|
+
* stamps the owner; every other write loads the owner and gates on
|
|
26
|
+
* owner-or-moderator (a missing owner is `NotFound`, never an open gate);
|
|
27
|
+
* `get` applies {@link canViewOwned} to the row; `list` applies the same
|
|
28
|
+
* facts as a {@link ListFilter} ({@link ownedListFilter}), so the two can
|
|
29
|
+
* never disagree.
|
|
30
|
+
*
|
|
31
|
+
* ## Invariants
|
|
32
|
+
*
|
|
33
|
+
* - A read (`get` / `getMany` / `list`) answers a hidden row as
|
|
34
|
+
* {@link NodeNotFound} or omits it, never {@link Forbidden}
|
|
35
|
+
* (`SEC-AUTH-ENUM-001`): a 403 would confirm the row exists. The write and
|
|
36
|
+
* history gate ({@link requireWriter}) does answer `Forbidden` for an
|
|
37
|
+
* existing owned row the caller doesn't own, whatever its visibility, so
|
|
38
|
+
* it deliberately confirms existence to a signed-in caller (pulpit#24;
|
|
39
|
+
* SECURITY.md → "Existence disclosure by the write gates"). On a reference
|
|
40
|
+
* leaf, a corpus writer's write or history read to a row `get` hides from
|
|
41
|
+
* them fails `NodeNotFound`, as `get` does, before the chain is reached
|
|
42
|
+
* (pulpit#101, `PUB-009`).
|
|
43
|
+
* - A list's order ({@link ListOrder}) only places the rows its filters
|
|
44
|
+
* chose: it never changes membership or the total, and a column that is
|
|
45
|
+
* not sortable (outside the leaf's `sortable` allow-list or, without one,
|
|
46
|
+
* the view's declared `columns`) fails {@link InvalidListOrder} before
|
|
47
|
+
* anything is read, never reaching the store (pulpit#112).
|
|
48
|
+
* - Soft-deleted rows are visible only to the view-deleted rung, and a list
|
|
49
|
+
* includes them only when the caller also asked (`includeDeleted`).
|
|
50
|
+
* - A declaration naming columns the view lacks fails closed — nothing lists,
|
|
51
|
+
* and `get` / `getMany` show nothing.
|
|
52
|
+
* - The follower seam, when a runtime does not provide one, answers "nobody
|
|
53
|
+
* follows anybody" — a `followers` row is then its owner's and the rung's
|
|
54
|
+
* alone. Fail closed.
|
|
55
|
+
*
|
|
56
|
+
* ## Lifecycle hooks
|
|
57
|
+
*
|
|
58
|
+
* A leaf's write side effects compose as {@link LifecycleHooks} (pulpit#45):
|
|
59
|
+
* before hooks run inside each write, after its gate and before the append,
|
|
60
|
+
* and may transform the patch or refuse the write ({@link WriteRejected});
|
|
61
|
+
* after hooks run in {@link subtypeHandlers} once the write's transaction
|
|
62
|
+
* has returned, and never fail it. The cache write-through is the first
|
|
63
|
+
* after hook ({@link cacheWriteThrough}). Events fire on explicit writes
|
|
64
|
+
* only: a future `publishedAt` arriving emits nothing (`./publication`'s
|
|
65
|
+
* `duePublications` drives an application's sweep instead).
|
|
66
|
+
*
|
|
67
|
+
* ## Seams
|
|
68
|
+
*
|
|
69
|
+
* Three things the kernel does not own, each an explicit parameter rather
|
|
70
|
+
* than an import (see the package README → Seams for each one's expected
|
|
71
|
+
* shape and which package, if any, fills it):
|
|
72
|
+
*
|
|
73
|
+
* - the **viewer** and the **role axes** ({@link Viewer}, {@link AccessPolicy})
|
|
74
|
+
* and the **follower** answer — effect-auth's session context, role ladder
|
|
75
|
+
* and `IsFollower`. Its read scope (`OwnedAccess` / `Guard` / `Ladder` /
|
|
76
|
+
* `Cacheability`) is on effect-auth's `trunk` but unpublished (release PR
|
|
77
|
+
* effect-auth#177);
|
|
78
|
+
* - the **current-version view** ({@link LeafStore}) — a Drizzle store over
|
|
79
|
+
* effect-drizzle-ext's `makeCurrentVersionView`;
|
|
80
|
+
* - the **handlers config** the factory's operations plug into
|
|
81
|
+
* ({@link subtypeHandlers}) — the record a JSON:API server's resource
|
|
82
|
+
* handlers are expected to accept (effect-jsonapi's `ResourceHandlers`,
|
|
83
|
+
* exported since 0.15.0 but not yet wired here; pulpit#5), with the
|
|
84
|
+
* write-through to the resource cache the application fills.
|
|
85
|
+
*
|
|
86
|
+
* @copyright Thomas Foster
|
|
87
|
+
* @module pulpit/subtype
|
|
88
|
+
* @since 0.1.0
|
|
89
|
+
*/
|
|
90
|
+
import * as Array from "effect/Array";
|
|
91
|
+
import * as Arr from "effect/Array";
|
|
92
|
+
import * as Cause from "effect/Cause";
|
|
93
|
+
import * as Clock from "effect/Clock";
|
|
94
|
+
import * as DateTime from "effect/DateTime";
|
|
95
|
+
import * as Effect from "effect/Effect";
|
|
96
|
+
import * as Exit from "effect/Exit";
|
|
97
|
+
import * as HashMap from "effect/HashMap";
|
|
98
|
+
import * as HashSet from "effect/HashSet";
|
|
99
|
+
import * as Match from "effect/Match";
|
|
100
|
+
import * as Option from "effect/Option";
|
|
101
|
+
import * as Predicate from "effect/Predicate";
|
|
102
|
+
import * as Record from "effect/Record";
|
|
103
|
+
import * as Ref from "effect/Ref";
|
|
104
|
+
import * as Str from "effect/String";
|
|
105
|
+
import * as Schema from "effect/Schema";
|
|
106
|
+
import { ResourceHandlers } from "@thomasfosterau/effect-jsonapi";
|
|
107
|
+
import { always, and, anyOf, eq, isIn, locale as localeIn, LOCALE_COLUMN, ne, never, or, } from "./filter.js";
|
|
108
|
+
import { byCodePoint } from "./internal/list-order.js";
|
|
109
|
+
import { canViewOwnedRow, declaresColumns, ownedFacts } from "./internal/owned-columns.js";
|
|
110
|
+
import { canViewReferenceRow, referenceListFilter } from "./internal/reference-columns.js";
|
|
111
|
+
import { defineVersionChain, NodeNotFound, MAX_HISTORY_LIMIT, VersionNotFound as VersionNotFoundError, } from "./version-chain.js";
|
|
112
|
+
/** The write clock: the current instant, as the `Date` a fact stores. */
|
|
113
|
+
const nowDate = Effect.map(Clock.currentTimeMillis, (millis) => DateTime.toDate(DateTime.makeUnsafe(millis)));
|
|
114
|
+
/**
|
|
115
|
+
* The fail-closed follower seam: nobody follows anybody. `nobody` is the
|
|
116
|
+
* matches-nothing value of the `in` node (SQL: `(select null::uuid where false)`).
|
|
117
|
+
*
|
|
118
|
+
* @since 0.1.0
|
|
119
|
+
*/
|
|
120
|
+
export const nobodyFollows = (nobody) => ({
|
|
121
|
+
isFollower: () => Effect.succeed(false),
|
|
122
|
+
following: () => nobody,
|
|
123
|
+
});
|
|
124
|
+
// ── Errors ────────────────────────────────────────────────────────────
|
|
125
|
+
/**
|
|
126
|
+
* No signed-in viewer where one is required: the wire's 401.
|
|
127
|
+
*
|
|
128
|
+
* @since 0.1.0
|
|
129
|
+
*/
|
|
130
|
+
export class Unauthorized extends Schema.TaggedError()("Unauthorized", {}) {
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* A signed-in viewer below the rung a write needs: the wire's 403. Never used to hide a row.
|
|
134
|
+
*
|
|
135
|
+
* @since 0.1.0
|
|
136
|
+
*/
|
|
137
|
+
export class Forbidden extends Schema.TaggedError()("Forbidden", {}) {
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* A list order the leaf does not sort by: a key whose column is not sortable
|
|
141
|
+
* (outside the leaf's `sortable` allow-list or, without one, outside the
|
|
142
|
+
* view's declared `LeafStore.columns`), a direction other than `asc` /
|
|
143
|
+
* `desc`, or input not shaped as an order at all (an `orderBy` that is not
|
|
144
|
+
* an array, a key that is not an object, a `column` that is not a string).
|
|
145
|
+
* The wire's 400. The order is checked before the viewer or anything else is
|
|
146
|
+
* read, so it says nothing about any row, but **it does say which columns
|
|
147
|
+
* are sortable**: without a `sortable` allow-list, that a name is (or is
|
|
148
|
+
* not) one of the view's columns. `column` is the caller's own key, never
|
|
149
|
+
* passed to the store, or `""` when the key carries no string column
|
|
150
|
+
* (pulpit#112).
|
|
151
|
+
*
|
|
152
|
+
* @since 0.1.0
|
|
153
|
+
*/
|
|
154
|
+
export class InvalidListOrder extends Schema.TaggedError()("InvalidListOrder", {
|
|
155
|
+
type: Schema.String,
|
|
156
|
+
column: Schema.String,
|
|
157
|
+
}) {
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* A leaf declared against the rules: a `sortable` entry that is not one of
|
|
161
|
+
* its view's `LeafStore.columns`. Thrown by {@link defineSubtype} when the
|
|
162
|
+
* leaf is declared, never an Effect failure, like `./edges`'
|
|
163
|
+
* `EdgeDeclarationError`: a misspelt allow-list entry must stop its module
|
|
164
|
+
* loading rather than silently leave a column unsortable (pulpit#112).
|
|
165
|
+
*
|
|
166
|
+
* @since 0.1.0
|
|
167
|
+
*/
|
|
168
|
+
export class SubtypeDeclarationError extends Schema.TaggedError()("SubtypeDeclarationError", { message: Schema.String }) {
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Resolve a viewer's {@link ReadScope}.
|
|
172
|
+
*
|
|
173
|
+
* @since 0.1.0
|
|
174
|
+
*/
|
|
175
|
+
export const readScopeFor = (viewer, policy, followers) => Option.match(viewer, {
|
|
176
|
+
onNone: () => ({ kind: "public" }),
|
|
177
|
+
onSome: (account) => policy.canViewPrivate(account)
|
|
178
|
+
? { kind: "all" }
|
|
179
|
+
: { kind: "member", userId: account.id, following: followers.following(account.id) },
|
|
180
|
+
});
|
|
181
|
+
/**
|
|
182
|
+
* The owned-material read predicate — the single interpretation of the
|
|
183
|
+
* three-state visibility axis plus the orthogonal `draft` axis (#1118):
|
|
184
|
+
*
|
|
185
|
+
* | visibility | who may view |
|
|
186
|
+
* | ----------- | -------------------------------------------------------------- |
|
|
187
|
+
* | `public` | everyone, anonymous included |
|
|
188
|
+
* | `followers` | the owner; the owner's followers; the view-private rung |
|
|
189
|
+
* | `private` | the owner; the view-private rung |
|
|
190
|
+
*
|
|
191
|
+
* `draft: true` narrows whatever `visibility` allows down to the owner and
|
|
192
|
+
* the rung — a `public` draft is exactly as hidden as a `private` one — and
|
|
193
|
+
* so does `unapproved: true` on a moderated leaf (a `pending` or `rejected`
|
|
194
|
+
* row reads only to its author and the rung, pulpit#154). Never
|
|
195
|
+
* fails; callers turn `false` into {@link NodeNotFound}.
|
|
196
|
+
*
|
|
197
|
+
* `defineSubtype`'s `get` / `getMany` and `./edges`' `canViewEndpoint`
|
|
198
|
+
* apply the same predicate to the row they read. There, an owner column
|
|
199
|
+
* holding no string (a `NULL`, an absent key) reads as no owner: no viewer
|
|
200
|
+
* is its owner and the follower seam is never asked, so the row shows only
|
|
201
|
+
* when `public`, not a draft and not unapproved, or to the rung, as {@link ownedListFilter}
|
|
202
|
+
* lists it (`SEC-AUTH-ENUM-001`).
|
|
203
|
+
*
|
|
204
|
+
* @since 0.1.0
|
|
205
|
+
*/
|
|
206
|
+
export const canViewOwned = (viewer, policy, followers, row) => canViewOwnedRow(viewer, policy, followers, { ...row, ownerId: Option.some(row.ownerId) });
|
|
207
|
+
/**
|
|
208
|
+
* The owned-leaf access declaration with the conventional factory-built column names.
|
|
209
|
+
*
|
|
210
|
+
* @since 0.1.0
|
|
211
|
+
*/
|
|
212
|
+
export const ownedAccess = (options = {}) => ({
|
|
213
|
+
kind: "owned",
|
|
214
|
+
ownerColumn: "ownerId",
|
|
215
|
+
visibilityColumn: "visibility",
|
|
216
|
+
draftColumn: options.draft === true ? Option.some("draft") : Option.none(),
|
|
217
|
+
moderationColumn: options.moderation === true ? Option.some("moderationState") : Option.none(),
|
|
218
|
+
});
|
|
219
|
+
/**
|
|
220
|
+
* The owned-leaf LIST filter — {@link ReadScope} rendered over the leaf's
|
|
221
|
+
* declared view columns. When the leaf carries a draft column, every
|
|
222
|
+
* non-owner arm additionally requires `draft = false`, and when it carries a
|
|
223
|
+
* moderation column, `moderation = 'approved'`; the owner's own arm never
|
|
224
|
+
* does, so a member lists their own drafts and unapproved rows. Columns the view lacks
|
|
225
|
+
* fail closed.
|
|
226
|
+
*
|
|
227
|
+
* @since 0.1.0
|
|
228
|
+
*/
|
|
229
|
+
export const ownedListFilter = (access, viewColumns, scope) => {
|
|
230
|
+
if (!declaresColumns(access, viewColumns))
|
|
231
|
+
return never;
|
|
232
|
+
const published = (filter) => and(filter, ...Option.toArray(Option.map(access.draftColumn, (draft) => eq(draft, false))), ...Option.toArray(Option.map(access.moderationColumn, (moderation) => eq(moderation, "approved"))));
|
|
233
|
+
return Match.value(scope).pipe(Match.discriminatorsExhaustive("kind")({
|
|
234
|
+
all: () => always,
|
|
235
|
+
public: () => published(eq(access.visibilityColumn, "public")),
|
|
236
|
+
member: (member) => or(published(eq(access.visibilityColumn, "public")), eq(access.ownerColumn, member.userId), published(and(eq(access.visibilityColumn, "followers"), isIn(access.ownerColumn, member.following)))),
|
|
237
|
+
}));
|
|
238
|
+
};
|
|
239
|
+
/**
|
|
240
|
+
* The soft-delete arm of every list: tombstones only when the caller asked
|
|
241
|
+
* AND holds the view-deleted rung — asking without the rung changes nothing.
|
|
242
|
+
*
|
|
243
|
+
* @since 0.1.0
|
|
244
|
+
*/
|
|
245
|
+
export const softDeleteFilter = (viewer, policy, includeDeleted) => includeDeleted && Option.exists(viewer, policy.canViewDeleted) ? always : ne("action", "delete");
|
|
246
|
+
/**
|
|
247
|
+
* The reference-leaf privacy arm (`PUB-009`): `visibility = 'public'` unless
|
|
248
|
+
* the viewer holds the view-private rung, or the view has no visibility
|
|
249
|
+
* column (an Entity leaf rides no such axis). Given a followers arm (the
|
|
250
|
+
* leaf's `access`, declaring `followersOfColumn` over a view that carries
|
|
251
|
+
* it, and the follower seam), a signed-in viewer also lists the `followers`
|
|
252
|
+
* rows whose column names them or an account they follow (pulpit#92):
|
|
253
|
+
* `visibility = 'followers' AND (<followersOfColumn> = <viewer> OR
|
|
254
|
+
* <followersOfColumn> IN (<following>))`. Without one (the default,
|
|
255
|
+
* `Option.none()`), a `followers` row is the view-private rung's alone.
|
|
256
|
+
* Pass `Option.some({ access, followers })` whenever the leaf declares
|
|
257
|
+
* `followersOfColumn`: `defineSubtype`'s `get` / `getMany` read the
|
|
258
|
+
* declared column, so the three-argument form agrees with them only for a
|
|
259
|
+
* leaf without it (for one with it, `get` serves `followers` rows this
|
|
260
|
+
* hides).
|
|
261
|
+
*
|
|
262
|
+
* @since 0.1.0
|
|
263
|
+
*/
|
|
264
|
+
export const referencePrivacyFilter = (viewer, policy, viewColumns, followersArm = Option.none()) => Option.match(followersArm, {
|
|
265
|
+
onNone: () => referenceListFilter({}, viewColumns, publicOrAll(viewer, policy)),
|
|
266
|
+
onSome: (arm) => referenceListFilter(arm.access, viewColumns, readScopeFor(viewer, policy, arm.followers)),
|
|
267
|
+
});
|
|
268
|
+
/** A read scope without the follower set: a member reads as the anonymous do. */
|
|
269
|
+
const publicOrAll = (viewer, policy) => Option.exists(viewer, policy.canViewPrivate) ? { kind: "all" } : { kind: "public" };
|
|
270
|
+
// ── The write gates ───────────────────────────────────────────────────
|
|
271
|
+
const signedIn = (viewer) => Effect.fromOption(viewer, () => new Unauthorized());
|
|
272
|
+
const atRung = (viewer, admits) => Effect.filterOrFail(signedIn(viewer), admits, () => new Forbidden());
|
|
273
|
+
/**
|
|
274
|
+
* The gate for a CREATE: reference data → the corpus-write rung; owned → any signed-in account.
|
|
275
|
+
*
|
|
276
|
+
* @since 0.1.0
|
|
277
|
+
*/
|
|
278
|
+
export const requireCreator = (access, viewer, policy) => access.kind === "reference" ? atRung(viewer, policy.canWriteCorpus) : signedIn(viewer);
|
|
279
|
+
/**
|
|
280
|
+
* The gate for a write to an EXISTING row: reference data → the corpus-write
|
|
281
|
+
* rung; owned material → the row's owner or the moderate rung. A missing
|
|
282
|
+
* owner on an owned leaf is {@link NodeNotFound}, never an open gate.
|
|
283
|
+
*
|
|
284
|
+
* On an owned leaf it never reads the row's visibility, draft or delete state:
|
|
285
|
+
* a signed-in caller who is neither the owner nor at the moderate rung gets
|
|
286
|
+
* {@link Forbidden} for any row with an owner record,
|
|
287
|
+
* hidden or not, so the gate deliberately confirms existence (pulpit#24, an
|
|
288
|
+
* exception to `SEC-AUTH-ENUM-001`; SECURITY.md → "Existence disclosure by the
|
|
289
|
+
* write gates"). An anonymous caller gets `Unauthorized` for every id.
|
|
290
|
+
*
|
|
291
|
+
* On a reference leaf this is the rung check alone: it reads no row, so it
|
|
292
|
+
* answers `Unauthorized` / `Forbidden` alike for every id. `defineSubtype`
|
|
293
|
+
* follows it with the read rung the row demands (pulpit#101): a row its `get`
|
|
294
|
+
* hides from the caller fails {@link NodeNotFound}, as `get` does
|
|
295
|
+
* (`PUB-009`, `SEC-AUTH-ENUM-001`). A caller using this gate directly on a
|
|
296
|
+
* reference row must apply that check too.
|
|
297
|
+
*
|
|
298
|
+
* @since 0.1.0
|
|
299
|
+
*/
|
|
300
|
+
export const requireWriter = (access, viewer, policy, row, ownerOf) => access.kind === "reference"
|
|
301
|
+
? atRung(viewer, policy.canWriteCorpus)
|
|
302
|
+
: Effect.gen(function* () {
|
|
303
|
+
const account = yield* signedIn(viewer);
|
|
304
|
+
const owner = yield* ownerOf;
|
|
305
|
+
if (Option.isNone(owner))
|
|
306
|
+
return yield* new NodeNotFound(row);
|
|
307
|
+
if (account.id === owner.value || policy.canModerate(account))
|
|
308
|
+
return account;
|
|
309
|
+
return yield* new Forbidden();
|
|
310
|
+
});
|
|
311
|
+
/**
|
|
312
|
+
* The resource-object `meta` a {@link SubtypeDefinition.toResource} mapper
|
|
313
|
+
* stamps: the row's head version as a decimal string (`bigint` has no JSON
|
|
314
|
+
* form). It reaches only a resource the read already serves the viewer, so it
|
|
315
|
+
* discloses nothing about a hidden row (`SEC-AUTH-ENUM-001`); what it does
|
|
316
|
+
* disclose about a shown one is how many versions its chain holds.
|
|
317
|
+
*
|
|
318
|
+
* @example
|
|
319
|
+
* ```ts
|
|
320
|
+
* import { strictEqual } from "node:assert";
|
|
321
|
+
* import { versionMeta } from "@thomasfosterau/pulpit/subtype";
|
|
322
|
+
*
|
|
323
|
+
* const row = { id: "post-1", version: 3n, action: "update" as const, title: "Hello" };
|
|
324
|
+
* const resource = { type: "posts", id: row.id, meta: versionMeta(row) };
|
|
325
|
+
* strictEqual(resource.meta.version, "3");
|
|
326
|
+
* ```
|
|
327
|
+
*
|
|
328
|
+
* @since 0.1.0
|
|
329
|
+
*/
|
|
330
|
+
export const versionMeta = (row) => ({
|
|
331
|
+
version: String(row.version),
|
|
332
|
+
});
|
|
333
|
+
// A version is a non-negative integer with no leading zero: anything else in
|
|
334
|
+
// `meta.version` is not one this kernel stamped.
|
|
335
|
+
const StampedVersion = Schema.Struct({
|
|
336
|
+
meta: Schema.Struct({
|
|
337
|
+
version: Schema.String.check(Schema.isPattern(/^(?:0|[1-9][0-9]*)$/, { description: "a head version in decimal" })),
|
|
338
|
+
}),
|
|
339
|
+
});
|
|
340
|
+
/**
|
|
341
|
+
* A single-resource response's `ETag` header value, read from the
|
|
342
|
+
* resource's `meta.version` ({@link versionMeta}): a strong tag (`"3"`)
|
|
343
|
+
* whose value is the head version, so the `If-Match` a client echoes names
|
|
344
|
+
* the version its write expects ({@link UpdateRequest.expectedVersion}; the
|
|
345
|
+
* header's mapping onto it is effect-jsonapi's, which 0.17.0 lacks). None when the value carries no
|
|
346
|
+
* decimal `meta.version`; a collection response takes none, having no single
|
|
347
|
+
* version to tag. Never fails.
|
|
348
|
+
*
|
|
349
|
+
* @example
|
|
350
|
+
* ```ts
|
|
351
|
+
* import { strictEqual } from "node:assert";
|
|
352
|
+
* import { Option } from "effect";
|
|
353
|
+
* import { versionEtag } from "@thomasfosterau/pulpit/subtype";
|
|
354
|
+
*
|
|
355
|
+
* const read = { type: "posts", id: "post-1", meta: { version: "3" } };
|
|
356
|
+
* strictEqual(Option.getOrElse(versionEtag(read), () => "none"), '"3"');
|
|
357
|
+
* const unstamped = { type: "posts", id: "post-1", meta: {} };
|
|
358
|
+
* strictEqual(Option.isNone(versionEtag(unstamped)), true);
|
|
359
|
+
* ```
|
|
360
|
+
*
|
|
361
|
+
* @since 0.1.0
|
|
362
|
+
*/
|
|
363
|
+
export const versionEtag = (resource) => Option.map(Schema.decodeUnknownOption(StampedVersion)(resource), ({ meta }) => `"${meta.version}"`);
|
|
364
|
+
/**
|
|
365
|
+
* The hard cap on a page (`SEC-INPUT-003`): no request drives an unbounded `LIMIT`.
|
|
366
|
+
*
|
|
367
|
+
* @since 0.1.0
|
|
368
|
+
*/
|
|
369
|
+
export const MAX_PAGE_LIMIT = 1000;
|
|
370
|
+
/**
|
|
371
|
+
* A publication operation on a node with no live version (`discardDraft`
|
|
372
|
+
* with nothing published): the wire's 409.
|
|
373
|
+
*
|
|
374
|
+
* @since 0.1.0
|
|
375
|
+
*/
|
|
376
|
+
export class NotPublished extends Schema.TaggedError()("NotPublished", {
|
|
377
|
+
type: Schema.String,
|
|
378
|
+
id: Schema.String,
|
|
379
|
+
}) {
|
|
380
|
+
}
|
|
381
|
+
// ── The lifecycle hook seam ───────────────────────────────────────────
|
|
382
|
+
/**
|
|
383
|
+
* A before hook's refusal: the write it was shown does not happen, and the
|
|
384
|
+
* caller's transaction rolls back with it. `reason` is the hook's own,
|
|
385
|
+
* for the wire's 422 (or whatever the application maps it to).
|
|
386
|
+
*
|
|
387
|
+
* @since 0.1.0
|
|
388
|
+
*/
|
|
389
|
+
export class WriteRejected extends Schema.TaggedError()("WriteRejected", {
|
|
390
|
+
type: Schema.String,
|
|
391
|
+
id: Schema.String,
|
|
392
|
+
action: Schema.String,
|
|
393
|
+
reason: Schema.String,
|
|
394
|
+
}) {
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* No hooks: the default for a leaf that declares none.
|
|
398
|
+
*
|
|
399
|
+
* @since 0.1.0
|
|
400
|
+
*/
|
|
401
|
+
export const noHooks = () => ({ before: [], after: [] });
|
|
402
|
+
/**
|
|
403
|
+
* Concatenate hook sets in order: `a`'s before hooks run before `b`'s, and
|
|
404
|
+
* likewise after. The first set's `afterFailure` that is given wins.
|
|
405
|
+
*
|
|
406
|
+
* @example
|
|
407
|
+
* ```ts
|
|
408
|
+
* import { strictEqual } from "node:assert";
|
|
409
|
+
* import * as Effect from "effect/Effect";
|
|
410
|
+
* import { composeHooks, noHooks, type BeforeWriteHook } from "@thomasfosterau/pulpit/subtype";
|
|
411
|
+
*
|
|
412
|
+
* const audit: BeforeWriteHook<never, never> = {
|
|
413
|
+
* name: "audit",
|
|
414
|
+
* run: (event) => Effect.succeed(event.patch),
|
|
415
|
+
* };
|
|
416
|
+
* const hooks = composeHooks({ before: [audit], after: [] }, noHooks());
|
|
417
|
+
* strictEqual(hooks.before[0]?.name, "audit");
|
|
418
|
+
* ```
|
|
419
|
+
*
|
|
420
|
+
* @since 0.1.0
|
|
421
|
+
*/
|
|
422
|
+
export const composeHooks = (...sets) => {
|
|
423
|
+
const afterFailure = Array.findFirst(sets, (set) => set.afterFailure !== undefined);
|
|
424
|
+
return {
|
|
425
|
+
before: Array.flatMap(sets, (set) => set.before),
|
|
426
|
+
after: Array.flatMap(sets, (set) => set.after),
|
|
427
|
+
afterFailure: Option.getOrUndefined(Option.flatMapNullishOr(afterFailure, (set) => set.afterFailure)),
|
|
428
|
+
};
|
|
429
|
+
};
|
|
430
|
+
/** Run the before hooks in order, each shown the patch the previous one returned. */
|
|
431
|
+
const runBefore = (hooks, event) => Effect.reduce(hooks, () => event.patch, (patch, hook) => hook.run({ ...event, patch }).pipe(Effect.withSpan(`${event.type}.before.${hook.name}`)));
|
|
432
|
+
/**
|
|
433
|
+
* Run the before hooks' locale passes in order, each shown the previous
|
|
434
|
+
* one's locale patch. A hook with no `locales` pass refuses a write that
|
|
435
|
+
* carries any locale change (fail closed, pulpit#56) and passes `{}` on.
|
|
436
|
+
*/
|
|
437
|
+
const runBeforeLocales = (hooks, event) => Effect.reduce(hooks, () => event.locales ?? {}, (locales, hook) => hook.locales === undefined
|
|
438
|
+
? Array.isReadonlyArrayNonEmpty(writtenKeys(locales))
|
|
439
|
+
? Effect.fail(new WriteRejected({
|
|
440
|
+
type: event.type,
|
|
441
|
+
id: event.id,
|
|
442
|
+
action: event.action,
|
|
443
|
+
reason: `before hook '${hook.name}' does not handle a locale patch`,
|
|
444
|
+
}))
|
|
445
|
+
: Effect.succeed(locales)
|
|
446
|
+
: hook
|
|
447
|
+
.locales({ ...event, locales })
|
|
448
|
+
.pipe(Effect.withSpan(`${event.type}.before.${hook.name}.locales`)));
|
|
449
|
+
/** Run the after hooks in order, each failure handed to the policy, never to the write. */
|
|
450
|
+
const runAfter = (hooks, event) => Effect.forEach(hooks.after, (hook) => hook.run(event).pipe(Effect.withSpan(`${event.type}.after.${hook.name}`), Effect.catchCause((cause) => hooks.afterFailure === "defect"
|
|
451
|
+
? Effect.die(Cause.squash(cause))
|
|
452
|
+
: Effect.logError(`${event.type}.after.${hook.name} failed`, cause))), { concurrency: 1, discard: true });
|
|
453
|
+
// ── The factory ───────────────────────────────────────────────────────
|
|
454
|
+
/**
|
|
455
|
+
* A key's column when it is an object with a string `column`, else `""`.
|
|
456
|
+
* Typed as the request types it, but read as a caller may really send it.
|
|
457
|
+
*/
|
|
458
|
+
const columnOf = (key) => Predicate.isObject(key) && Predicate.isString(key.column) ? key.column : "";
|
|
459
|
+
/**
|
|
460
|
+
* A list request's order as the store receives it: an array whose every key
|
|
461
|
+
* is an object with a sortable string `column` and a direction `asc` or
|
|
462
|
+
* `desc`, or {@link InvalidListOrder} for the first key that isn't (or for an
|
|
463
|
+
* `orderBy` that is no array), so no unsortable name reaches SQL and no
|
|
464
|
+
* out-of-type input dies. A repeated column is dropped (its first key
|
|
465
|
+
* already ordered every tie it could), which bounds the keys by the view's
|
|
466
|
+
* columns; each key is rebuilt, so nothing but `column` and `direction`
|
|
467
|
+
* reaches the store.
|
|
468
|
+
*/
|
|
469
|
+
const checkedOrder = (type, isSortable, keys) => {
|
|
470
|
+
// The request's types are not checked at runtime: a decoded query or an
|
|
471
|
+
// untyped caller can send any value here, so every guard reads it as such.
|
|
472
|
+
const isKey = (key) => Predicate.isObject(key) &&
|
|
473
|
+
Predicate.isString(key.column) &&
|
|
474
|
+
isSortable(key.column) &&
|
|
475
|
+
(key.direction === "asc" || key.direction === "desc");
|
|
476
|
+
// A boolean, not a guard: narrowing a typed array to `Array<unknown>` would lose its keys' type.
|
|
477
|
+
const isList = (value) => Array.isArray(value);
|
|
478
|
+
if (!isList(keys))
|
|
479
|
+
return Effect.fail(new InvalidListOrder({ type, column: columnOf(keys) }));
|
|
480
|
+
return Option.match(Array.findFirst(keys, (key) => !isKey(key)), {
|
|
481
|
+
onNone: () => Effect.succeed(Array.map(Array.dedupeWith(keys, (self, that) => self.column === that.column), ({ column, direction }) => ({ column, direction }))),
|
|
482
|
+
onSome: (key) => Effect.fail(new InvalidListOrder({ type, column: columnOf(key) })),
|
|
483
|
+
});
|
|
484
|
+
};
|
|
485
|
+
/**
|
|
486
|
+
* The leaf's sortable-column test, its `sortable` allow-list checked against
|
|
487
|
+
* the view's columns once, when the leaf is declared: an entry outside them
|
|
488
|
+
* throws {@link SubtypeDeclarationError}. Without an allow-list, every
|
|
489
|
+
* declared view column is sortable.
|
|
490
|
+
*/
|
|
491
|
+
const sortableColumns = (type, columns,
|
|
492
|
+
// oxlint-disable-next-line effect/prefer-option-over-null -- `SubtypeDefinition.sortable` as declared, an optional `?: T | undefined` field (AGENTS.md → Conventions)
|
|
493
|
+
sortable) => {
|
|
494
|
+
if (sortable === undefined)
|
|
495
|
+
return (column) => columns.has(column);
|
|
496
|
+
const undeclared = Array.findFirst(sortable, (column) => !columns.has(column));
|
|
497
|
+
if (Option.isSome(undeclared)) {
|
|
498
|
+
throw new SubtypeDeclarationError({
|
|
499
|
+
message: `${type}: sortable names '${undeclared.value}', which is not a column of the view`,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
return (column) => Array.contains(sortable, column);
|
|
503
|
+
};
|
|
504
|
+
/**
|
|
505
|
+
* Bind a leaf's operations. Every produced operation is traced as
|
|
506
|
+
* `${type}.<operation>`; the write operations run in the caller's
|
|
507
|
+
* transaction (the boundary is the handler seam's, website #1883).
|
|
508
|
+
*
|
|
509
|
+
* Throws {@link SubtypeDeclarationError}, when the leaf is declared, for a
|
|
510
|
+
* `sortable` entry that is not a column of the view.
|
|
511
|
+
*
|
|
512
|
+
* @since 0.1.0
|
|
513
|
+
*/
|
|
514
|
+
export const defineSubtype = (definition) => {
|
|
515
|
+
const { type, access, leaves, policy, followers, toResource } = definition;
|
|
516
|
+
const hooks = definition.hooks ?? Effect.succeed(noHooks());
|
|
517
|
+
// The history reads run no hook, so they read through a chain bound without one.
|
|
518
|
+
const history = defineVersionChain(type, definition.chain);
|
|
519
|
+
// Bound only for a leaf that declares hooks, so a leaf without them reads nothing extra.
|
|
520
|
+
const chain = defineVersionChain(type, definition.chain, definition.hooks === undefined
|
|
521
|
+
? {}
|
|
522
|
+
: {
|
|
523
|
+
beforeAppend: (event) => Effect.flatMap(hooks, ({ before }) => Array.isReadonlyArrayNonEmpty(before)
|
|
524
|
+
? Effect.flatMap(definition.viewer, (viewer) => runBefore(before, { ...event, type, viewer }))
|
|
525
|
+
: Effect.succeed(event.patch)),
|
|
526
|
+
beforeAppendLocales: (event) => Effect.flatMap(hooks, ({ before }) => Array.isReadonlyArrayNonEmpty(before)
|
|
527
|
+
? Effect.flatMap(definition.viewer, (viewer) => runBeforeLocales(before, { ...event, type, viewer }))
|
|
528
|
+
: Effect.succeed(event.locales ?? {})),
|
|
529
|
+
});
|
|
530
|
+
const actor = (account) => Option.some(account.id);
|
|
531
|
+
// The view's column set is fixed at bind time, so the owned leaf's
|
|
532
|
+
// declared-column check is answered once, not per row.
|
|
533
|
+
const ownedColumnsDeclared = access.kind === "owned" && declaresColumns(access, leaves.columns);
|
|
534
|
+
// Checked here, so a misspelt `sortable` entry throws when the leaf is declared.
|
|
535
|
+
const isSortable = sortableColumns(type, leaves.columns, definition.sortable);
|
|
536
|
+
// Checked here too, so a projection naming a column the view lacks throws when the leaf is declared.
|
|
537
|
+
if (definition.search !== undefined)
|
|
538
|
+
checkSearchProjection(definition.search, leaves.columns);
|
|
539
|
+
// And the locale level, so a translated column the view lacks throws at declaration.
|
|
540
|
+
const localeLevel = Option.fromUndefinedOr(definition.locales);
|
|
541
|
+
if (Option.isSome(localeLevel)) {
|
|
542
|
+
checkLocaleLevel(localeLevel.value, leaves.columns, [
|
|
543
|
+
...accessColumns(access),
|
|
544
|
+
...(definition.publication === undefined ? [] : PUBLICATION_COLUMNS),
|
|
545
|
+
]);
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* The write's locale level checked against the declaration (pulpit#56):
|
|
549
|
+
* shared columns in `state`, translated ones per locale, every locale tag
|
|
550
|
+
* non-empty; a leaf with none takes no locale. Runs after the gate.
|
|
551
|
+
*/
|
|
552
|
+
const lockedLocales = (id, action, state, locales) => Option.match(localeViolation(Option.map(localeLevel, (level) => level.columns), state, locales), {
|
|
553
|
+
onNone: () => Effect.void,
|
|
554
|
+
onSome: (reason) => Effect.fail(new WriteRejected({ type, id, action, reason })),
|
|
555
|
+
});
|
|
556
|
+
/**
|
|
557
|
+
* The row's own visibility, by access kind; the soft-delete axis is checked
|
|
558
|
+
* by the caller. It fails closed exactly where the list filter does, so
|
|
559
|
+
* `get` / `getMany` never serve a row `list` hides (SEC-AUTH-ENUM-001): an
|
|
560
|
+
* owned leaf whose view lacks a declared column shows no row, as
|
|
561
|
+
* `ownedListFilter` lists none; a reference row shows only when the view
|
|
562
|
+
* has no `visibility` column, the viewer holds the view-private rung, its
|
|
563
|
+
* visibility is exactly `public`, or it is exactly `followers` and the
|
|
564
|
+
* viewer is, or follows, the account its declared `followersOfColumn` names, as
|
|
565
|
+
* `referencePrivacyFilter` lists (pulpit#92). The reference arm is one
|
|
566
|
+
* shared predicate with `./edges`' `canViewEndpoint`.
|
|
567
|
+
*/
|
|
568
|
+
const rowVisible = (viewer, row) => {
|
|
569
|
+
if (access.kind === "reference") {
|
|
570
|
+
return canViewReferenceRow(access, leaves.columns, viewer, policy, followers, row);
|
|
571
|
+
}
|
|
572
|
+
return ownedColumnsDeclared
|
|
573
|
+
? canViewOwnedRow(viewer, policy, followers, ownedFacts(access, row))
|
|
574
|
+
: Effect.succeed(false);
|
|
575
|
+
};
|
|
576
|
+
const visible = (viewer, row) => row.action === "delete" && !Option.exists(viewer, policy.canViewDeleted)
|
|
577
|
+
? Effect.succeed(false)
|
|
578
|
+
: rowVisible(viewer, row);
|
|
579
|
+
const publication = Option.fromUndefinedOr(definition.publication);
|
|
580
|
+
/** Whether the viewer's rung reads every head: the view-private rung, or a reference leaf's corpus writers. */
|
|
581
|
+
const readsHeadByRung = (viewer) => Option.exists(viewer, (account) => policy.canViewPrivate(account) ||
|
|
582
|
+
(access.kind === "reference" && policy.canWriteCorpus(account)));
|
|
583
|
+
/** Whether the viewer reads this node's head: by rung, or as its owner. */
|
|
584
|
+
const readsHeadOf = (viewer, head) => readsHeadByRung(viewer) ||
|
|
585
|
+
(access.kind === "owned" &&
|
|
586
|
+
Option.exists(viewer, (account) => Option.contains(ownedFacts(access, head).ownerId, account.id)));
|
|
587
|
+
/**
|
|
588
|
+
* The row the viewer reads for a node whose head is `head`: the head
|
|
589
|
+
* itself, or on a publish-gated leaf, for a viewer who doesn't read it,
|
|
590
|
+
* the live row (none without one).
|
|
591
|
+
*/
|
|
592
|
+
const chosenRow = (viewer, head) => Option.match(publication, {
|
|
593
|
+
onNone: () => Effect.succeedSome(head),
|
|
594
|
+
onSome: ({ live }) => readsHeadOf(viewer, head) ? Effect.succeedSome(head) : live.get(head.id),
|
|
595
|
+
});
|
|
596
|
+
/**
|
|
597
|
+
* The row `get` serves the viewer: a missing or hidden row is the same
|
|
598
|
+
* {@link NodeNotFound} (SEC-AUTH-ENUM-001). `get` and the reference write
|
|
599
|
+
* gate both read through it, so they can never disagree (pulpit#101).
|
|
600
|
+
*/
|
|
601
|
+
const visibleRow = (viewer, id) => Effect.gen(function* () {
|
|
602
|
+
const head = yield* leaves.get(id);
|
|
603
|
+
const row = Option.isSome(head) ? yield* chosenRow(viewer, head.value) : Option.none();
|
|
604
|
+
if (Option.isSome(row) && (yield* visible(viewer, row.value)))
|
|
605
|
+
return row.value;
|
|
606
|
+
return yield* new NodeNotFound({ type, id });
|
|
607
|
+
});
|
|
608
|
+
/** Whether this request holds a preview grant for `id` (a publish-gated leaf's alone). */
|
|
609
|
+
const previewing = (id) => Option.match(Option.flatMapNullishOr(publication, (declared) => declared.preview), {
|
|
610
|
+
onNone: () => Effect.succeed(false),
|
|
611
|
+
onSome: (grant) => Effect.map(grant, (granted) => Option.contains(granted, id)),
|
|
612
|
+
});
|
|
613
|
+
/**
|
|
614
|
+
* The gate for a write or history read to an existing row. On a reference
|
|
615
|
+
* leaf, the corpus-write rung and then the read rung the row demands: a
|
|
616
|
+
* corpus writer who can't `get` the row learns `NodeNotFound`, exactly as
|
|
617
|
+
* from `get`, before any content is read or written (pulpit#101,
|
|
618
|
+
* `PUB-009`). The rung check runs first and reads no row, so a caller below
|
|
619
|
+
* it gets `Unauthorized` / `Forbidden` for every id alike. An owned leaf's
|
|
620
|
+
* gate is unchanged (pulpit#24).
|
|
621
|
+
*/
|
|
622
|
+
const writer = (viewer, id) => Effect.tap(requireWriter(access, viewer, policy, { type, id }, leaves.ownerOf(id)), () => access.kind === "reference" ? Effect.asVoid(visibleRow(viewer, id)) : Effect.void);
|
|
623
|
+
const create = Effect.fn(`${type}.create`)(function* (input) {
|
|
624
|
+
const account = yield* requireCreator(access, yield* definition.viewer, policy);
|
|
625
|
+
yield* lockedLocales(Option.getOrElse(input.id, () => ""), "create", input.state, input.locales ?? {});
|
|
626
|
+
const written = yield* chain.create({ ...input, actorId: actor(account) });
|
|
627
|
+
if (access.kind === "owned")
|
|
628
|
+
yield* leaves.stampOwner(written.id, account.id);
|
|
629
|
+
return written;
|
|
630
|
+
});
|
|
631
|
+
const update = Effect.fn(`${type}.update`)(function* (input) {
|
|
632
|
+
const account = yield* writer(yield* definition.viewer, input.id);
|
|
633
|
+
yield* lockedLocales(input.id, "update", input.patch, input.locales ?? {});
|
|
634
|
+
return yield* chain.update({ ...input, actorId: actor(account) });
|
|
635
|
+
});
|
|
636
|
+
const restore = Effect.fn(`${type}.restore`)(function* (input) {
|
|
637
|
+
const account = yield* writer(yield* definition.viewer, input.id);
|
|
638
|
+
yield* lockedLocales(input.id, "restore", input.patch, input.locales ?? {});
|
|
639
|
+
return yield* chain.restore({ ...input, actorId: actor(account) });
|
|
640
|
+
});
|
|
641
|
+
const softDelete = Effect.fn(`${type}.delete`)(function* (id, precondition = {}) {
|
|
642
|
+
const account = yield* writer(yield* definition.viewer, id);
|
|
643
|
+
return yield* chain.delete(id, actor(account), precondition);
|
|
644
|
+
});
|
|
645
|
+
const get = Effect.fn(`${type}.get`)(function* (id) {
|
|
646
|
+
// A preview grant serves exactly its node's head, unless tombstoned.
|
|
647
|
+
if (yield* previewing(id)) {
|
|
648
|
+
const head = yield* leaves.get(id);
|
|
649
|
+
if (Option.isSome(head) && head.value.action !== "delete")
|
|
650
|
+
return toResource(head.value);
|
|
651
|
+
}
|
|
652
|
+
// Hidden and missing are the same answer (SEC-AUTH-ENUM-001).
|
|
653
|
+
return toResource(yield* visibleRow(yield* definition.viewer, id));
|
|
654
|
+
});
|
|
655
|
+
const owners = Effect.fn(`${type}.owners`)(function* (request) {
|
|
656
|
+
if (access.kind !== "owned")
|
|
657
|
+
return { rows: [], total: 0 };
|
|
658
|
+
const viewer = yield* definition.viewer;
|
|
659
|
+
const among = request.among === undefined ? always : anyOf(access.ownerColumn, request.among);
|
|
660
|
+
const query = {
|
|
661
|
+
column: access.ownerColumn,
|
|
662
|
+
where: searchScopeFilter({ access, columns: leaves.columns, policy, followers }, viewer, {
|
|
663
|
+
includeDeleted: request.includeDeleted,
|
|
664
|
+
where: among,
|
|
665
|
+
}),
|
|
666
|
+
limit: Math.max(1, Math.min(Math.trunc(request.limit), MAX_PAGE_LIMIT)),
|
|
667
|
+
offset: Math.max(0, Math.trunc(request.offset)),
|
|
668
|
+
};
|
|
669
|
+
// The same choice `list` makes: the head by rung, else live rows and the viewer's own heads.
|
|
670
|
+
return yield* Option.match(publication, {
|
|
671
|
+
onNone: () => leaves.distinct(query),
|
|
672
|
+
onSome: ({ live }) => readsHeadByRung(viewer)
|
|
673
|
+
? leaves.distinct(query)
|
|
674
|
+
: live.distinct({ ...query, headFor: ownHeads(viewer) }),
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
const getLocalised = Effect.fn(`${type}.getLocalised`)(function* (id, locales) {
|
|
678
|
+
const viewer = yield* definition.viewer;
|
|
679
|
+
// The node's own gate, exactly as `get` runs it: a preview grant reads
|
|
680
|
+
// its node's head unless tombstoned, anyone else the row `get` serves.
|
|
681
|
+
const preview = (yield* previewing(id))
|
|
682
|
+
? Option.filter(yield* leaves.get(id), (head) => head.action !== "delete")
|
|
683
|
+
: Option.none();
|
|
684
|
+
const shown = Option.isSome(preview) ? preview.value : yield* visibleRow(viewer, id);
|
|
685
|
+
if (Option.isNone(localeLevel))
|
|
686
|
+
return yield* new NodeNotFound({ type, id });
|
|
687
|
+
// One read of the requested locales' rows, from the version the gate chose.
|
|
688
|
+
const wanted = Array.dedupe(locales);
|
|
689
|
+
const query = {
|
|
690
|
+
where: and(eq("id", id), localeIn(wanted)),
|
|
691
|
+
orderBy: [],
|
|
692
|
+
limit: Math.max(1, Math.min(wanted.length, MAX_PAGE_LIMIT)),
|
|
693
|
+
offset: 0,
|
|
694
|
+
};
|
|
695
|
+
const page = yield* Option.match(publication, {
|
|
696
|
+
onNone: () => leaves.list(query),
|
|
697
|
+
onSome: ({ live }) => Option.isSome(preview) || readsHeadOf(viewer, shown)
|
|
698
|
+
? leaves.list(query)
|
|
699
|
+
: live.list({ ...query, headFor: never }),
|
|
700
|
+
});
|
|
701
|
+
// Defence in depth: each locale row passes the node's read gate itself,
|
|
702
|
+
// so a store that joined a locale row onto other access facts serves none.
|
|
703
|
+
const readable = Option.isSome(preview)
|
|
704
|
+
? page.rows
|
|
705
|
+
: yield* Effect.filter(page.rows, (row) => visible(viewer, row));
|
|
706
|
+
const found = Array.findFirst(wanted, (tag) => Array.findFirst(readable, (row) => row[LOCALE_COLUMN] === tag).pipe(Option.map((row) => ({
|
|
707
|
+
locale: tag,
|
|
708
|
+
resource: toResource(row),
|
|
709
|
+
}))));
|
|
710
|
+
if (Option.isNone(found))
|
|
711
|
+
return yield* new NodeNotFound({ type, id });
|
|
712
|
+
return found.value;
|
|
713
|
+
});
|
|
714
|
+
/** The rows a viewer reads for these heads: the heads they read, and one live read for the rest. */
|
|
715
|
+
const chosenRows = (viewer, heads) => Option.match(publication, {
|
|
716
|
+
onNone: () => Effect.succeed(heads),
|
|
717
|
+
onSome: ({ live }) => {
|
|
718
|
+
const own = Array.filter(heads, (head) => readsHeadOf(viewer, head));
|
|
719
|
+
const others = Array.filter(heads, (head) => !readsHeadOf(viewer, head));
|
|
720
|
+
return Array.isReadonlyArrayNonEmpty(others)
|
|
721
|
+
? Effect.map(live.getMany(Array.map(others, (head) => head.id)), (lives) => [...own, ...lives])
|
|
722
|
+
: Effect.succeed(own);
|
|
723
|
+
},
|
|
724
|
+
});
|
|
725
|
+
const getMany = Effect.fn(`${type}.getMany`)(function* (ids) {
|
|
726
|
+
if (!Array.isReadonlyArrayNonEmpty(ids))
|
|
727
|
+
return HashMap.empty();
|
|
728
|
+
const viewer = yield* definition.viewer;
|
|
729
|
+
const rows = yield* chosenRows(viewer, yield* leaves.getMany(Array.dedupe(ids)));
|
|
730
|
+
const shown = yield* Effect.filter(rows, (row) => visible(viewer, row));
|
|
731
|
+
return HashMap.fromIterable(Array.map(shown, (row) => [row.id, toResource(row)]));
|
|
732
|
+
});
|
|
733
|
+
/** The heads a viewer below the head-reading rungs reads: their own, on an owned leaf. */
|
|
734
|
+
const ownHeads = (viewer) => access.kind === "owned"
|
|
735
|
+
? Option.match(viewer, {
|
|
736
|
+
onNone: () => never,
|
|
737
|
+
onSome: (account) => eq(access.ownerColumn, account.id),
|
|
738
|
+
})
|
|
739
|
+
: never;
|
|
740
|
+
const list = Effect.fn(`${type}.list`)(function* (request) {
|
|
741
|
+
const orderBy = yield* checkedOrder(type, isSortable, request.orderBy ?? []);
|
|
742
|
+
const viewer = yield* definition.viewer;
|
|
743
|
+
const where = searchScopeFilter({ access, columns: leaves.columns, policy, followers }, viewer, request);
|
|
744
|
+
const query = {
|
|
745
|
+
where,
|
|
746
|
+
orderBy,
|
|
747
|
+
limit: Math.max(1, Math.min(Math.trunc(request.limit), MAX_PAGE_LIMIT)),
|
|
748
|
+
offset: Math.max(0, Math.trunc(request.offset)),
|
|
749
|
+
};
|
|
750
|
+
// On a publish-gated leaf, a viewer below the head-reading rungs lists
|
|
751
|
+
// live rows, and their own rows at the head, as `get` reads them.
|
|
752
|
+
const page = yield* Option.match(publication, {
|
|
753
|
+
onNone: () => leaves.list(query),
|
|
754
|
+
onSome: ({ live }) => readsHeadByRung(viewer)
|
|
755
|
+
? leaves.list(query)
|
|
756
|
+
: live.list({ ...query, headFor: ownHeads(viewer) }),
|
|
757
|
+
});
|
|
758
|
+
return { rows: page.rows.map(toResource), total: page.total };
|
|
759
|
+
});
|
|
760
|
+
/**
|
|
761
|
+
* A history read (`READ-009`): the viewer's {@link writer} gate on `id`,
|
|
762
|
+
* then `read`. `getVersion` and `compare` both run through it, so their
|
|
763
|
+
* gate is one code path and `read` never runs for a caller it refuses.
|
|
764
|
+
*/
|
|
765
|
+
const historyRead = (id, read) => Effect.andThen(Effect.flatMap(definition.viewer, (viewer) => writer(viewer, id)), read);
|
|
766
|
+
const getVersion = Effect.fn(`${type}.getVersion`)(function* (id, version) {
|
|
767
|
+
return yield* historyRead(id, history.getVersion(id, version));
|
|
768
|
+
});
|
|
769
|
+
const compare = Effect.fn(`${type}.compare`)(function* (id, from, to) {
|
|
770
|
+
return yield* historyRead(id, history.compare(id, from, to));
|
|
771
|
+
});
|
|
772
|
+
const restoreVersion = Effect.fn(`${type}.restoreVersion`)(function* (id, version, precondition = {}) {
|
|
773
|
+
const account = yield* writer(yield* definition.viewer, id);
|
|
774
|
+
return yield* chain.restoreVersion(id, version, actor(account), precondition);
|
|
775
|
+
});
|
|
776
|
+
const publicResource = Effect.fn(`${type}.publicResource`)(function* (id) {
|
|
777
|
+
return yield* visibleRow(Option.none(), id).pipe(Effect.map((row) => Option.some(toResource(row))), Effect.catchTag("NodeNotFound", () => Effect.succeed(Option.none())));
|
|
778
|
+
});
|
|
779
|
+
const authorize = Effect.fn(`${type}.authorize`)(function* (id) {
|
|
780
|
+
return yield* writer(yield* definition.viewer, id);
|
|
781
|
+
});
|
|
782
|
+
/** A publication's before hooks: shown the head, an empty patch; the returned patch is ignored. */
|
|
783
|
+
const beforePublication = (action, id, viewer, account) => definition.hooks === undefined
|
|
784
|
+
? Effect.void
|
|
785
|
+
: Effect.gen(function* () {
|
|
786
|
+
const { before } = yield* hooks;
|
|
787
|
+
if (!Array.isReadonlyArrayNonEmpty(before))
|
|
788
|
+
return;
|
|
789
|
+
const head = yield* definition.chain.head(id);
|
|
790
|
+
const prior = Option.isSome(head)
|
|
791
|
+
? yield* definition.chain.snapshot(id, head.value.version)
|
|
792
|
+
: Option.none();
|
|
793
|
+
yield* runBefore(before, {
|
|
794
|
+
action,
|
|
795
|
+
id,
|
|
796
|
+
prior,
|
|
797
|
+
patch: {},
|
|
798
|
+
actorId: actor(account),
|
|
799
|
+
type,
|
|
800
|
+
viewer,
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
const publicationOps = Option.map(publication, ({ facts }) => ({
|
|
804
|
+
publish: Effect.fn(`${type}.publish`)(function* (id, options = {}) {
|
|
805
|
+
const viewer = yield* definition.viewer;
|
|
806
|
+
const account = yield* writer(viewer, id);
|
|
807
|
+
const head = yield* definition.chain.head(id);
|
|
808
|
+
if (Option.isNone(head) || head.value.action === "delete") {
|
|
809
|
+
return yield* new NodeNotFound({ type, id });
|
|
810
|
+
}
|
|
811
|
+
const version = options.version ?? head.value.version;
|
|
812
|
+
const snapshot = yield* definition.chain.snapshot(id, version);
|
|
813
|
+
if (Option.isNone(snapshot) || snapshot.value.action === "delete") {
|
|
814
|
+
return yield* new VersionNotFoundError({ type, id, version });
|
|
815
|
+
}
|
|
816
|
+
yield* beforePublication("publish", id, viewer, account);
|
|
817
|
+
const now = yield* nowDate;
|
|
818
|
+
const fact = {
|
|
819
|
+
id,
|
|
820
|
+
version: Option.some(version),
|
|
821
|
+
at: options.at ?? now,
|
|
822
|
+
actorId: actor(account),
|
|
823
|
+
recordedAt: now,
|
|
824
|
+
};
|
|
825
|
+
yield* facts.record(fact);
|
|
826
|
+
return { fact, head: head.value.version };
|
|
827
|
+
}),
|
|
828
|
+
unpublish: Effect.fn(`${type}.unpublish`)(function* (id) {
|
|
829
|
+
const viewer = yield* definition.viewer;
|
|
830
|
+
const account = yield* writer(viewer, id);
|
|
831
|
+
const head = yield* definition.chain.head(id);
|
|
832
|
+
if (Option.isNone(head))
|
|
833
|
+
return yield* new NodeNotFound({ type, id });
|
|
834
|
+
yield* beforePublication("unpublish", id, viewer, account);
|
|
835
|
+
const now = yield* nowDate;
|
|
836
|
+
const fact = {
|
|
837
|
+
id,
|
|
838
|
+
version: Option.none(),
|
|
839
|
+
at: now,
|
|
840
|
+
actorId: actor(account),
|
|
841
|
+
recordedAt: now,
|
|
842
|
+
};
|
|
843
|
+
yield* facts.record(fact);
|
|
844
|
+
return { fact, head: head.value.version };
|
|
845
|
+
}),
|
|
846
|
+
discardDraft: Effect.fn(`${type}.discardDraft`)(function* (id, precondition = {}) {
|
|
847
|
+
const account = yield* writer(yield* definition.viewer, id);
|
|
848
|
+
const live = Option.flatMap(yield* facts.effective(id), (fact) => fact.version);
|
|
849
|
+
if (Option.isNone(live))
|
|
850
|
+
return yield* new NotPublished({ type, id });
|
|
851
|
+
const head = yield* definition.chain.head(id);
|
|
852
|
+
if (Option.exists(head, (entry) => entry.version === live.value)) {
|
|
853
|
+
return { id, version: live.value };
|
|
854
|
+
}
|
|
855
|
+
return yield* chain.restoreVersion(id, live.value, actor(account), precondition);
|
|
856
|
+
}),
|
|
857
|
+
facts: Effect.fn(`${type}.publicationFacts`)(function* (id) {
|
|
858
|
+
return yield* historyRead(id, facts.facts(id, MAX_HISTORY_LIMIT));
|
|
859
|
+
}),
|
|
860
|
+
}));
|
|
861
|
+
return {
|
|
862
|
+
type,
|
|
863
|
+
access,
|
|
864
|
+
hooks,
|
|
865
|
+
publication: publicationOps,
|
|
866
|
+
publicResource,
|
|
867
|
+
authorize,
|
|
868
|
+
create,
|
|
869
|
+
update,
|
|
870
|
+
restore,
|
|
871
|
+
delete: softDelete,
|
|
872
|
+
get,
|
|
873
|
+
getLocalised,
|
|
874
|
+
owners,
|
|
875
|
+
getMany,
|
|
876
|
+
list,
|
|
877
|
+
getVersion,
|
|
878
|
+
compare,
|
|
879
|
+
restoreVersion,
|
|
880
|
+
};
|
|
881
|
+
};
|
|
882
|
+
const checkSearchProjection = (projection, columns) => {
|
|
883
|
+
const named = [
|
|
884
|
+
...Array.map(projection.fields, (field) => field.column),
|
|
885
|
+
...Option.toArray(projection.languageColumn),
|
|
886
|
+
];
|
|
887
|
+
const undeclared = Array.findFirst(named, (column) => !columns.has(column));
|
|
888
|
+
if (Option.isSome(undeclared)) {
|
|
889
|
+
throw new SubtypeDeclarationError({
|
|
890
|
+
message: `search names '${undeclared.value}', which is not a column of the view`,
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
/**
|
|
895
|
+
* Declare a {@link SearchProjection}. Throws {@link SubtypeDeclarationError}
|
|
896
|
+
* at import, as a registry violation is, for no field, a column named twice
|
|
897
|
+
* or a weight outside `A`–`D`; {@link defineSubtype} checks the columns
|
|
898
|
+
* against the view.
|
|
899
|
+
*
|
|
900
|
+
* @example
|
|
901
|
+
* ```ts
|
|
902
|
+
* import { strictEqual } from "node:assert";
|
|
903
|
+
* import { defineSearchProjection } from "@thomasfosterau/pulpit/subtype";
|
|
904
|
+
*
|
|
905
|
+
* const posts = defineSearchProjection({
|
|
906
|
+
* fields: [
|
|
907
|
+
* { column: "title", weight: "A" },
|
|
908
|
+
* { column: "excerpt", weight: "B" },
|
|
909
|
+
* ],
|
|
910
|
+
* languageColumn: "language",
|
|
911
|
+
* });
|
|
912
|
+
* strictEqual(posts.fields.length, 2);
|
|
913
|
+
* ```
|
|
914
|
+
*
|
|
915
|
+
* @since 0.1.0
|
|
916
|
+
*/
|
|
917
|
+
export const defineSearchProjection = (declaration) => {
|
|
918
|
+
if (!Array.isReadonlyArrayNonEmpty(declaration.fields)) {
|
|
919
|
+
throw new SubtypeDeclarationError({ message: "search declares no field" });
|
|
920
|
+
}
|
|
921
|
+
const repeated = Array.findFirst(declaration.fields, (field, index) => declaration.fields.slice(0, index).some((earlier) => earlier.column === field.column));
|
|
922
|
+
if (Option.isSome(repeated)) {
|
|
923
|
+
throw new SubtypeDeclarationError({
|
|
924
|
+
message: `search names '${repeated.value.column}' twice`,
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
const unweighted = Array.findFirst(declaration.fields, (field) => !Array.contains(["A", "B", "C", "D"], field.weight));
|
|
928
|
+
if (Option.isSome(unweighted)) {
|
|
929
|
+
throw new SubtypeDeclarationError({
|
|
930
|
+
message: `search weighs '${unweighted.value.column}' outside A–D`,
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
return {
|
|
934
|
+
fields: declaration.fields,
|
|
935
|
+
languageColumn: Option.fromNullishOr(declaration.languageColumn),
|
|
936
|
+
};
|
|
937
|
+
};
|
|
938
|
+
/**
|
|
939
|
+
* The read scope a search over one leaf must apply to its hits: exactly the
|
|
940
|
+
* filter {@link defineSubtype}'s `list` reads under (`list` calls this), the
|
|
941
|
+
* soft-delete arm, the access kind's privacy arm (owned visibility and draft
|
|
942
|
+
* through {@link ownedListFilter}; the reference privacy arm with its
|
|
943
|
+
* followers, `PUB-009`) and the family's own `where`, so a search can never
|
|
944
|
+
* surface a row `list` hides (`SEC-AUTH-ENUM-001`). The application renders
|
|
945
|
+
* it into its search query as the list store renders it into the list's. On
|
|
946
|
+
* a leaf with the live pointer ({@link SubtypeDefinition.publication}) the
|
|
947
|
+
* search matches over the rows {@link LiveView}'s `list` reads (the viewer's
|
|
948
|
+
* own at the head, every other node's live row), never every head, so a
|
|
949
|
+
* match never scores text a reader cannot see.
|
|
950
|
+
*
|
|
951
|
+
* @since 0.1.0
|
|
952
|
+
*/
|
|
953
|
+
export const searchScopeFilter = (leaf, viewer, request) => {
|
|
954
|
+
const { access, columns, policy, followers } = leaf;
|
|
955
|
+
const privacy = access.kind === "owned"
|
|
956
|
+
? ownedListFilter(access, columns, readScopeFor(viewer, policy, followers))
|
|
957
|
+
: referencePrivacyFilter(viewer, policy, columns, Option.some({ access, followers }));
|
|
958
|
+
return and(softDeleteFilter(viewer, policy, request.includeDeleted), privacy, request.where);
|
|
959
|
+
};
|
|
960
|
+
/**
|
|
961
|
+
* Hydrate ranked search hits through the leaf's gated `getMany`: one read
|
|
962
|
+
* for every hit, each re-checked against the viewer by the same predicate
|
|
963
|
+
* `get` applies, so a stale or poisoned index (a hit the search scope should
|
|
964
|
+
* have excluded, or one hidden since it was indexed) never leaks a row
|
|
965
|
+
* (`SEC-AUTH-ENUM-001`). The hits keep their rank order, a hidden or
|
|
966
|
+
* missing one is dropped, and a repeated id keeps its first, best rank.
|
|
967
|
+
* Fails with the store's `E`.
|
|
968
|
+
*
|
|
969
|
+
* @since 0.1.0
|
|
970
|
+
*/
|
|
971
|
+
export const hydrateSearchHits = (ops, hits) => {
|
|
972
|
+
const ranked = Array.dedupeWith(hits, (a, b) => a.id === b.id);
|
|
973
|
+
return Effect.map(ops.getMany(Array.map(ranked, (hit) => hit.id)), (shown) => Array.flatMap(ranked, (hit) => Option.toArray(Option.map(HashMap.get(shown, hit.id), (resource) => ({ hit, resource })))));
|
|
974
|
+
};
|
|
975
|
+
/**
|
|
976
|
+
* The resource cache's write-through as an {@link AfterWriteHook}: a write
|
|
977
|
+
* whose writer re-read the resource sets it, and any other (a `delete`, or a
|
|
978
|
+
* write whose re-read failed or no longer shows the row) evicts it
|
|
979
|
+
* (`CACHE-AUTH-005`). {@link subtypeHandlers} runs it first, before the
|
|
980
|
+
* leaf's own after hooks, whenever its config has a cache. Never fails.
|
|
981
|
+
*
|
|
982
|
+
* @since 0.1.0
|
|
983
|
+
*/
|
|
984
|
+
export const cacheWriteThrough = (cache) => ({
|
|
985
|
+
name: "cache",
|
|
986
|
+
run: ({ write, resource }) => Option.match(resource, {
|
|
987
|
+
onNone: () => cache.delete(write.id, write.version),
|
|
988
|
+
onSome: (value) => cache.set(write.id, value, write.version),
|
|
989
|
+
}),
|
|
990
|
+
});
|
|
991
|
+
/**
|
|
992
|
+
* Wrap a leaf's operations as its handler record. An owned leaf may still
|
|
993
|
+
* be cached — `CACHE-AUTH-005` is enforced by the cache's serveability
|
|
994
|
+
* policy (`./publication`'s `isPubliclyServeable` with `owned: true`), which
|
|
995
|
+
* only ever holds its public rows, and by the handlers' own eviction when a
|
|
996
|
+
* committed write can't be re-read or written through (see
|
|
997
|
+
* {@link SubtypeHandlers}). Each write fails with its operation's error, or
|
|
998
|
+
* with the re-read's unless that is `NodeNotFound` (a write hidden from its
|
|
999
|
+
* writer, answered as a {@link CommittedWrite} with no resource).
|
|
1000
|
+
*
|
|
1001
|
+
* @since 0.1.0
|
|
1002
|
+
*/
|
|
1003
|
+
export const subtypeHandlers = (ops, config) => {
|
|
1004
|
+
const evict = ({ id, version }) => Option.match(config.cache, {
|
|
1005
|
+
onNone: () => Effect.void,
|
|
1006
|
+
onSome: (cache) => cache.delete(id, version),
|
|
1007
|
+
});
|
|
1008
|
+
// A write runs under `uninterruptibleMask`: the chain write (inside
|
|
1009
|
+
// `transact`) and the re-read stay interruptible through `restore`, but
|
|
1010
|
+
// the step between them, and the cache step after, cannot be skipped by
|
|
1011
|
+
// an interrupt, so a committed write always reaches its write-through or
|
|
1012
|
+
// eviction (`CACHE-AUTH-005`). Once the operation itself has succeeded
|
|
1013
|
+
// (its gate passed and the chain write ran), any later failure of the
|
|
1014
|
+
// boundary, an interrupt included, evicts: the write may have committed.
|
|
1015
|
+
// A request the gate refused never evicts, so a forbidden request can't
|
|
1016
|
+
// bust the cache. The flag is cleared at the start of each attempt, so a
|
|
1017
|
+
// `transact` that retries internally, rolling back an attempt that wrote,
|
|
1018
|
+
// evicts only when its last attempt wrote.
|
|
1019
|
+
const committed = (restore, operation) => Effect.flatMap(Ref.make(Option.none()), (wrote) => restore(config.transact(Effect.andThen(Ref.set(wrote, Option.none()), Effect.tap(operation, ({ id, version }) => Ref.set(wrote, Option.some({ id, version })))))).pipe(Effect.onError(() => Effect.flatMap(Ref.get(wrote), Option.match({ onNone: () => Effect.void, onSome: evict })))));
|
|
1020
|
+
/**
|
|
1021
|
+
* The cache write-through. On a publish-gated leaf the writer's re-read
|
|
1022
|
+
* is the head, which must never reach a public cache, so the cache is
|
|
1023
|
+
* given the resource as an anonymous reader gets it (the live version),
|
|
1024
|
+
* or evicts (`CACHE-AUTH-005`).
|
|
1025
|
+
*/
|
|
1026
|
+
const cacheHook = (cache) => Option.isNone(ops.publication)
|
|
1027
|
+
? cacheWriteThrough(cache)
|
|
1028
|
+
: {
|
|
1029
|
+
name: "cache",
|
|
1030
|
+
run: (event) => ops.publicResource(event.write.id).pipe(Effect.flatMap((resource) => cacheWriteThrough(cache).run({ ...event, resource })), Effect.catchCause(() => cache.delete(event.write.id, event.write.version))),
|
|
1031
|
+
};
|
|
1032
|
+
/**
|
|
1033
|
+
* The cache step inside an outer transaction: the write hasn't committed,
|
|
1034
|
+
* so evict at its version and cache nothing (pulpit#124).
|
|
1035
|
+
*/
|
|
1036
|
+
const evictHook = (cache) => ({
|
|
1037
|
+
name: "cache",
|
|
1038
|
+
run: ({ write }) => cache.delete(write.id, write.version),
|
|
1039
|
+
});
|
|
1040
|
+
/**
|
|
1041
|
+
* The after hooks for one write whose `transact` has returned: the cache
|
|
1042
|
+
* step first (when the leaf has a cache; a write-through, or an eviction
|
|
1043
|
+
* inside an outer transaction), then the leaf's own, in order.
|
|
1044
|
+
*/
|
|
1045
|
+
const after = (event) => Effect.flatMap(Effect.zip(ops.hooks, config.inTransaction), ([hooks, nested]) => runAfter({
|
|
1046
|
+
...hooks,
|
|
1047
|
+
after: [
|
|
1048
|
+
...Option.toArray(Option.map(config.cache, nested ? evictHook : cacheHook)),
|
|
1049
|
+
...hooks.after,
|
|
1050
|
+
],
|
|
1051
|
+
}, event));
|
|
1052
|
+
/**
|
|
1053
|
+
* A publication fact, recorded in `transact`, then the after hooks with
|
|
1054
|
+
* the resource as the writer now reads it.
|
|
1055
|
+
*/
|
|
1056
|
+
const published = (action, operation) => Effect.uninterruptibleMask((restore) => Effect.tap(restore(config.transact(operation)), ({ fact, head }) => Effect.flatMap(Effect.option(restore(ops.get(fact.id))), (resource) => after({
|
|
1057
|
+
type: ops.type,
|
|
1058
|
+
action,
|
|
1059
|
+
write: { id: fact.id, version: head },
|
|
1060
|
+
resource,
|
|
1061
|
+
publication: Option.some(fact),
|
|
1062
|
+
}))));
|
|
1063
|
+
// The re-read runs as the writer, after the write committed, and its
|
|
1064
|
+
// outcome is the after hooks' `resource`. When it fails (the write hid
|
|
1065
|
+
// the row from its own writer: a corpus writer below the view-private
|
|
1066
|
+
// rung making a reference row `private`; a store error; an interrupt),
|
|
1067
|
+
// the resource is none and the cache hook evicts: the cache must never go
|
|
1068
|
+
// on serving the superseded resource, whatever the writer may read now
|
|
1069
|
+
// (`CACHE-AUTH-005`, pulpit#107). A `NodeNotFound` re-read is a write
|
|
1070
|
+
// hidden from its writer: it answers the committed write with no resource,
|
|
1071
|
+
// not a 404 for a write that committed. Any other failure fails the
|
|
1072
|
+
// handler with the re-read's own cause.
|
|
1073
|
+
const write = (action, operation) => Effect.uninterruptibleMask((restore) => Effect.flatMap(committed(restore, operation), (written) => Effect.flatMap(Effect.exit(restore(ops.get(written.id))), (reread) => Effect.andThen(after({
|
|
1074
|
+
type: ops.type,
|
|
1075
|
+
action,
|
|
1076
|
+
write: { id: written.id, version: written.version },
|
|
1077
|
+
resource: Exit.isSuccess(reread) ? Option.some(reread.value) : Option.none(),
|
|
1078
|
+
publication: Option.none(),
|
|
1079
|
+
}), reread.pipe(Effect.map(Option.some), Effect.catchTag("NodeNotFound", () => Effect.succeedNone), Effect.map((resource) => ({
|
|
1080
|
+
id: written.id,
|
|
1081
|
+
version: written.version,
|
|
1082
|
+
resource,
|
|
1083
|
+
})))))));
|
|
1084
|
+
return {
|
|
1085
|
+
list: ops.list,
|
|
1086
|
+
get: ops.get,
|
|
1087
|
+
create: (input) => write("create", ops.create(input)),
|
|
1088
|
+
update: (input) => write("update", ops.update(input)),
|
|
1089
|
+
restore: (input) => write("restore", ops.restore(input)),
|
|
1090
|
+
// An already tombstoned node wrote nothing, so it emits nothing; its
|
|
1091
|
+
// cache entry is still evicted, as every delete's is.
|
|
1092
|
+
delete: (id, precondition) => Effect.uninterruptibleMask((restore) => Effect.flatMap(committed(restore, ops.delete(id, precondition)), (written) => written.alreadyDeleted
|
|
1093
|
+
? evict(written)
|
|
1094
|
+
: after({
|
|
1095
|
+
type: ops.type,
|
|
1096
|
+
action: "delete",
|
|
1097
|
+
write: { id: written.id, version: written.version },
|
|
1098
|
+
resource: Option.none(),
|
|
1099
|
+
publication: Option.none(),
|
|
1100
|
+
}))),
|
|
1101
|
+
restoreVersion: (input) => write("restoreVersion", ops.restoreVersion(input.id, input.version, { expectedVersion: input.expectedVersion })),
|
|
1102
|
+
publication: Option.map(ops.publication, (publication) => ({
|
|
1103
|
+
publish: (id, options) => published("publish", publication.publish(id, options)),
|
|
1104
|
+
unpublish: (id) => published("unpublish", publication.unpublish(id)),
|
|
1105
|
+
discardDraft: (id, precondition) => Effect.uninterruptibleMask((restore) => Effect.tap(committed(restore, publication.discardDraft(id, precondition)), (written) => Effect.flatMap(Effect.option(restore(ops.get(written.id))), (resource) => after({
|
|
1106
|
+
type: ops.type,
|
|
1107
|
+
action: "restoreVersion",
|
|
1108
|
+
write: written,
|
|
1109
|
+
resource,
|
|
1110
|
+
publication: Option.none(),
|
|
1111
|
+
})))),
|
|
1112
|
+
facts: publication.facts,
|
|
1113
|
+
})),
|
|
1114
|
+
};
|
|
1115
|
+
};
|
|
1116
|
+
/**
|
|
1117
|
+
* A leaf's {@link SubtypeHandlers} as effect-jsonapi's CRUD handlers
|
|
1118
|
+
* (`ResourceHandlers.make`), keyed by the endpoint names `Group.flat`
|
|
1119
|
+
* declares (pulpit#5). Each write goes through the record, so it keeps the
|
|
1120
|
+
* record's `transact` boundary, writer re-read, lifecycle hooks and cache
|
|
1121
|
+
* write-through (`CACHE-AUTH-005`); effect-jsonapi is given no `cache` and no
|
|
1122
|
+
* `transact` of its own, and its mutation tail re-reads the row once more
|
|
1123
|
+
* with `get` to assemble the document. `list` answers the page with the
|
|
1124
|
+
* unpaged total, and `delete` the 204. `restore` and `restoreVersion` have no
|
|
1125
|
+
* JSON:API endpoint and stay on the record. The handlers fail with the
|
|
1126
|
+
* record's errors (and effect-jsonapi's `Conflict` for an `update` whose
|
|
1127
|
+
* payload id is not the route's, before any operation runs); mapping them to
|
|
1128
|
+
* the wire is the application's `ServerErrors`.
|
|
1129
|
+
*
|
|
1130
|
+
* A `create` / `update` whose committed write hides the row from its writer
|
|
1131
|
+
* (the re-read fails `NodeNotFound`) answers effect-jsonapi's committed
|
|
1132
|
+
* identifier document, `{ data: { type, id, meta: { version } } }` at the
|
|
1133
|
+
* endpoint's own status, not a 404 (effect-jsonapi's `hiddenWrite`,
|
|
1134
|
+
* pulpit#107). Declare the group with `Group.flat({ …, hiddenWrites: true })`
|
|
1135
|
+
* so its `create` / `update` success admits that document.
|
|
1136
|
+
*
|
|
1137
|
+
* @since 0.1.0
|
|
1138
|
+
*/
|
|
1139
|
+
export const subtypeResourceHandlers = (handlers, config) => {
|
|
1140
|
+
const { type, listRequest, createInput, updateInput, ...rest } = config;
|
|
1141
|
+
return ResourceHandlers.make({
|
|
1142
|
+
...rest,
|
|
1143
|
+
hiddenWrite: { type, when: (error) => Predicate.isTagged(error, "NodeNotFound") },
|
|
1144
|
+
ops: {
|
|
1145
|
+
list: (query) => Effect.map(handlers.list(listRequest(query)), (page) => ({
|
|
1146
|
+
items: page.rows,
|
|
1147
|
+
totalCount: page.total,
|
|
1148
|
+
})),
|
|
1149
|
+
get: handlers.get,
|
|
1150
|
+
// The committed version rides along: a write hidden from its writer
|
|
1151
|
+
// answers it as `data.meta.version` (pulpit#107).
|
|
1152
|
+
create: (payload) => Effect.map(handlers.create(createInput(payload)), ({ id, version }) => ({ id, version })),
|
|
1153
|
+
update: (payload) => Effect.map(handlers.update(updateInput(payload)), ({ id, version }) => ({ id, version })),
|
|
1154
|
+
delete: (id) => handlers.delete(id),
|
|
1155
|
+
},
|
|
1156
|
+
});
|
|
1157
|
+
};
|
|
1158
|
+
/** The names a view keeps for itself: no translated column may take one. */
|
|
1159
|
+
const RESERVED_COLUMNS = HashSet.make("id", "version", "action", LOCALE_COLUMN);
|
|
1160
|
+
/**
|
|
1161
|
+
* The columns a leaf's read gate reads (pulpit#56): none of them may be
|
|
1162
|
+
* translated, so every locale row carries the node's one owner, visibility,
|
|
1163
|
+
* draft and moderation state.
|
|
1164
|
+
*/
|
|
1165
|
+
const accessColumns = (access) => access.kind === "owned"
|
|
1166
|
+
? [
|
|
1167
|
+
access.ownerColumn,
|
|
1168
|
+
access.visibilityColumn,
|
|
1169
|
+
...Option.toArray(access.draftColumn),
|
|
1170
|
+
...Option.toArray(access.moderationColumn),
|
|
1171
|
+
]
|
|
1172
|
+
: ["visibility", ...(access.followersOfColumn === undefined ? [] : [access.followersOfColumn])];
|
|
1173
|
+
/** The columns a live view stamps on a publish-gated leaf's rows. */
|
|
1174
|
+
const PUBLICATION_COLUMNS = ["draft", "publishedAt"];
|
|
1175
|
+
const checkLocaleLevel = (level, columns, gated) => {
|
|
1176
|
+
const gate = Array.findFirst(level.columns, (column) => gated.includes(column));
|
|
1177
|
+
if (Option.isSome(gate)) {
|
|
1178
|
+
throw new SubtypeDeclarationError({
|
|
1179
|
+
message: `locales names '${gate.value}', which the read gate reads: it must be shared`,
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
const undeclared = Array.findFirst([...level.columns, LOCALE_COLUMN], (column) => !columns.has(column));
|
|
1183
|
+
if (Option.isSome(undeclared)) {
|
|
1184
|
+
throw new SubtypeDeclarationError({
|
|
1185
|
+
message: `locales names '${undeclared.value}', which is not a column of the view`,
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
};
|
|
1189
|
+
/** The keys of a patch that set or clear a value (an `undefined` key carries forward). */
|
|
1190
|
+
const writtenKeys = (patch) => Array.filter(Record.keys(patch), (key) => patch[key] !== undefined);
|
|
1191
|
+
/**
|
|
1192
|
+
* Why a write breaks the locale level, or none: `translated` is the leaf's
|
|
1193
|
+
* declared columns (none for a leaf with no locale level).
|
|
1194
|
+
*/
|
|
1195
|
+
const localeViolation = (translated, state, locales) => {
|
|
1196
|
+
const tags = writtenKeys(locales);
|
|
1197
|
+
if (Option.isNone(translated)) {
|
|
1198
|
+
return Array.isReadonlyArrayNonEmpty(tags)
|
|
1199
|
+
? Option.some("the leaf declares no locale level")
|
|
1200
|
+
: Option.none();
|
|
1201
|
+
}
|
|
1202
|
+
const isTranslated = (column) => translated.value.includes(column);
|
|
1203
|
+
const shared = Array.findFirst(writtenKeys(state), isTranslated);
|
|
1204
|
+
if (Option.isSome(shared)) {
|
|
1205
|
+
return Option.some(`'${shared.value}' is translated: write it per locale`);
|
|
1206
|
+
}
|
|
1207
|
+
if (tags.some(Str.isEmpty))
|
|
1208
|
+
return Option.some("a locale tag is empty");
|
|
1209
|
+
return Array.findFirst(tags, (tag) => Option.fromNullishOr(locales[tag]).pipe(Option.flatMap((change) => Array.findFirst(writtenKeys(change), (column) => !isTranslated(column))), Option.map((column) => `locale '${tag}' names '${column}', a shared column: write it once, for every locale`)));
|
|
1210
|
+
};
|
|
1211
|
+
/**
|
|
1212
|
+
* Declare a {@link LocaleLevel}. Throws {@link SubtypeDeclarationError} at
|
|
1213
|
+
* import, as a registry violation is, for no column, a column named twice or
|
|
1214
|
+
* one the view keeps for itself (`id`, `version`, `action`,
|
|
1215
|
+
* {@link LOCALE_COLUMN}); {@link defineSubtype} checks the columns against
|
|
1216
|
+
* the view.
|
|
1217
|
+
*
|
|
1218
|
+
* @example
|
|
1219
|
+
* ```ts
|
|
1220
|
+
* import { deepStrictEqual } from "node:assert";
|
|
1221
|
+
* import { defineLocaleLevel } from "@thomasfosterau/pulpit/subtype";
|
|
1222
|
+
*
|
|
1223
|
+
* const pages = defineLocaleLevel({ columns: ["title", "body", "slug"] });
|
|
1224
|
+
* deepStrictEqual(pages.columns, ["title", "body", "slug"]);
|
|
1225
|
+
* ```
|
|
1226
|
+
*
|
|
1227
|
+
* @since 0.1.0
|
|
1228
|
+
*/
|
|
1229
|
+
export const defineLocaleLevel = (declaration) => {
|
|
1230
|
+
if (!Array.isReadonlyArrayNonEmpty(declaration.columns)) {
|
|
1231
|
+
throw new SubtypeDeclarationError({ message: "locales declares no column" });
|
|
1232
|
+
}
|
|
1233
|
+
const repeated = Array.findFirst(declaration.columns, (column, index) => declaration.columns.slice(0, index).includes(column));
|
|
1234
|
+
if (Option.isSome(repeated)) {
|
|
1235
|
+
throw new SubtypeDeclarationError({ message: `locales names '${repeated.value}' twice` });
|
|
1236
|
+
}
|
|
1237
|
+
const reserved = Array.findFirst(declaration.columns, (column) => HashSet.has(RESERVED_COLUMNS, column));
|
|
1238
|
+
if (Option.isSome(reserved)) {
|
|
1239
|
+
throw new SubtypeDeclarationError({
|
|
1240
|
+
message: `locales names '${reserved.value}', which the view keeps for itself`,
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
return { columns: [...declaration.columns] };
|
|
1244
|
+
};
|
|
1245
|
+
// ── Owners across leaves ──────────────────────────────────────────────
|
|
1246
|
+
/**
|
|
1247
|
+
* {@link readableOwners} refused a union it could not make exact: a leaf
|
|
1248
|
+
* has more readable owners than one read returns ({@link MAX_PAGE_LIMIT}),
|
|
1249
|
+
* so the union and its total would be short (`SEC-INPUT-003`).
|
|
1250
|
+
*
|
|
1251
|
+
* @since 0.1.0
|
|
1252
|
+
*/
|
|
1253
|
+
export class OwnerSetTooLarge extends Schema.TaggedError()("OwnerSetTooLarge", {
|
|
1254
|
+
type: Schema.String,
|
|
1255
|
+
}) {
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* The accounts that own at least one row the viewer may list in any of
|
|
1259
|
+
* `leaves` (pulpit#186): the union of each leaf's
|
|
1260
|
+
* {@link SubtypeOps.owners}, deduplicated in code-point order, paged by
|
|
1261
|
+
* `request`, with the exact union size as `total`. One read per leaf, each
|
|
1262
|
+
* for every owner it has (up to {@link MAX_PAGE_LIMIT}): a leaf with more
|
|
1263
|
+
* fails {@link OwnerSetTooLarge} rather than answer a short union. With
|
|
1264
|
+
* `among` (at most `MAX_SET_VALUES` ids) no leaf can exceed it. A hidden row
|
|
1265
|
+
* never contributes its owner (`SEC-AUTH-ENUM-001`). Fails otherwise only
|
|
1266
|
+
* with the stores' `E`.
|
|
1267
|
+
*
|
|
1268
|
+
* @example
|
|
1269
|
+
* ```ts
|
|
1270
|
+
* import { deepStrictEqual } from "node:assert";
|
|
1271
|
+
* import * as Effect from "effect/Effect";
|
|
1272
|
+
* import { readableOwners } from "@thomasfosterau/pulpit/subtype";
|
|
1273
|
+
*
|
|
1274
|
+
* const leaf = (type: string, owners: ReadonlyArray<string>) => ({
|
|
1275
|
+
* type,
|
|
1276
|
+
* owners: () => Effect.succeed({ rows: owners, total: owners.length }),
|
|
1277
|
+
* });
|
|
1278
|
+
* const page = Effect.runSync(
|
|
1279
|
+
* readableOwners([leaf("posts", ["ana", "bo"]), leaf("pages", ["bo", "cy"])], {
|
|
1280
|
+
* limit: 10,
|
|
1281
|
+
* offset: 0,
|
|
1282
|
+
* includeDeleted: false,
|
|
1283
|
+
* }),
|
|
1284
|
+
* );
|
|
1285
|
+
* deepStrictEqual(page, { rows: ["ana", "bo", "cy"], total: 3 });
|
|
1286
|
+
* ```
|
|
1287
|
+
*
|
|
1288
|
+
* @since 0.1.0
|
|
1289
|
+
*/
|
|
1290
|
+
export const readableOwners = (leaves, request) => Effect.gen(function* () {
|
|
1291
|
+
const every = { ...request, limit: MAX_PAGE_LIMIT, offset: 0 };
|
|
1292
|
+
const pages = yield* Effect.forEach(leaves, (leaf) => Effect.flatMap(leaf.owners(every), (page) => page.total > page.rows.length
|
|
1293
|
+
? Effect.fail(new OwnerSetTooLarge({ type: leaf.type }))
|
|
1294
|
+
: Effect.succeed(page.rows)), { concurrency: 1 });
|
|
1295
|
+
const union = Arr.sort(Array.dedupe(Array.flatten(pages)), byCodePoint);
|
|
1296
|
+
const limit = Math.max(1, Math.min(Math.trunc(request.limit), MAX_PAGE_LIMIT));
|
|
1297
|
+
const offset = Math.max(0, Math.trunc(request.offset));
|
|
1298
|
+
return { rows: union.slice(offset, offset + limit), total: union.length };
|
|
1299
|
+
});
|
|
1300
|
+
//# sourceMappingURL=subtype.js.map
|