@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,1613 @@
|
|
|
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 Cause from "effect/Cause";
|
|
91
|
+
import * as Effect from "effect/Effect";
|
|
92
|
+
import * as HashMap from "effect/HashMap";
|
|
93
|
+
import * as Option from "effect/Option";
|
|
94
|
+
import * as Schema from "effect/Schema";
|
|
95
|
+
import { ResourceHandlers, type Assemble, type Handlers } from "@thomasfosterau/effect-jsonapi";
|
|
96
|
+
import type { Visibility } from "./content.ts";
|
|
97
|
+
import { type ListFilter } from "./filter.ts";
|
|
98
|
+
import { NodeNotFound, type ChainPatch, type ChainState, type ChainWrite, type ColumnValue, type LocalePatch, type LocaleStates, type Precondition, type StaleVersion, type VersionAction, type VersionChainStore, type VersionComparison, type VersionConflict, type VersionNotFound, type VersionSnapshot, type NodeAlreadyExists, type WriteAction, type ChainAppendEvent } from "./version-chain.ts";
|
|
99
|
+
/**
|
|
100
|
+
* The signed-in account the operation acts as: an id and a role on the application's ladder.
|
|
101
|
+
*
|
|
102
|
+
* @since 0.1.0
|
|
103
|
+
*/
|
|
104
|
+
export interface ViewerAccount {
|
|
105
|
+
readonly id: string;
|
|
106
|
+
readonly role: string;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The viewer: an account, or none for an anonymous request (effect-auth's session context).
|
|
110
|
+
*
|
|
111
|
+
* @since 0.1.0
|
|
112
|
+
*/
|
|
113
|
+
export type Viewer = Option.Option<ViewerAccount>;
|
|
114
|
+
/**
|
|
115
|
+
* The role axes the kernel asks about — each an application-named rung
|
|
116
|
+
* (`VIEW_PRIVATE_MIN_ROLE`, `VIEW_DELETED_MIN_ROLE`, `MODERATE_MIN_ROLE`,
|
|
117
|
+
* `CORPUS_WRITE_MIN_ROLE`) compared by the application's one
|
|
118
|
+
* `hasRoleAtLeast`, which must fail closed on an unknown role. effect-auth's
|
|
119
|
+
* role-ladder factory produces these.
|
|
120
|
+
*
|
|
121
|
+
* @since 0.1.0
|
|
122
|
+
*/
|
|
123
|
+
export interface AccessPolicy {
|
|
124
|
+
/** May see `private` rows and drafts (gardeneria: moderator+; website: admin). */
|
|
125
|
+
readonly canViewPrivate: (account: ViewerAccount) => boolean;
|
|
126
|
+
/** May see soft-deleted rows (gardeneria: moderator+; website: admin). */
|
|
127
|
+
readonly canViewDeleted: (account: ViewerAccount) => boolean;
|
|
128
|
+
/** May write someone else's owned row (gardeneria: moderator+). */
|
|
129
|
+
readonly canModerate: (account: ViewerAccount) => boolean;
|
|
130
|
+
/** May write reference data directly (gardeneria: contributor+). */
|
|
131
|
+
readonly canWriteCorpus: (account: ViewerAccount) => boolean;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The follower seam — the `followers` visibility state's one external fact.
|
|
135
|
+
* `isFollower` answers one row's question; `following` answers a list's, as
|
|
136
|
+
* whatever the list filter's `in` node takes (a subquery in SQL, #1107).
|
|
137
|
+
*
|
|
138
|
+
* @since 0.1.0
|
|
139
|
+
*/
|
|
140
|
+
export interface FollowerSeam<Following, R> {
|
|
141
|
+
/** Whether `followerId` follows `ownerId`: the single-row question `canViewOwned` asks. */
|
|
142
|
+
readonly isFollower: (followerId: string, ownerId: string) => Effect.Effect<boolean, never, R>;
|
|
143
|
+
/**
|
|
144
|
+
* The owners `followerId` follows, as the list filter's `in` set (a
|
|
145
|
+
* subquery in SQL, never a materialised array).
|
|
146
|
+
*/
|
|
147
|
+
readonly following: (followerId: string) => Following;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The fail-closed follower seam: nobody follows anybody. `nobody` is the
|
|
151
|
+
* matches-nothing value of the `in` node (SQL: `(select null::uuid where false)`).
|
|
152
|
+
*
|
|
153
|
+
* @since 0.1.0
|
|
154
|
+
*/
|
|
155
|
+
export declare const nobodyFollows: <Following>(nobody: Following) => FollowerSeam<Following, never>;
|
|
156
|
+
declare const Unauthorized_base: Schema.Class<Unauthorized, Schema.TaggedStruct<"Unauthorized", {}>, Cause.YieldableError & {
|
|
157
|
+
readonly brand: unique symbol;
|
|
158
|
+
}>;
|
|
159
|
+
/**
|
|
160
|
+
* No signed-in viewer where one is required: the wire's 401.
|
|
161
|
+
*
|
|
162
|
+
* @since 0.1.0
|
|
163
|
+
*/
|
|
164
|
+
export declare class Unauthorized extends Unauthorized_base {
|
|
165
|
+
}
|
|
166
|
+
declare const Forbidden_base: Schema.Class<Forbidden, Schema.TaggedStruct<"Forbidden", {}>, Cause.YieldableError & {
|
|
167
|
+
readonly brand: unique symbol;
|
|
168
|
+
}>;
|
|
169
|
+
/**
|
|
170
|
+
* A signed-in viewer below the rung a write needs: the wire's 403. Never used to hide a row.
|
|
171
|
+
*
|
|
172
|
+
* @since 0.1.0
|
|
173
|
+
*/
|
|
174
|
+
export declare class Forbidden extends Forbidden_base {
|
|
175
|
+
}
|
|
176
|
+
declare const InvalidListOrder_base: Schema.Class<InvalidListOrder, Schema.TaggedStruct<"InvalidListOrder", {
|
|
177
|
+
readonly type: Schema.String;
|
|
178
|
+
readonly column: Schema.String;
|
|
179
|
+
}>, Cause.YieldableError & {
|
|
180
|
+
readonly brand: unique symbol;
|
|
181
|
+
}>;
|
|
182
|
+
/**
|
|
183
|
+
* A list order the leaf does not sort by: a key whose column is not sortable
|
|
184
|
+
* (outside the leaf's `sortable` allow-list or, without one, outside the
|
|
185
|
+
* view's declared `LeafStore.columns`), a direction other than `asc` /
|
|
186
|
+
* `desc`, or input not shaped as an order at all (an `orderBy` that is not
|
|
187
|
+
* an array, a key that is not an object, a `column` that is not a string).
|
|
188
|
+
* The wire's 400. The order is checked before the viewer or anything else is
|
|
189
|
+
* read, so it says nothing about any row, but **it does say which columns
|
|
190
|
+
* are sortable**: without a `sortable` allow-list, that a name is (or is
|
|
191
|
+
* not) one of the view's columns. `column` is the caller's own key, never
|
|
192
|
+
* passed to the store, or `""` when the key carries no string column
|
|
193
|
+
* (pulpit#112).
|
|
194
|
+
*
|
|
195
|
+
* @since 0.1.0
|
|
196
|
+
*/
|
|
197
|
+
export declare class InvalidListOrder extends InvalidListOrder_base {
|
|
198
|
+
}
|
|
199
|
+
declare const SubtypeDeclarationError_base: Schema.Class<SubtypeDeclarationError, Schema.TaggedStruct<"SubtypeDeclarationError", {
|
|
200
|
+
readonly message: Schema.String;
|
|
201
|
+
}>, Cause.YieldableError & {
|
|
202
|
+
readonly brand: unique symbol;
|
|
203
|
+
}>;
|
|
204
|
+
/**
|
|
205
|
+
* A leaf declared against the rules: a `sortable` entry that is not one of
|
|
206
|
+
* its view's `LeafStore.columns`. Thrown by {@link defineSubtype} when the
|
|
207
|
+
* leaf is declared, never an Effect failure, like `./edges`'
|
|
208
|
+
* `EdgeDeclarationError`: a misspelt allow-list entry must stop its module
|
|
209
|
+
* loading rather than silently leave a column unsortable (pulpit#112).
|
|
210
|
+
*
|
|
211
|
+
* @since 0.1.0
|
|
212
|
+
*/
|
|
213
|
+
export declare class SubtypeDeclarationError extends SubtypeDeclarationError_base {
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* The viewer's owned-material read scope — the three facts a LIST needs to
|
|
217
|
+
* build its filter from, and the set-shaped counterpart of
|
|
218
|
+
* {@link canViewOwned}:
|
|
219
|
+
*
|
|
220
|
+
* - `all` — the view-private rung: no visibility filter;
|
|
221
|
+
* - `public` — anonymous: `visibility = 'public'` only;
|
|
222
|
+
* - `member` — a signed-in account: `public`, OR their own rows, OR
|
|
223
|
+
* `followers` rows whose owner they follow.
|
|
224
|
+
*
|
|
225
|
+
* @since 0.1.0
|
|
226
|
+
*/
|
|
227
|
+
export type ReadScope<Following> = {
|
|
228
|
+
readonly kind: "all";
|
|
229
|
+
} | {
|
|
230
|
+
readonly kind: "public";
|
|
231
|
+
} | {
|
|
232
|
+
readonly kind: "member";
|
|
233
|
+
readonly userId: string;
|
|
234
|
+
readonly following: Following;
|
|
235
|
+
};
|
|
236
|
+
/**
|
|
237
|
+
* Resolve a viewer's {@link ReadScope}.
|
|
238
|
+
*
|
|
239
|
+
* @since 0.1.0
|
|
240
|
+
*/
|
|
241
|
+
export declare const readScopeFor: <Following>(viewer: Viewer, policy: AccessPolicy, followers: FollowerSeam<Following, unknown>) => ReadScope<Following>;
|
|
242
|
+
/**
|
|
243
|
+
* The facts {@link canViewOwned} reads off an owned row. `draft: false` for a leaf without the axis.
|
|
244
|
+
*
|
|
245
|
+
* @since 0.1.0
|
|
246
|
+
*/
|
|
247
|
+
export interface OwnedFacts {
|
|
248
|
+
readonly ownerId: string;
|
|
249
|
+
readonly visibility: Visibility;
|
|
250
|
+
readonly draft: boolean;
|
|
251
|
+
/**
|
|
252
|
+
* A moderated row whose state is not exactly `approved` (pulpit#154);
|
|
253
|
+
* `false` for a leaf without the axis.
|
|
254
|
+
*/
|
|
255
|
+
readonly unapproved: boolean;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* The owned-material read predicate — the single interpretation of the
|
|
259
|
+
* three-state visibility axis plus the orthogonal `draft` axis (#1118):
|
|
260
|
+
*
|
|
261
|
+
* | visibility | who may view |
|
|
262
|
+
* | ----------- | -------------------------------------------------------------- |
|
|
263
|
+
* | `public` | everyone, anonymous included |
|
|
264
|
+
* | `followers` | the owner; the owner's followers; the view-private rung |
|
|
265
|
+
* | `private` | the owner; the view-private rung |
|
|
266
|
+
*
|
|
267
|
+
* `draft: true` narrows whatever `visibility` allows down to the owner and
|
|
268
|
+
* the rung — a `public` draft is exactly as hidden as a `private` one — and
|
|
269
|
+
* so does `unapproved: true` on a moderated leaf (a `pending` or `rejected`
|
|
270
|
+
* row reads only to its author and the rung, pulpit#154). Never
|
|
271
|
+
* fails; callers turn `false` into {@link NodeNotFound}.
|
|
272
|
+
*
|
|
273
|
+
* `defineSubtype`'s `get` / `getMany` and `./edges`' `canViewEndpoint`
|
|
274
|
+
* apply the same predicate to the row they read. There, an owner column
|
|
275
|
+
* holding no string (a `NULL`, an absent key) reads as no owner: no viewer
|
|
276
|
+
* is its owner and the follower seam is never asked, so the row shows only
|
|
277
|
+
* when `public`, not a draft and not unapproved, or to the rung, as {@link ownedListFilter}
|
|
278
|
+
* lists it (`SEC-AUTH-ENUM-001`).
|
|
279
|
+
*
|
|
280
|
+
* @since 0.1.0
|
|
281
|
+
*/
|
|
282
|
+
export declare const canViewOwned: <R>(viewer: Viewer, policy: AccessPolicy, followers: FollowerSeam<unknown, R>, row: OwnedFacts) => Effect.Effect<boolean, never, R>;
|
|
283
|
+
/**
|
|
284
|
+
* The gate selector, mirroring the registry's `referenceData` ⊕ `owned`
|
|
285
|
+
* flags: an `owned` leaf names the VIEW columns its read paths consult,
|
|
286
|
+
* `draftColumn` only when the leaf actually writes the flag, and
|
|
287
|
+
* `moderationColumn` only when it is a moderated family (`./moderation`)
|
|
288
|
+
* whose unapproved rows its readers must not see (pulpit#154). A `reference`
|
|
289
|
+
* leaf names `followersOfColumn` only when its rows may be `followers`-visible
|
|
290
|
+
* (pulpit#92).
|
|
291
|
+
*
|
|
292
|
+
* @since 0.1.0
|
|
293
|
+
*/
|
|
294
|
+
export type LeafAccess = {
|
|
295
|
+
readonly kind: "reference";
|
|
296
|
+
/**
|
|
297
|
+
* The view column that plays the owner's part for a `followers` row:
|
|
298
|
+
* it holds the id of the account whose followers may read the row
|
|
299
|
+
* (`PUB-009`). The column is the application's, and holds whatever id
|
|
300
|
+
* its follower seam takes; no id brand is implied. Only the followers
|
|
301
|
+
* check reads it: the account it names and that account's followers
|
|
302
|
+
* read its `followers` rows, but it gets no other owner rights (a
|
|
303
|
+
* `private` row stays the view-private rung's alone), and a value that
|
|
304
|
+
* is not a string names nobody. Absent, or naming a column the view
|
|
305
|
+
* lacks, a `followers` row is hidden below the view-private rung.
|
|
306
|
+
*
|
|
307
|
+
* @since 0.1.0
|
|
308
|
+
*/
|
|
309
|
+
readonly followersOfColumn?: string | undefined;
|
|
310
|
+
} | {
|
|
311
|
+
readonly kind: "owned";
|
|
312
|
+
readonly ownerColumn: string;
|
|
313
|
+
readonly visibilityColumn: string;
|
|
314
|
+
readonly draftColumn: Option.Option<string>;
|
|
315
|
+
/**
|
|
316
|
+
* The moderation state column (`ModerationState`). Declared, a row
|
|
317
|
+
* whose state is not exactly `approved` reads only to its owner and
|
|
318
|
+
* the view-private rung, as a draft does; absent, the leaf's reads
|
|
319
|
+
* ignore moderation and the rule is the application's (`PUB-005`).
|
|
320
|
+
* Owned leaves only: a reference leaf has no such axis.
|
|
321
|
+
*
|
|
322
|
+
* @since 0.1.0
|
|
323
|
+
*/
|
|
324
|
+
readonly moderationColumn: Option.Option<string>;
|
|
325
|
+
};
|
|
326
|
+
/**
|
|
327
|
+
* The owned-leaf access declaration with the conventional factory-built column names.
|
|
328
|
+
*
|
|
329
|
+
* @since 0.1.0
|
|
330
|
+
*/
|
|
331
|
+
export declare const ownedAccess: (options?: {
|
|
332
|
+
readonly draft?: boolean | undefined;
|
|
333
|
+
readonly moderation?: boolean | undefined;
|
|
334
|
+
}) => Extract<LeafAccess, {
|
|
335
|
+
kind: "owned";
|
|
336
|
+
}>;
|
|
337
|
+
/**
|
|
338
|
+
* The owned-leaf LIST filter — {@link ReadScope} rendered over the leaf's
|
|
339
|
+
* declared view columns. When the leaf carries a draft column, every
|
|
340
|
+
* non-owner arm additionally requires `draft = false`, and when it carries a
|
|
341
|
+
* moderation column, `moderation = 'approved'`; the owner's own arm never
|
|
342
|
+
* does, so a member lists their own drafts and unapproved rows. Columns the view lacks
|
|
343
|
+
* fail closed.
|
|
344
|
+
*
|
|
345
|
+
* @since 0.1.0
|
|
346
|
+
*/
|
|
347
|
+
export declare const ownedListFilter: <Following>(access: Extract<LeafAccess, {
|
|
348
|
+
kind: "owned";
|
|
349
|
+
}>, viewColumns: ReadonlySet<string>, scope: ReadScope<Following>) => ListFilter<Following>;
|
|
350
|
+
/**
|
|
351
|
+
* The soft-delete arm of every list: tombstones only when the caller asked
|
|
352
|
+
* AND holds the view-deleted rung — asking without the rung changes nothing.
|
|
353
|
+
*
|
|
354
|
+
* @since 0.1.0
|
|
355
|
+
*/
|
|
356
|
+
export declare const softDeleteFilter: <Following>(viewer: Viewer, policy: AccessPolicy, includeDeleted: boolean) => ListFilter<Following>;
|
|
357
|
+
/**
|
|
358
|
+
* The reference-leaf privacy arm (`PUB-009`): `visibility = 'public'` unless
|
|
359
|
+
* the viewer holds the view-private rung, or the view has no visibility
|
|
360
|
+
* column (an Entity leaf rides no such axis). Given a followers arm (the
|
|
361
|
+
* leaf's `access`, declaring `followersOfColumn` over a view that carries
|
|
362
|
+
* it, and the follower seam), a signed-in viewer also lists the `followers`
|
|
363
|
+
* rows whose column names them or an account they follow (pulpit#92):
|
|
364
|
+
* `visibility = 'followers' AND (<followersOfColumn> = <viewer> OR
|
|
365
|
+
* <followersOfColumn> IN (<following>))`. Without one (the default,
|
|
366
|
+
* `Option.none()`), a `followers` row is the view-private rung's alone.
|
|
367
|
+
* Pass `Option.some({ access, followers })` whenever the leaf declares
|
|
368
|
+
* `followersOfColumn`: `defineSubtype`'s `get` / `getMany` read the
|
|
369
|
+
* declared column, so the three-argument form agrees with them only for a
|
|
370
|
+
* leaf without it (for one with it, `get` serves `followers` rows this
|
|
371
|
+
* hides).
|
|
372
|
+
*
|
|
373
|
+
* @since 0.1.0
|
|
374
|
+
*/
|
|
375
|
+
export declare const referencePrivacyFilter: <Following>(viewer: Viewer, policy: AccessPolicy, viewColumns: ReadonlySet<string>, followersArm?: Option.Option<{
|
|
376
|
+
readonly access: Extract<LeafAccess, {
|
|
377
|
+
kind: "reference";
|
|
378
|
+
}>;
|
|
379
|
+
readonly followers: FollowerSeam<Following, unknown>;
|
|
380
|
+
}>) => ListFilter<Following>;
|
|
381
|
+
/**
|
|
382
|
+
* The gate for a CREATE: reference data → the corpus-write rung; owned → any signed-in account.
|
|
383
|
+
*
|
|
384
|
+
* @since 0.1.0
|
|
385
|
+
*/
|
|
386
|
+
export declare const requireCreator: (access: LeafAccess, viewer: Viewer, policy: AccessPolicy) => Effect.Effect<ViewerAccount, Unauthorized | Forbidden>;
|
|
387
|
+
/**
|
|
388
|
+
* The gate for a write to an EXISTING row: reference data → the corpus-write
|
|
389
|
+
* rung; owned material → the row's owner or the moderate rung. A missing
|
|
390
|
+
* owner on an owned leaf is {@link NodeNotFound}, never an open gate.
|
|
391
|
+
*
|
|
392
|
+
* On an owned leaf it never reads the row's visibility, draft or delete state:
|
|
393
|
+
* a signed-in caller who is neither the owner nor at the moderate rung gets
|
|
394
|
+
* {@link Forbidden} for any row with an owner record,
|
|
395
|
+
* hidden or not, so the gate deliberately confirms existence (pulpit#24, an
|
|
396
|
+
* exception to `SEC-AUTH-ENUM-001`; SECURITY.md → "Existence disclosure by the
|
|
397
|
+
* write gates"). An anonymous caller gets `Unauthorized` for every id.
|
|
398
|
+
*
|
|
399
|
+
* On a reference leaf this is the rung check alone: it reads no row, so it
|
|
400
|
+
* answers `Unauthorized` / `Forbidden` alike for every id. `defineSubtype`
|
|
401
|
+
* follows it with the read rung the row demands (pulpit#101): a row its `get`
|
|
402
|
+
* hides from the caller fails {@link NodeNotFound}, as `get` does
|
|
403
|
+
* (`PUB-009`, `SEC-AUTH-ENUM-001`). A caller using this gate directly on a
|
|
404
|
+
* reference row must apply that check too.
|
|
405
|
+
*
|
|
406
|
+
* @since 0.1.0
|
|
407
|
+
*/
|
|
408
|
+
export declare const requireWriter: <E, R>(access: LeafAccess, viewer: Viewer, policy: AccessPolicy, row: {
|
|
409
|
+
readonly type: string;
|
|
410
|
+
readonly id: string;
|
|
411
|
+
}, ownerOf: Effect.Effect<Option.Option<string>, E, R>) => Effect.Effect<ViewerAccount, Unauthorized | Forbidden | NodeNotFound | E, R>;
|
|
412
|
+
/**
|
|
413
|
+
* A current-version view row: its node id, its head `version` and `action`,
|
|
414
|
+
* and the leaf's columns. The view projects `version` from the head row
|
|
415
|
+
* (pulpit#65), so a read carries the version an `If-Match` must name: a
|
|
416
|
+
* mapper stamps it on the wire with {@link versionMeta}, and a
|
|
417
|
+
* single-resource response's `ETag` is {@link versionEtag}. `id`, `version`
|
|
418
|
+
* and `action` are the view's own, so no leaf column may take those names.
|
|
419
|
+
*
|
|
420
|
+
* @since 0.1.0
|
|
421
|
+
*/
|
|
422
|
+
export interface LeafRow {
|
|
423
|
+
readonly id: string;
|
|
424
|
+
/** The head version, the one an update's `expectedVersion` names. */
|
|
425
|
+
readonly version: bigint;
|
|
426
|
+
readonly action: VersionAction;
|
|
427
|
+
readonly [column: string]: ColumnValue;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* The resource-object `meta` a {@link SubtypeDefinition.toResource} mapper
|
|
431
|
+
* stamps: the row's head version as a decimal string (`bigint` has no JSON
|
|
432
|
+
* form). It reaches only a resource the read already serves the viewer, so it
|
|
433
|
+
* discloses nothing about a hidden row (`SEC-AUTH-ENUM-001`); what it does
|
|
434
|
+
* disclose about a shown one is how many versions its chain holds.
|
|
435
|
+
*
|
|
436
|
+
* @example
|
|
437
|
+
* ```ts
|
|
438
|
+
* import { strictEqual } from "node:assert";
|
|
439
|
+
* import { versionMeta } from "@thomasfosterau/pulpit/subtype";
|
|
440
|
+
*
|
|
441
|
+
* const row = { id: "post-1", version: 3n, action: "update" as const, title: "Hello" };
|
|
442
|
+
* const resource = { type: "posts", id: row.id, meta: versionMeta(row) };
|
|
443
|
+
* strictEqual(resource.meta.version, "3");
|
|
444
|
+
* ```
|
|
445
|
+
*
|
|
446
|
+
* @since 0.1.0
|
|
447
|
+
*/
|
|
448
|
+
export declare const versionMeta: (row: Pick<LeafRow, "version">) => VersionMeta;
|
|
449
|
+
/**
|
|
450
|
+
* The resource-object `meta` {@link versionMeta} stamps: the head version as
|
|
451
|
+
* a decimal string.
|
|
452
|
+
*
|
|
453
|
+
* @since 0.1.0
|
|
454
|
+
*/
|
|
455
|
+
export interface VersionMeta {
|
|
456
|
+
readonly version: string;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* What {@link versionEtag} reads: a resource object, by its optional `meta`.
|
|
460
|
+
*
|
|
461
|
+
* @since 0.1.0
|
|
462
|
+
*/
|
|
463
|
+
export interface VersionedResource {
|
|
464
|
+
readonly meta?: object | undefined;
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* A single-resource response's `ETag` header value, read from the
|
|
468
|
+
* resource's `meta.version` ({@link versionMeta}): a strong tag (`"3"`)
|
|
469
|
+
* whose value is the head version, so the `If-Match` a client echoes names
|
|
470
|
+
* the version its write expects ({@link UpdateRequest.expectedVersion}; the
|
|
471
|
+
* header's mapping onto it is effect-jsonapi's, which 0.17.0 lacks). None when the value carries no
|
|
472
|
+
* decimal `meta.version`; a collection response takes none, having no single
|
|
473
|
+
* version to tag. Never fails.
|
|
474
|
+
*
|
|
475
|
+
* @example
|
|
476
|
+
* ```ts
|
|
477
|
+
* import { strictEqual } from "node:assert";
|
|
478
|
+
* import { Option } from "effect";
|
|
479
|
+
* import { versionEtag } from "@thomasfosterau/pulpit/subtype";
|
|
480
|
+
*
|
|
481
|
+
* const read = { type: "posts", id: "post-1", meta: { version: "3" } };
|
|
482
|
+
* strictEqual(Option.getOrElse(versionEtag(read), () => "none"), '"3"');
|
|
483
|
+
* const unstamped = { type: "posts", id: "post-1", meta: {} };
|
|
484
|
+
* strictEqual(Option.isNone(versionEtag(unstamped)), true);
|
|
485
|
+
* ```
|
|
486
|
+
*
|
|
487
|
+
* @since 0.1.0
|
|
488
|
+
*/
|
|
489
|
+
export declare const versionEtag: (resource: VersionedResource) => Option.Option<string>;
|
|
490
|
+
/**
|
|
491
|
+
* One list page and the unpaged total of rows matching the filter.
|
|
492
|
+
*
|
|
493
|
+
* @since 0.1.0
|
|
494
|
+
*/
|
|
495
|
+
export interface Page<A> {
|
|
496
|
+
readonly rows: ReadonlyArray<A>;
|
|
497
|
+
readonly total: number;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* The hard cap on a page (`SEC-INPUT-003`): no request drives an unbounded `LIMIT`.
|
|
501
|
+
*
|
|
502
|
+
* @since 0.1.0
|
|
503
|
+
*/
|
|
504
|
+
export declare const MAX_PAGE_LIMIT = 1000;
|
|
505
|
+
/**
|
|
506
|
+
* One key of a list's order: a column of the leaf's current-version view and
|
|
507
|
+
* its direction. A list sorts by its keys in turn with a `NULL` (or absent)
|
|
508
|
+
* column after every value in **both** directions (`NULLS LAST`, which
|
|
509
|
+
* PostgreSQL's `DESC` does not default to), then by `id` ascending, so the
|
|
510
|
+
* order is total and an offset page never skips or repeats a row. A string
|
|
511
|
+
* sorts by code point (SQL's `COLLATE "C"`), a number or `bigint` by value, a
|
|
512
|
+
* boolean `false` first and a `Date` by its instant; `id` closes in byte
|
|
513
|
+
* order too (pulpit#112).
|
|
514
|
+
*
|
|
515
|
+
* **Only scalar orderable columns.** A sort key's column must hold one
|
|
516
|
+
* scalar orderable type: text, numeric, boolean, timestamp or bigint. A
|
|
517
|
+
* `json` / `jsonb` or array column is outside the contract (PostgreSQL
|
|
518
|
+
* refuses to order `json` and orders `jsonb` by its own rules, which no
|
|
519
|
+
* fake reproduces), so neither an adapter nor a leaf's `sortable` list may
|
|
520
|
+
* offer one.
|
|
521
|
+
*
|
|
522
|
+
* **What an order discloses.** The column need not be one the resource
|
|
523
|
+
* exposes: without a `sortable` allow-list on the leaf
|
|
524
|
+
* ({@link SubtypeDefinition.sortable}), **every** declared view column is
|
|
525
|
+
* sortable, so a caller can rank the rows it may already see by a column the
|
|
526
|
+
* API never shows it, and an {@link InvalidListOrder} tells it which names
|
|
527
|
+
* are view columns. No hidden row, nor any total, is revealed.
|
|
528
|
+
*
|
|
529
|
+
* @since 0.1.0
|
|
530
|
+
*/
|
|
531
|
+
export interface ListOrder {
|
|
532
|
+
/** A sortable column of the view; any other fails the list with {@link InvalidListOrder}. */
|
|
533
|
+
readonly column: string;
|
|
534
|
+
readonly direction: "asc" | "desc";
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* What `LeafStore.distinct` reads: the column, the rows' filter and the page
|
|
538
|
+
* window over the distinct values.
|
|
539
|
+
*
|
|
540
|
+
* @since 0.1.0
|
|
541
|
+
*/
|
|
542
|
+
export interface DistinctQuery<Following> {
|
|
543
|
+
readonly column: string;
|
|
544
|
+
readonly where: ListFilter<Following>;
|
|
545
|
+
readonly limit: number;
|
|
546
|
+
readonly offset: number;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* The read seam over a leaf's fail-closed current-version view, plus the
|
|
550
|
+
* owner stamp. `columns` is the view's column set — what the owned filter
|
|
551
|
+
* checks its declaration against.
|
|
552
|
+
*
|
|
553
|
+
* @since 0.1.0
|
|
554
|
+
*/
|
|
555
|
+
export interface LeafStore<Row extends LeafRow, Following, E, R> {
|
|
556
|
+
/** The view's column names; an owned declaration naming a column outside this set reads nothing (`get`, `getMany` or `list`). */
|
|
557
|
+
readonly columns: ReadonlySet<string>;
|
|
558
|
+
/** One row of the current-version view by id, or none. Applies no visibility policy. */
|
|
559
|
+
readonly get: (id: string) => Effect.Effect<Option.Option<Row>, E, R>;
|
|
560
|
+
/** One `id = ANY($ids)` read, never `ids.length` sequential gets. */
|
|
561
|
+
readonly getMany: (ids: ReadonlyArray<string>) => Effect.Effect<ReadonlyArray<Row>, E, R>;
|
|
562
|
+
/**
|
|
563
|
+
* The page under `where`, with the unpaged total, ordered totally by
|
|
564
|
+
* `orderBy` and then `id`: `ORDER BY <column> ASC|DESC NULLS LAST, …,
|
|
565
|
+
* id ASC`, every string key under `COLLATE "C"` (see {@link ListOrder}).
|
|
566
|
+
* `orderBy` arrives checked: each column is in `columns`, each direction
|
|
567
|
+
* `asc` or `desc`, no column twice; empty is `id` ascending alone. The
|
|
568
|
+
* order never changes which rows match `where`, nor `total`.
|
|
569
|
+
*/
|
|
570
|
+
readonly list: (query: {
|
|
571
|
+
readonly where: ListFilter<Following>;
|
|
572
|
+
readonly orderBy: ReadonlyArray<ListOrder>;
|
|
573
|
+
readonly limit: number;
|
|
574
|
+
readonly offset: number;
|
|
575
|
+
}) => Effect.Effect<Page<Row>, E, R>;
|
|
576
|
+
/**
|
|
577
|
+
* The distinct values of `column` among the view rows matching `where`
|
|
578
|
+
* (pulpit#186): `SELECT DISTINCT <column> … WHERE <where> AND <column> IS
|
|
579
|
+
* NOT NULL ORDER BY <column> COLLATE "C" LIMIT … OFFSET …`, with the
|
|
580
|
+
* unpaged distinct count as `total`. Only string values are distinct
|
|
581
|
+
* values; a `NULL` or non-string one is none. Applies no visibility
|
|
582
|
+
* policy.
|
|
583
|
+
*/
|
|
584
|
+
readonly distinct: (query: DistinctQuery<Following>) => Effect.Effect<Page<string>, E, R>;
|
|
585
|
+
/** The owner of an owned node (`core.node_owner`), or none. */
|
|
586
|
+
readonly ownerOf: (id: string) => Effect.Effect<Option.Option<string>, E, R>;
|
|
587
|
+
/** Record a freshly created owned node's owner. */
|
|
588
|
+
readonly stampOwner: (id: string, ownerId: string) => Effect.Effect<void, E, R>;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* One publication fact, as a {@link PublicationStore} records it: the
|
|
592
|
+
* version made live (none for an unpublish), the instant it takes effect
|
|
593
|
+
* (now, or a future one: a schedule), who recorded it and when. Facts are
|
|
594
|
+
* appended, never rewritten, so a node's publication history is data
|
|
595
|
+
* beside its version chain (`VERSION-CHAIN-001/002` are untouched).
|
|
596
|
+
*
|
|
597
|
+
* @since 0.1.0
|
|
598
|
+
*/
|
|
599
|
+
export interface PublicationFact {
|
|
600
|
+
readonly id: string;
|
|
601
|
+
/** The version made live, or none for an unpublish. */
|
|
602
|
+
readonly version: Option.Option<bigint>;
|
|
603
|
+
/** When the fact takes effect; a future instant schedules it. */
|
|
604
|
+
readonly at: Date;
|
|
605
|
+
readonly actorId: Option.Option<string>;
|
|
606
|
+
readonly recordedAt: Date;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* The append-only publication log an application supplies (README → Seams).
|
|
610
|
+
* The **effective** fact is the most recently recorded one whose `at` has
|
|
611
|
+
* arrived: a later record supersedes an earlier schedule, and a schedule
|
|
612
|
+
* takes effect at its stamp with no write (the read-time gate,
|
|
613
|
+
* `isPublishedAt`). Applies no policy and fails only with its `E`.
|
|
614
|
+
*
|
|
615
|
+
* @since 0.1.0
|
|
616
|
+
*/
|
|
617
|
+
export interface PublicationStore<E, R> {
|
|
618
|
+
/** Append one fact. */
|
|
619
|
+
readonly record: (fact: PublicationFact) => Effect.Effect<void, E, R>;
|
|
620
|
+
/** The node's effective fact at the store's clock (`now()`), or none. */
|
|
621
|
+
readonly effective: (id: string) => Effect.Effect<Option.Option<PublicationFact>, E, R>;
|
|
622
|
+
/** The node's facts, oldest first, at most `limit` of them. */
|
|
623
|
+
readonly facts: (id: string, limit: number) => Effect.Effect<ReadonlyArray<PublicationFact>, E, R>;
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* A publish-gated leaf's live-version view, beside its current-version
|
|
627
|
+
* `LeafStore`: per node, the row at the version its effective publication
|
|
628
|
+
* fact names, carrying that version as its `version` (the representation
|
|
629
|
+
* it serves, so its `meta.version` and ETag change only when what readers
|
|
630
|
+
* see does, and never disclose an unpublished edit), the node's head
|
|
631
|
+
* `action` (so a tombstone hides it too), `draft: false`, and that fact's
|
|
632
|
+
* `at` as the row's publication stamp.
|
|
633
|
+
* A node with no live version (never published, or unpublished) has no
|
|
634
|
+
* row: `get` answers none and `getMany` omits it. Applies no visibility
|
|
635
|
+
* policy.
|
|
636
|
+
*
|
|
637
|
+
* @since 0.1.0
|
|
638
|
+
*/
|
|
639
|
+
export interface LiveView<Row extends LeafRow, Following, E, R> extends Pick<LeafStore<Row, Following, E, R>, "get" | "getMany"> {
|
|
640
|
+
/**
|
|
641
|
+
* `LeafStore.distinct` over the rows {@link LiveView.list} chooses for
|
|
642
|
+
* the same `headFor` (pulpit#186): a node counts with the row the viewer
|
|
643
|
+
* reads, its head or its live version, or not at all.
|
|
644
|
+
*/
|
|
645
|
+
readonly distinct: (query: DistinctQuery<Following> & {
|
|
646
|
+
readonly headFor: ListFilter<Following>;
|
|
647
|
+
}) => Effect.Effect<Page<string>, E, R>;
|
|
648
|
+
/**
|
|
649
|
+
* The page under `where`, ordered as `LeafStore.list` orders, over one
|
|
650
|
+
* row per node chosen per viewer: the **head** row (as the current-version
|
|
651
|
+
* view has it, with `draft` = no live version) for a node whose head
|
|
652
|
+
* matches `headFor` (the viewer's own rows), else the live row, else
|
|
653
|
+
* none.
|
|
654
|
+
*/
|
|
655
|
+
readonly list: (query: Parameters<LeafStore<Row, Following, E, R>["list"]>[0] & {
|
|
656
|
+
readonly headFor: ListFilter<Following>;
|
|
657
|
+
}) => Effect.Effect<Page<Row>, E, R>;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* A leaf's opt-in to the live pointer (`publishGated`, owner decision on
|
|
661
|
+
* pulpit#46): its live view, its publication log, and the request's
|
|
662
|
+
* preview grant. Without it, head and live are one.
|
|
663
|
+
*
|
|
664
|
+
* @since 0.1.0
|
|
665
|
+
*/
|
|
666
|
+
export interface PublicationDeclaration<Row extends LeafRow, Following, E, R> {
|
|
667
|
+
readonly live: LiveView<Row, Following, E, R>;
|
|
668
|
+
readonly facts: PublicationStore<E, R>;
|
|
669
|
+
/**
|
|
670
|
+
* The node id this request holds a preview grant for (a signed, expiring
|
|
671
|
+
* token the application or effect-web verified): `get` of exactly that
|
|
672
|
+
* node serves its head, whoever asks, unless it is tombstoned. Signing
|
|
673
|
+
* and verifying the token is the application's. Absent, no request
|
|
674
|
+
* previews.
|
|
675
|
+
*/
|
|
676
|
+
readonly preview?: Effect.Effect<Option.Option<string>, never, R> | undefined;
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* What `publish` / `unpublish` report: the fact recorded, and the node's
|
|
680
|
+
* head version when it was.
|
|
681
|
+
*
|
|
682
|
+
* @since 0.1.0
|
|
683
|
+
*/
|
|
684
|
+
export interface PublicationWrite {
|
|
685
|
+
readonly fact: PublicationFact;
|
|
686
|
+
readonly head: bigint;
|
|
687
|
+
}
|
|
688
|
+
declare const NotPublished_base: Schema.Class<NotPublished, Schema.TaggedStruct<"NotPublished", {
|
|
689
|
+
readonly type: Schema.String;
|
|
690
|
+
readonly id: Schema.String;
|
|
691
|
+
}>, Cause.YieldableError & {
|
|
692
|
+
readonly brand: unique symbol;
|
|
693
|
+
}>;
|
|
694
|
+
/**
|
|
695
|
+
* A publication operation on a node with no live version (`discardDraft`
|
|
696
|
+
* with nothing published): the wire's 409.
|
|
697
|
+
*
|
|
698
|
+
* @since 0.1.0
|
|
699
|
+
*/
|
|
700
|
+
export declare class NotPublished extends NotPublished_base {
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* What `publish` makes live, and when.
|
|
704
|
+
*
|
|
705
|
+
* @since 0.1.0
|
|
706
|
+
*/
|
|
707
|
+
export interface PublishOptions {
|
|
708
|
+
/** The version to make live; the head when omitted. */
|
|
709
|
+
readonly version?: bigint | undefined;
|
|
710
|
+
/** When it takes effect; now when omitted, and a future instant schedules it. */
|
|
711
|
+
readonly at?: Date | undefined;
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* A publish-gated leaf's publication operations. Each runs the leaf's write
|
|
715
|
+
* gate first, and its before hooks (with action `publish` / `unpublish`,
|
|
716
|
+
* an empty patch whose return is ignored: a publication writes no
|
|
717
|
+
* version) before recording; a refusing hook records nothing.
|
|
718
|
+
*
|
|
719
|
+
* @since 0.1.0
|
|
720
|
+
*/
|
|
721
|
+
export interface PublicationOps<E, R> {
|
|
722
|
+
/**
|
|
723
|
+
* Make `version` (the head when omitted) live at `at` (now when
|
|
724
|
+
* omitted; a future instant schedules it, and readers see it once it
|
|
725
|
+
* arrives). Fails as `update` does at the gate, `NodeNotFound` for a
|
|
726
|
+
* missing or tombstoned node, and `VersionNotFound` for a version that
|
|
727
|
+
* has no row or is a tombstone. Its lifecycle event fires now, not when
|
|
728
|
+
* `at` arrives.
|
|
729
|
+
*/
|
|
730
|
+
readonly publish: (id: string, options?: PublishOptions) => Effect.Effect<PublicationWrite, Gate | WriteRejected | NodeNotFound | VersionNotFound | E, R>;
|
|
731
|
+
/**
|
|
732
|
+
* Take the node's live version down, now. Fails as `update` does at the
|
|
733
|
+
* gate, and `NodeNotFound` for a node with no versions.
|
|
734
|
+
*/
|
|
735
|
+
readonly unpublish: (id: string) => Effect.Effect<PublicationWrite, Gate | WriteRejected | NodeNotFound | E, R>;
|
|
736
|
+
/**
|
|
737
|
+
* Throw the unpublished head away: append the live version's state as a
|
|
738
|
+
* new version (`restoreVersion`, never a rewrite). A head that already is
|
|
739
|
+
* the live version writes nothing. Fails `NotPublished` without a live
|
|
740
|
+
* version, and as `restoreVersion` does.
|
|
741
|
+
*/
|
|
742
|
+
readonly discardDraft: (id: string, precondition?: Precondition) => Effect.Effect<ChainWrite, Gate | WriteRejected | NodeNotFound | NotPublished | VersionNotFound | StaleVersion | VersionConflict | E, R>;
|
|
743
|
+
/** The node's publication facts, oldest first; gated like `getVersion` (`READ-009`). */
|
|
744
|
+
readonly facts: (id: string) => Effect.Effect<ReadonlyArray<PublicationFact>, Gate | NodeNotFound | E, R>;
|
|
745
|
+
}
|
|
746
|
+
declare const WriteRejected_base: Schema.Class<WriteRejected, Schema.TaggedStruct<"WriteRejected", {
|
|
747
|
+
readonly type: Schema.String;
|
|
748
|
+
readonly id: Schema.String;
|
|
749
|
+
readonly action: Schema.String;
|
|
750
|
+
readonly reason: Schema.String;
|
|
751
|
+
}>, Cause.YieldableError & {
|
|
752
|
+
readonly brand: unique symbol;
|
|
753
|
+
}>;
|
|
754
|
+
/**
|
|
755
|
+
* A before hook's refusal: the write it was shown does not happen, and the
|
|
756
|
+
* caller's transaction rolls back with it. `reason` is the hook's own,
|
|
757
|
+
* for the wire's 422 (or whatever the application maps it to).
|
|
758
|
+
*
|
|
759
|
+
* @since 0.1.0
|
|
760
|
+
*/
|
|
761
|
+
export declare class WriteRejected extends WriteRejected_base {
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* What a {@link BeforeWriteHook} is shown: the chain's {@link ChainAppendEvent}
|
|
765
|
+
* (the action, the node, the head the write extends, the patch), plus the
|
|
766
|
+
* leaf's type and the request's viewer.
|
|
767
|
+
*
|
|
768
|
+
* @since 0.1.0
|
|
769
|
+
*/
|
|
770
|
+
export interface BeforeWriteEvent extends ChainAppendEvent {
|
|
771
|
+
readonly type: string;
|
|
772
|
+
readonly viewer: Viewer;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* A hook that runs inside the write, after its gate and its head read and
|
|
776
|
+
* before the version is appended, in the caller's transaction. It returns
|
|
777
|
+
* the patch the write applies (the event's, or a transformed one: a derived
|
|
778
|
+
* slug, a normalised field), or fails the write, appending nothing, with
|
|
779
|
+
* {@link WriteRejected} or the store's `E` (an outbox insert that failed). A
|
|
780
|
+
* side effect that must not be lost belongs here, as a row the same
|
|
781
|
+
* transaction writes (an outbox), never in an {@link AfterWriteHook}.
|
|
782
|
+
*
|
|
783
|
+
* @since 0.1.0
|
|
784
|
+
*/
|
|
785
|
+
export interface BeforeWriteHook<E, R> {
|
|
786
|
+
/** Names the hook in its span and in a logged failure. */
|
|
787
|
+
readonly name: string;
|
|
788
|
+
readonly run: (event: BeforeWriteEvent) => Effect.Effect<ChainPatch, WriteRejected | E, R>;
|
|
789
|
+
/**
|
|
790
|
+
* The hook's locale-level pass on a localised leaf (pulpit#56): shown the
|
|
791
|
+
* same event, it returns the {@link LocalePatch} the write applies (the
|
|
792
|
+
* event's `locales`, or a transformed one), or fails the write as `run`
|
|
793
|
+
* does. **Fails closed:** a write carrying a locale patch through a hook
|
|
794
|
+
* that declares no `locales` is refused with {@link WriteRejected}, so a
|
|
795
|
+
* hook that validates or rewrites a column can never be bypassed by
|
|
796
|
+
* writing it per locale. A hook with nothing to do per locale passes the
|
|
797
|
+
* event's patch through (`(event) => Effect.succeed(event.locales ?? {})`).
|
|
798
|
+
*/
|
|
799
|
+
readonly locales?: ((event: BeforeWriteEvent) => Effect.Effect<LocalePatch, WriteRejected | E, R>) | undefined;
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* What an {@link AfterWriteHook} is shown, once the write has committed:
|
|
803
|
+
* the leaf's type, the action, the version entry the write landed at, and
|
|
804
|
+
* the resource as its writer now reads it (none for a `delete`, or when the
|
|
805
|
+
* write hid the row from its own writer or the re-read failed).
|
|
806
|
+
*
|
|
807
|
+
* @since 0.1.0
|
|
808
|
+
*/
|
|
809
|
+
export interface AfterWriteEvent<Resource> {
|
|
810
|
+
readonly type: string;
|
|
811
|
+
readonly action: WriteAction;
|
|
812
|
+
/**
|
|
813
|
+
* The version the write landed at; for a `publish` or `unpublish`, which
|
|
814
|
+
* append no version, the node's head, so the resource cache's
|
|
815
|
+
* compare-on-set orders it after the writes before it (pulpit#121). The
|
|
816
|
+
* version a `publish` made live is the fact's.
|
|
817
|
+
*/
|
|
818
|
+
readonly write: ChainWrite;
|
|
819
|
+
readonly resource: Option.Option<Resource>;
|
|
820
|
+
/** The fact a `publish` / `unpublish` recorded; none for a chain write. */
|
|
821
|
+
readonly publication: Option.Option<PublicationFact>;
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* A hook that runs after the write's transaction has committed
|
|
825
|
+
* ({@link subtypeHandlers}, which owns the boundary). It never fails the
|
|
826
|
+
* write: its failure, a defect included, goes to the hooks'
|
|
827
|
+
* {@link LifecycleHooks.afterFailure} policy, and the next hook still runs
|
|
828
|
+
* under `"log"`. It runs uninterruptibly, so a committed write always
|
|
829
|
+
* reaches it; a delivery that must survive a crash is an outbox row
|
|
830
|
+
* written by a {@link BeforeWriteHook} instead.
|
|
831
|
+
*
|
|
832
|
+
* @since 0.1.0
|
|
833
|
+
*/
|
|
834
|
+
export interface AfterWriteHook<Resource, E, R> {
|
|
835
|
+
/** Names the hook in its span and in a logged failure. */
|
|
836
|
+
readonly name: string;
|
|
837
|
+
readonly run: (event: AfterWriteEvent<Resource>) => Effect.Effect<void, E, R>;
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* A leaf's write side effects, composed per leaf type by the application:
|
|
841
|
+
* provide them from a `Context` service (a `Layer`) through
|
|
842
|
+
* {@link SubtypeDefinition.hooks}. The hooks run in array order, so their
|
|
843
|
+
* order is deterministic and is the application's to choose; the default is
|
|
844
|
+
* none ({@link noHooks}).
|
|
845
|
+
*
|
|
846
|
+
* Events fire on explicit writes only: `create`, `update`, `restore`,
|
|
847
|
+
* `delete` (not an already tombstoned node's, which writes nothing) and
|
|
848
|
+
* `restoreVersion`. A scheduled version whose `publishedAt` stamp arrives
|
|
849
|
+
* later writes nothing and emits nothing; an application that needs a side
|
|
850
|
+
* effect when scheduled content goes live drives a sweep from its cron with
|
|
851
|
+
* `./publication`'s `duePublications`.
|
|
852
|
+
*
|
|
853
|
+
* @since 0.1.0
|
|
854
|
+
*/
|
|
855
|
+
export interface LifecycleHooks<Resource, E, R> {
|
|
856
|
+
readonly before: ReadonlyArray<BeforeWriteHook<E, R>>;
|
|
857
|
+
readonly after: ReadonlyArray<AfterWriteHook<Resource, E, R>>;
|
|
858
|
+
/**
|
|
859
|
+
* What an after hook's failure becomes: `"log"` (the default) logs its
|
|
860
|
+
* cause at error level and goes on; `"defect"` dies with it, so the
|
|
861
|
+
* handler fails with a defect although the write committed.
|
|
862
|
+
*/
|
|
863
|
+
readonly afterFailure?: "log" | "defect" | undefined;
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* No hooks: the default for a leaf that declares none.
|
|
867
|
+
*
|
|
868
|
+
* @since 0.1.0
|
|
869
|
+
*/
|
|
870
|
+
export declare const noHooks: <Resource = never, E = never, R = never>() => LifecycleHooks<Resource, E, R>;
|
|
871
|
+
/**
|
|
872
|
+
* Concatenate hook sets in order: `a`'s before hooks run before `b`'s, and
|
|
873
|
+
* likewise after. The first set's `afterFailure` that is given wins.
|
|
874
|
+
*
|
|
875
|
+
* @example
|
|
876
|
+
* ```ts
|
|
877
|
+
* import { strictEqual } from "node:assert";
|
|
878
|
+
* import * as Effect from "effect/Effect";
|
|
879
|
+
* import { composeHooks, noHooks, type BeforeWriteHook } from "@thomasfosterau/pulpit/subtype";
|
|
880
|
+
*
|
|
881
|
+
* const audit: BeforeWriteHook<never, never> = {
|
|
882
|
+
* name: "audit",
|
|
883
|
+
* run: (event) => Effect.succeed(event.patch),
|
|
884
|
+
* };
|
|
885
|
+
* const hooks = composeHooks({ before: [audit], after: [] }, noHooks());
|
|
886
|
+
* strictEqual(hooks.before[0]?.name, "audit");
|
|
887
|
+
* ```
|
|
888
|
+
*
|
|
889
|
+
* @since 0.1.0
|
|
890
|
+
*/
|
|
891
|
+
export declare const composeHooks: <Resource, E, R>(...sets: ReadonlyArray<LifecycleHooks<Resource, E, R>>) => LifecycleHooks<Resource, E, R>;
|
|
892
|
+
/**
|
|
893
|
+
* A leaf's declaration: its type and access, its seams, and its row → resource mapper.
|
|
894
|
+
*
|
|
895
|
+
* @since 0.1.0
|
|
896
|
+
*/
|
|
897
|
+
export interface SubtypeDefinition<Row extends LeafRow, Resource, Following, E, R> {
|
|
898
|
+
/** The leaf's JSON:API wire type — names spans and tags `NodeNotFound`. */
|
|
899
|
+
readonly type: string;
|
|
900
|
+
/** How the leaf is gated: `reference` corpus material or `owned` per-member rows. */
|
|
901
|
+
readonly access: LeafAccess;
|
|
902
|
+
/** The version-chain store the writes append through. */
|
|
903
|
+
readonly chain: VersionChainStore<E, R>;
|
|
904
|
+
/** The current-version view the reads and lists go through, plus the owner stamp. */
|
|
905
|
+
readonly leaves: LeafStore<Row, Following, E, R>;
|
|
906
|
+
/** The request's viewer (effect-auth's session context). */
|
|
907
|
+
readonly viewer: Effect.Effect<Viewer, never, R>;
|
|
908
|
+
/** The application's role axes. */
|
|
909
|
+
readonly policy: AccessPolicy;
|
|
910
|
+
/** The follower seam; {@link nobodyFollows} when the runtime provides none. */
|
|
911
|
+
readonly followers: FollowerSeam<Following, R>;
|
|
912
|
+
/** Shape a view row into the resource (sync; links resolved by the caller's mapper). */
|
|
913
|
+
readonly toResource: (row: Row) => Resource;
|
|
914
|
+
/**
|
|
915
|
+
* The columns a list may sort by ({@link ListRequest.orderBy}); each must
|
|
916
|
+
* be one of the view's `LeafStore.columns`, holding a scalar orderable
|
|
917
|
+
* type (text, numeric, boolean, timestamp or bigint, never `json` /
|
|
918
|
+
* `jsonb` or an array), or {@link defineSubtype} throws
|
|
919
|
+
* {@link SubtypeDeclarationError}. An order key on any other column fails
|
|
920
|
+
* {@link InvalidListOrder}. With a list, `id` sorts only if listed; `[]`
|
|
921
|
+
* admits only the default `id`-ascending order, so any non-empty
|
|
922
|
+
* {@link ListRequest.orderBy} fails {@link InvalidListOrder}.
|
|
923
|
+
*
|
|
924
|
+
* **Omitted, every declared view column is sortable**, the columns the
|
|
925
|
+
* resource never shows included. That default discloses two things to any
|
|
926
|
+
* caller: the ranking, among the rows it may already see, by any view
|
|
927
|
+
* column (a `rank` or a moderation score it can't read), and, through
|
|
928
|
+
* {@link InvalidListOrder}'s 400, which names are view columns. It never
|
|
929
|
+
* reveals a hidden row nor changes a total (`SEC-AUTH-ENUM-001`). Declare
|
|
930
|
+
* the list to sort only by what the resource exposes (pulpit#112).
|
|
931
|
+
*/
|
|
932
|
+
readonly sortable?: ReadonlyArray<string> | undefined;
|
|
933
|
+
/**
|
|
934
|
+
* The leaf's lifecycle hooks, read per write: an Effect, so the
|
|
935
|
+
* application can hold them in a `Context` service and provide it with a
|
|
936
|
+
* `Layer`. The before hooks run inside every write; the after hooks run in
|
|
937
|
+
* {@link subtypeHandlers} once its transaction has committed. Omitted, the
|
|
938
|
+
* leaf has none ({@link noHooks}), and a write reads nothing extra.
|
|
939
|
+
*/
|
|
940
|
+
readonly hooks?: Effect.Effect<LifecycleHooks<Resource, E, R>, never, R> | undefined;
|
|
941
|
+
/**
|
|
942
|
+
* The leaf's searchable projection ({@link defineSearchProjection}), the
|
|
943
|
+
* same declaration the application's search-view generator reads. Each
|
|
944
|
+
* field and the language column must be a column of the view, or
|
|
945
|
+
* {@link defineSubtype} throws {@link SubtypeDeclarationError}. Omitted,
|
|
946
|
+
* the leaf is not searched.
|
|
947
|
+
*/
|
|
948
|
+
readonly search?: SearchProjection | undefined;
|
|
949
|
+
/**
|
|
950
|
+
* The leaf's locale level ({@link defineLocaleLevel}, pulpit#56): which of
|
|
951
|
+
* its columns are translated, written and read per locale, while the rest
|
|
952
|
+
* are shared by every locale of the node. Each column, and
|
|
953
|
+
* {@link LOCALE_COLUMN}, must be a column of the view, or
|
|
954
|
+
* {@link defineSubtype} throws {@link SubtypeDeclarationError}. Omitted,
|
|
955
|
+
* the leaf is not localised and a write carrying a locale is refused.
|
|
956
|
+
*/
|
|
957
|
+
readonly locales?: LocaleLevel | undefined;
|
|
958
|
+
/**
|
|
959
|
+
* The live pointer, opt-in (pulpit#46). With it, the view-private rung,
|
|
960
|
+
* the corpus-write rung on a reference leaf and a row's own owner read
|
|
961
|
+
* the head; everyone else reads the live version (a node with none is
|
|
962
|
+
* hidden from them, which is what `draft` means on such a leaf).
|
|
963
|
+
* Without it, head and live are one.
|
|
964
|
+
*/
|
|
965
|
+
readonly publication?: PublicationDeclaration<Row, Following, E, R> | undefined;
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* A list request, as the decoded query hands it over.
|
|
969
|
+
*
|
|
970
|
+
* @since 0.1.0
|
|
971
|
+
*/
|
|
972
|
+
export interface ListRequest<Following = never> {
|
|
973
|
+
readonly limit: number;
|
|
974
|
+
readonly offset: number;
|
|
975
|
+
readonly includeDeleted: boolean;
|
|
976
|
+
/**
|
|
977
|
+
* The family's own extra predicate (a `?filter[published]=`), or `always`.
|
|
978
|
+
* It may read the follower seam's `Following`, as an edge family's
|
|
979
|
+
* `memberFilter` does (pulpit#189), when the leaf's `SubtypeOps` carries it.
|
|
980
|
+
*/
|
|
981
|
+
readonly where: ListFilter<Following>;
|
|
982
|
+
/**
|
|
983
|
+
* The order, its keys in turn, closed by `id` ascending with `NULL`s last
|
|
984
|
+
* in both directions (see {@link ListOrder}); absent, empty or `null` (as
|
|
985
|
+
* an unchecked caller may send it, the same as absent) is `id` ascending
|
|
986
|
+
* alone. A column that is not sortable (outside the leaf's
|
|
987
|
+
* `sortable` allow-list or, **when the leaf declares none, any view column
|
|
988
|
+
* at all, the ones the resource never shows included**), a direction other
|
|
989
|
+
* than `asc` / `desc`, or input not shaped as an order (no array, a key
|
|
990
|
+
* that is no object, a `column` that is no string) fails the list with
|
|
991
|
+
* {@link InvalidListOrder}, before the viewer is read; a repeated column
|
|
992
|
+
* adds nothing and is dropped. The order discloses the visible rows'
|
|
993
|
+
* ranking by any sortable column, and the 400 which names are sortable;
|
|
994
|
+
* never a hidden row or a total (see {@link SubtypeDefinition.sortable}).
|
|
995
|
+
*/
|
|
996
|
+
readonly orderBy?: ReadonlyArray<ListOrder> | undefined;
|
|
997
|
+
}
|
|
998
|
+
type Gate = Unauthorized | Forbidden;
|
|
999
|
+
/**
|
|
1000
|
+
* What {@link SubtypeOps.owners} reads: the page window, the soft-delete
|
|
1001
|
+
* axis as {@link ListRequest.includeDeleted}, and an optional set of
|
|
1002
|
+
* account ids to narrow to.
|
|
1003
|
+
*
|
|
1004
|
+
* @since 0.1.0
|
|
1005
|
+
*/
|
|
1006
|
+
export interface OwnersRequest {
|
|
1007
|
+
readonly limit: number;
|
|
1008
|
+
readonly offset: number;
|
|
1009
|
+
readonly includeDeleted: boolean;
|
|
1010
|
+
readonly among?: ReadonlyArray<string> | undefined;
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* An update's input: the node, the tri-state patch, an optional expected
|
|
1014
|
+
* version and, for an edit that applies an accepted proposal, the
|
|
1015
|
+
* contributor the version records as `proposedBy` (`./proposals`).
|
|
1016
|
+
*
|
|
1017
|
+
* @since 0.1.0
|
|
1018
|
+
*/
|
|
1019
|
+
export interface UpdateRequest extends RestoreRequest {
|
|
1020
|
+
readonly proposedBy?: Option.Option<string> | undefined;
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* A restore's input: the node, the tri-state patch, a localised leaf's
|
|
1024
|
+
* per-locale patch and an optional expected version.
|
|
1025
|
+
*
|
|
1026
|
+
* @since 0.1.0
|
|
1027
|
+
*/
|
|
1028
|
+
export interface RestoreRequest extends Precondition {
|
|
1029
|
+
readonly id: string;
|
|
1030
|
+
readonly patch: ChainPatch;
|
|
1031
|
+
/** A localised leaf's per-locale patch (pulpit#56); absent, every locale carries forward. */
|
|
1032
|
+
readonly locales?: LocalePatch | undefined;
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* A create's input: a caller-supplied id (minted when none), the initial
|
|
1036
|
+
* state and a localised leaf's locale level at version 0.
|
|
1037
|
+
*
|
|
1038
|
+
* @since 0.1.0
|
|
1039
|
+
*/
|
|
1040
|
+
export interface CreateRequest {
|
|
1041
|
+
readonly id: Option.Option<string>;
|
|
1042
|
+
readonly state: ChainState;
|
|
1043
|
+
/** A localised leaf's locale level at version 0 (pulpit#56); absent, none. */
|
|
1044
|
+
readonly locales?: LocaleStates | undefined;
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* The operations {@link defineSubtype} produces for one leaf. Every write
|
|
1048
|
+
* also fails {@link WriteRejected} when one of the leaf's before hooks
|
|
1049
|
+
* refuses it, after the gate and with nothing appended.
|
|
1050
|
+
*
|
|
1051
|
+
* @since 0.1.0
|
|
1052
|
+
*/
|
|
1053
|
+
export interface SubtypeOps<Resource, E, R, Following = never> {
|
|
1054
|
+
/** The leaf's wire type, as declared. */
|
|
1055
|
+
readonly type: string;
|
|
1056
|
+
/** The access declaration the operations gate by. */
|
|
1057
|
+
readonly access: LeafAccess;
|
|
1058
|
+
/**
|
|
1059
|
+
* The leaf's lifecycle hooks, as {@link SubtypeDefinition.hooks} provides
|
|
1060
|
+
* them; {@link subtypeHandlers} runs their after hooks.
|
|
1061
|
+
*/
|
|
1062
|
+
readonly hooks: Effect.Effect<LifecycleHooks<Resource, E, R>, never, R>;
|
|
1063
|
+
/** The publication operations, on a leaf that declares the live pointer. */
|
|
1064
|
+
readonly publication: Option.Option<PublicationOps<E, R>>;
|
|
1065
|
+
/**
|
|
1066
|
+
* The resource as an anonymous reader gets it (the live version on a
|
|
1067
|
+
* publish-gated leaf), or none when they can't read it. What a public
|
|
1068
|
+
* cache may hold.
|
|
1069
|
+
*/
|
|
1070
|
+
readonly publicResource: (id: string) => Effect.Effect<Option.Option<Resource>, E, R>;
|
|
1071
|
+
/**
|
|
1072
|
+
* The leaf's write gate on `id`, as `update` runs it: the acting account,
|
|
1073
|
+
* or the gate's failure. For a surface outside the chain (a working copy)
|
|
1074
|
+
* that must gate like a write.
|
|
1075
|
+
*/
|
|
1076
|
+
readonly authorize: (id: string) => Effect.Effect<ViewerAccount, Gate | NodeNotFound | E, R>;
|
|
1077
|
+
/**
|
|
1078
|
+
* Create the node at version 0, stamping the viewer as owner on an owned
|
|
1079
|
+
* leaf. Fails `Unauthorized` when anonymous, `Forbidden` below the
|
|
1080
|
+
* corpus-write rung on a reference leaf, `NodeAlreadyExists` when a
|
|
1081
|
+
* caller-supplied id is taken, and `WriteRejected` when the write breaks
|
|
1082
|
+
* the leaf's locale level (see {@link SubtypeOps.update}).
|
|
1083
|
+
*/
|
|
1084
|
+
readonly create: (input: CreateRequest) => Effect.Effect<ChainWrite, Gate | WriteRejected | NodeAlreadyExists | VersionConflict | E, R>;
|
|
1085
|
+
/**
|
|
1086
|
+
* Append a tri-state patch. Fails `Unauthorized` when anonymous,
|
|
1087
|
+
* `Forbidden` below the write rung (the corpus-write rung for reference
|
|
1088
|
+
* data, owner-or-moderator for an owned row), `NodeNotFound` when an
|
|
1089
|
+
* owned row has no owner, when a reference row is one `get` hides from the
|
|
1090
|
+
* caller (`PUB-009`; pulpit#101), or when the node is unknown or
|
|
1091
|
+
* tombstoned, and `StaleVersion` when `input.expectedVersion` is not the
|
|
1092
|
+
* head (checked after the gate, so only a writer learns the head).
|
|
1093
|
+
*
|
|
1094
|
+
* On a localised leaf (pulpit#56) `input.locales` patches the locale
|
|
1095
|
+
* level; the write appends one version of the whole node, however few
|
|
1096
|
+
* locales it touches. Fails `WriteRejected`, after the gate and with
|
|
1097
|
+
* nothing appended, when the shared patch names a translated column, a
|
|
1098
|
+
* locale's patch names a shared one, a locale tag is empty, or a leaf with
|
|
1099
|
+
* no locale level is sent a locale.
|
|
1100
|
+
*/
|
|
1101
|
+
readonly update: (input: UpdateRequest) => Effect.Effect<ChainWrite, Gate | WriteRejected | NodeNotFound | StaleVersion | VersionConflict | E, R>;
|
|
1102
|
+
/**
|
|
1103
|
+
* {@link SubtypeOps.update}, except a tombstone does not fail: the node
|
|
1104
|
+
* comes back live (`VERSION-CHAIN-006`). Fails as `update` does, apart from
|
|
1105
|
+
* the tombstone. On a reference leaf only the view-deleted rung can see a
|
|
1106
|
+
* tombstone, so only it can restore one; anyone else gets `NodeNotFound`.
|
|
1107
|
+
*/
|
|
1108
|
+
readonly restore: (input: RestoreRequest) => Effect.Effect<ChainWrite, Gate | WriteRejected | NodeNotFound | StaleVersion | VersionConflict | E, R>;
|
|
1109
|
+
/**
|
|
1110
|
+
* Tombstone the node (idempotent: an already tombstoned node reports
|
|
1111
|
+
* `alreadyDeleted: true` and writes nothing, whatever the precondition).
|
|
1112
|
+
* Fails `Unauthorized` when anonymous, `Forbidden` below the write rung,
|
|
1113
|
+
* `NodeNotFound` when an owned row has no owner, when a reference row
|
|
1114
|
+
* (an already tombstoned one included) is one `get` hides from the caller
|
|
1115
|
+
* (`PUB-009`; pulpit#101), or when the node is unknown, and
|
|
1116
|
+
* `StaleVersion` when a live node's head is not
|
|
1117
|
+
* `precondition.expectedVersion`.
|
|
1118
|
+
*/
|
|
1119
|
+
readonly delete: (id: string, precondition?: Precondition) => Effect.Effect<ChainWrite & {
|
|
1120
|
+
readonly alreadyDeleted: boolean;
|
|
1121
|
+
}, Gate | WriteRejected | NodeNotFound | StaleVersion | VersionConflict | E, R>;
|
|
1122
|
+
/**
|
|
1123
|
+
* The row as a resource. Fails `NodeNotFound` when it is missing OR hidden
|
|
1124
|
+
* from the viewer (`SEC-AUTH-ENUM-001`).
|
|
1125
|
+
*/
|
|
1126
|
+
readonly get: (id: string) => Effect.Effect<Resource, NodeNotFound | E, R>;
|
|
1127
|
+
/**
|
|
1128
|
+
* The locale-fallback read (pulpit#56): the node's row in the first of
|
|
1129
|
+
* `locales`, in the caller's order, that the version the viewer reads
|
|
1130
|
+
* carries (the head, or on a publish-gated leaf the live version for a
|
|
1131
|
+
* viewer who doesn't read the head), with the shared columns joined in.
|
|
1132
|
+
* Gated by the node's own read gate alone, exactly as `get` is: draft,
|
|
1133
|
+
* visibility and publication are the node's, shared by its locales, so no
|
|
1134
|
+
* locale is ever hidden on its own. Fails `NodeNotFound` when the node is
|
|
1135
|
+
* missing or hidden from the viewer (`SEC-AUTH-ENUM-001`) and when it
|
|
1136
|
+
* carries none of `locales` (or the leaf has no locale level), in one
|
|
1137
|
+
* read of the locale rows after the gate.
|
|
1138
|
+
*/
|
|
1139
|
+
/**
|
|
1140
|
+
* The accounts that own at least one row the viewer may list
|
|
1141
|
+
* (pulpit#186): the distinct owner column values of exactly the rows
|
|
1142
|
+
* {@link SubtypeOps.list} would show for `request` (the same scope, and on
|
|
1143
|
+
* a publish-gated leaf the same head-or-live choice), in code-point order,
|
|
1144
|
+
* with the distinct total. `among`, when given, narrows to those account
|
|
1145
|
+
* ids (more than `MAX_SET_VALUES` reads none, `SEC-INPUT-003`). A hidden
|
|
1146
|
+
* row never contributes its owner (`SEC-AUTH-ENUM-001`). A reference leaf
|
|
1147
|
+
* has no owner, so its page is empty. The limit is clamped to
|
|
1148
|
+
* {@link MAX_PAGE_LIMIT}. Fails only with the store's `E`.
|
|
1149
|
+
*/
|
|
1150
|
+
readonly owners: (request: OwnersRequest) => Effect.Effect<Page<string>, E, R>;
|
|
1151
|
+
readonly getLocalised: (id: string, locales: ReadonlyArray<string>) => Effect.Effect<LocalisedResource<Resource>, NodeNotFound | E, R>;
|
|
1152
|
+
/** Visible rows by id, in one read; a missing or hidden id is simply absent. */
|
|
1153
|
+
readonly getMany: (ids: ReadonlyArray<string>) => Effect.Effect<HashMap.HashMap<string, Resource>, E, R>;
|
|
1154
|
+
/**
|
|
1155
|
+
* One page of the rows the viewer may see, with the unpaged total, in
|
|
1156
|
+
* `request.orderBy` closed by `id` (`NULL`s last; see {@link ListOrder}).
|
|
1157
|
+
* The limit is clamped to {@link MAX_PAGE_LIMIT}. Fails
|
|
1158
|
+
* {@link InvalidListOrder}, before the viewer or the store is read, when
|
|
1159
|
+
* an order key names a column that is not sortable (see
|
|
1160
|
+
* {@link SubtypeDefinition.sortable}) or is not shaped as an order. The
|
|
1161
|
+
* order never changes which rows are listed, nor the total
|
|
1162
|
+
* (`SEC-AUTH-ENUM-001`).
|
|
1163
|
+
*/
|
|
1164
|
+
readonly list: (request: ListRequest<Following>) => Effect.Effect<Page<Resource>, InvalidListOrder | E, R>;
|
|
1165
|
+
/**
|
|
1166
|
+
* Gated like a write (`READ-009`): the history exposes every state,
|
|
1167
|
+
* private and deleted included. Fails as `update` does at the gate (on a
|
|
1168
|
+
* reference leaf, `NodeNotFound` for a row `get` hides from the caller,
|
|
1169
|
+
* pulpit#101), then `VersionNotFound` when the version has no row.
|
|
1170
|
+
*/
|
|
1171
|
+
readonly getVersion: (id: string, version: bigint) => Effect.Effect<VersionSnapshot, Gate | NodeNotFound | VersionNotFound | E, R>;
|
|
1172
|
+
/**
|
|
1173
|
+
* The field-level change set from version `from` to version `to`: the
|
|
1174
|
+
* chain's `compare` (two version reads and `diffStates`, see
|
|
1175
|
+
* {@link VersionComparison}) behind exactly {@link SubtypeOps.getVersion}'s
|
|
1176
|
+
* gate (`READ-009`), which both run through, so nothing is read before it
|
|
1177
|
+
* passes. Fails as `getVersion` does: `Unauthorized` when anonymous;
|
|
1178
|
+
* `Forbidden` below the write rung (owner-or-moderator on an owned row,
|
|
1179
|
+
* whatever its visibility, pulpit#24; the corpus-write rung on a reference
|
|
1180
|
+
* leaf); `NodeNotFound` when an owned row has no owner or a reference row
|
|
1181
|
+
* is missing or one `get` hides from the caller (pulpit#101), whichever
|
|
1182
|
+
* version is asked for; then `VersionNotFound` for whichever version
|
|
1183
|
+
* (`from` first) has no row.
|
|
1184
|
+
*/
|
|
1185
|
+
readonly compare: (id: string, from: bigint, to: bigint) => Effect.Effect<VersionComparison, Gate | NodeNotFound | VersionNotFound | E, R>;
|
|
1186
|
+
/**
|
|
1187
|
+
* Re-apply the state at `version` as a new version, gated like a write
|
|
1188
|
+
* (on a reference leaf, `NodeNotFound` for a row `get` hides from the
|
|
1189
|
+
* caller, pulpit#101). Fails `VersionNotFound`, before writing anything,
|
|
1190
|
+
* when that version
|
|
1191
|
+
* cannot be fully reconstructed, and `StaleVersion` when the head is not
|
|
1192
|
+
* `precondition.expectedVersion`.
|
|
1193
|
+
*/
|
|
1194
|
+
readonly restoreVersion: (id: string, version: bigint, precondition?: Precondition) => Effect.Effect<ChainWrite, Gate | WriteRejected | NodeNotFound | VersionNotFound | StaleVersion | VersionConflict | E, R>;
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Bind a leaf's operations. Every produced operation is traced as
|
|
1198
|
+
* `${type}.<operation>`; the write operations run in the caller's
|
|
1199
|
+
* transaction (the boundary is the handler seam's, website #1883).
|
|
1200
|
+
*
|
|
1201
|
+
* Throws {@link SubtypeDeclarationError}, when the leaf is declared, for a
|
|
1202
|
+
* `sortable` entry that is not a column of the view.
|
|
1203
|
+
*
|
|
1204
|
+
* @since 0.1.0
|
|
1205
|
+
*/
|
|
1206
|
+
export declare const defineSubtype: <Row extends LeafRow, Resource, Following, E, R>(definition: SubtypeDefinition<Row, Resource, Following, E, R>) => SubtypeOps<Resource, E, R, Following>;
|
|
1207
|
+
/**
|
|
1208
|
+
* A searchable field's rank weight, PostgreSQL's `setweight` labels from the
|
|
1209
|
+
* strongest (`A`) to the weakest (`D`).
|
|
1210
|
+
*
|
|
1211
|
+
* @since 0.1.0
|
|
1212
|
+
*/
|
|
1213
|
+
export type SearchWeight = "A" | "B" | "C" | "D";
|
|
1214
|
+
/**
|
|
1215
|
+
* One indexed column of a {@link SearchProjection} and its weight.
|
|
1216
|
+
*
|
|
1217
|
+
* @since 0.1.0
|
|
1218
|
+
*/
|
|
1219
|
+
export interface SearchField {
|
|
1220
|
+
readonly column: string;
|
|
1221
|
+
readonly weight: SearchWeight;
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* A leaf's searchable projection (pulpit#54): which view columns its search
|
|
1225
|
+
* view indexes, at what weight, and the column naming each row's text
|
|
1226
|
+
* search configuration (its language), if any. Declared once, beside the
|
|
1227
|
+
* leaf's registry entry, so the application's search-view generator and
|
|
1228
|
+
* {@link defineSubtype} (`search`) read the same value. The ranking itself
|
|
1229
|
+
* (full text, trigram, vectors, their fusion) is the application's SQL.
|
|
1230
|
+
*
|
|
1231
|
+
* @since 0.1.0
|
|
1232
|
+
*/
|
|
1233
|
+
export interface SearchProjection {
|
|
1234
|
+
/** The indexed columns, each once, strongest first by convention. */
|
|
1235
|
+
readonly fields: ReadonlyArray<SearchField>;
|
|
1236
|
+
/** The view column holding each row's text search configuration, or none for one fixed configuration. */
|
|
1237
|
+
readonly languageColumn: Option.Option<string>;
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* Declare a {@link SearchProjection}. Throws {@link SubtypeDeclarationError}
|
|
1241
|
+
* at import, as a registry violation is, for no field, a column named twice
|
|
1242
|
+
* or a weight outside `A`–`D`; {@link defineSubtype} checks the columns
|
|
1243
|
+
* against the view.
|
|
1244
|
+
*
|
|
1245
|
+
* @example
|
|
1246
|
+
* ```ts
|
|
1247
|
+
* import { strictEqual } from "node:assert";
|
|
1248
|
+
* import { defineSearchProjection } from "@thomasfosterau/pulpit/subtype";
|
|
1249
|
+
*
|
|
1250
|
+
* const posts = defineSearchProjection({
|
|
1251
|
+
* fields: [
|
|
1252
|
+
* { column: "title", weight: "A" },
|
|
1253
|
+
* { column: "excerpt", weight: "B" },
|
|
1254
|
+
* ],
|
|
1255
|
+
* languageColumn: "language",
|
|
1256
|
+
* });
|
|
1257
|
+
* strictEqual(posts.fields.length, 2);
|
|
1258
|
+
* ```
|
|
1259
|
+
*
|
|
1260
|
+
* @since 0.1.0
|
|
1261
|
+
*/
|
|
1262
|
+
export declare const defineSearchProjection: (declaration: {
|
|
1263
|
+
readonly fields: ReadonlyArray<SearchField>;
|
|
1264
|
+
readonly languageColumn?: string | undefined;
|
|
1265
|
+
}) => SearchProjection;
|
|
1266
|
+
/**
|
|
1267
|
+
* The read scope a search over one leaf must apply to its hits: exactly the
|
|
1268
|
+
* filter {@link defineSubtype}'s `list` reads under (`list` calls this), the
|
|
1269
|
+
* soft-delete arm, the access kind's privacy arm (owned visibility and draft
|
|
1270
|
+
* through {@link ownedListFilter}; the reference privacy arm with its
|
|
1271
|
+
* followers, `PUB-009`) and the family's own `where`, so a search can never
|
|
1272
|
+
* surface a row `list` hides (`SEC-AUTH-ENUM-001`). The application renders
|
|
1273
|
+
* it into its search query as the list store renders it into the list's. On
|
|
1274
|
+
* a leaf with the live pointer ({@link SubtypeDefinition.publication}) the
|
|
1275
|
+
* search matches over the rows {@link LiveView}'s `list` reads (the viewer's
|
|
1276
|
+
* own at the head, every other node's live row), never every head, so a
|
|
1277
|
+
* match never scores text a reader cannot see.
|
|
1278
|
+
*
|
|
1279
|
+
* @since 0.1.0
|
|
1280
|
+
*/
|
|
1281
|
+
export declare const searchScopeFilter: <Following>(leaf: {
|
|
1282
|
+
readonly access: LeafAccess;
|
|
1283
|
+
readonly columns: ReadonlySet<string>;
|
|
1284
|
+
readonly policy: AccessPolicy;
|
|
1285
|
+
readonly followers: FollowerSeam<Following, unknown>;
|
|
1286
|
+
}, viewer: Viewer, request: Pick<ListRequest<Following>, "includeDeleted" | "where">) => ListFilter<Following>;
|
|
1287
|
+
/**
|
|
1288
|
+
* Hydrate ranked search hits through the leaf's gated `getMany`: one read
|
|
1289
|
+
* for every hit, each re-checked against the viewer by the same predicate
|
|
1290
|
+
* `get` applies, so a stale or poisoned index (a hit the search scope should
|
|
1291
|
+
* have excluded, or one hidden since it was indexed) never leaks a row
|
|
1292
|
+
* (`SEC-AUTH-ENUM-001`). The hits keep their rank order, a hidden or
|
|
1293
|
+
* missing one is dropped, and a repeated id keeps its first, best rank.
|
|
1294
|
+
* Fails with the store's `E`.
|
|
1295
|
+
*
|
|
1296
|
+
* @since 0.1.0
|
|
1297
|
+
*/
|
|
1298
|
+
export declare const hydrateSearchHits: <Hit extends {
|
|
1299
|
+
readonly id: string;
|
|
1300
|
+
}, Resource, E, R>(ops: Pick<SubtypeOps<Resource, E, R>, "getMany">, hits: ReadonlyArray<Hit>) => Effect.Effect<ReadonlyArray<{
|
|
1301
|
+
readonly hit: Hit;
|
|
1302
|
+
readonly resource: Resource;
|
|
1303
|
+
}>, E, R>;
|
|
1304
|
+
/**
|
|
1305
|
+
* The resource cache's write side: `set` writes the resource through — the
|
|
1306
|
+
* cache itself refuses (and evicts) a non-serveable one, so a
|
|
1307
|
+
* `public → private` flip self-purges — and `delete` evicts. The application
|
|
1308
|
+
* fills it with a cache it owns; no library is expected to provide one
|
|
1309
|
+
* (effect-web dropped its Cloudflare package, and the planned cache with it,
|
|
1310
|
+
* on effect-web#33).
|
|
1311
|
+
*
|
|
1312
|
+
* **Compare-on-set** (pulpit#121). Both calls carry the version they speak
|
|
1313
|
+
* for, and the cache keeps, per id, the highest version it has been given by
|
|
1314
|
+
* either. A `set` below that version is a no-op, and a `delete` (or a refused
|
|
1315
|
+
* `set`) raises it, outliving the entry it evicted. So a racing writer's
|
|
1316
|
+
* late write-through, or a read-through fill that read before a hiding
|
|
1317
|
+
* write, can never re-cache a resource older than one already evicted or
|
|
1318
|
+
* replaced for the id (`CACHE-AUTH-005`). The compare and the write must be
|
|
1319
|
+
* one atomic step in the cache's store: a store with no conditional write
|
|
1320
|
+
* cannot honour the seam. A read-through fill passes the version its read
|
|
1321
|
+
* saw, and goes through `set` like any other.
|
|
1322
|
+
*
|
|
1323
|
+
* @since 0.1.0
|
|
1324
|
+
*/
|
|
1325
|
+
export interface ResourceCacheSeam<Resource, R> {
|
|
1326
|
+
/**
|
|
1327
|
+
* Write the resource through, as of `version`: a no-op below the highest
|
|
1328
|
+
* version the cache holds for `id`; the cache refuses (and evicts at
|
|
1329
|
+
* `version`) one that isn't serveable. Never fails.
|
|
1330
|
+
*/
|
|
1331
|
+
readonly set: (id: string, resource: Resource, version: bigint) => Effect.Effect<void, never, R>;
|
|
1332
|
+
/**
|
|
1333
|
+
* Evict the resource as of `version`, which a later `set` must reach to
|
|
1334
|
+
* cache it again. Never fails.
|
|
1335
|
+
*/
|
|
1336
|
+
readonly delete: (id: string, version: bigint) => Effect.Effect<void, never, R>;
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* The resource cache's write-through as an {@link AfterWriteHook}: a write
|
|
1340
|
+
* whose writer re-read the resource sets it, and any other (a `delete`, or a
|
|
1341
|
+
* write whose re-read failed or no longer shows the row) evicts it
|
|
1342
|
+
* (`CACHE-AUTH-005`). {@link subtypeHandlers} runs it first, before the
|
|
1343
|
+
* leaf's own after hooks, whenever its config has a cache. Never fails.
|
|
1344
|
+
*
|
|
1345
|
+
* @since 0.1.0
|
|
1346
|
+
*/
|
|
1347
|
+
export declare const cacheWriteThrough: <Resource, R>(cache: ResourceCacheSeam<Resource, R>) => AfterWriteHook<Resource, never, R>;
|
|
1348
|
+
/**
|
|
1349
|
+
* What a handler-record write answers: the committed `{ id, version }`, and
|
|
1350
|
+
* the resource as its writer re-read it after the commit. `resource` is
|
|
1351
|
+
* none when the write hid the row from its own writer (the re-read failed
|
|
1352
|
+
* `NodeNotFound`): the write still committed, so it answers the write
|
|
1353
|
+
* rather than a 404 that would invite a duplicating retry (pulpit#107).
|
|
1354
|
+
*
|
|
1355
|
+
* @since 0.1.0
|
|
1356
|
+
*/
|
|
1357
|
+
export interface CommittedWrite<Resource> extends ChainWrite {
|
|
1358
|
+
/** The resource as the writer now reads it; none when the write hid it from them. */
|
|
1359
|
+
readonly resource: Option.Option<Resource>;
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* The handler record a leaf contributes to a JSON:API server's resource
|
|
1363
|
+
* handlers — one entry per endpoint, the writes answering the committed
|
|
1364
|
+
* write and the resource the document assembler wraps
|
|
1365
|
+
* ({@link CommittedWrite}) — plus the version-restore command, which has no
|
|
1366
|
+
* JSON:API endpoint and is run SSR-direct.
|
|
1367
|
+
*
|
|
1368
|
+
* {@link subtypeResourceHandlers} turns the record into effect-jsonapi's
|
|
1369
|
+
* `ResourceHandlers.make` CRUD handlers (pulpit#5).
|
|
1370
|
+
*
|
|
1371
|
+
* Every write re-reads the row after the chain write and hands it to the
|
|
1372
|
+
* leaf's after hooks ({@link LifecycleHooks}), the cache write-through
|
|
1373
|
+
* ({@link cacheWriteThrough}) first, so it writes the resource through the
|
|
1374
|
+
* cache; that write-through is why these exist rather than each route
|
|
1375
|
+
* calling the operation directly: the cache serves
|
|
1376
|
+
* anonymous reads, and a write that skipped it would leave them on the
|
|
1377
|
+
* superseded content until the TTL expired. The re-read runs as the writer,
|
|
1378
|
+
* so a write that hides the row from its own writer fails it
|
|
1379
|
+
* (`NodeNotFound`) after the write committed; the handler then evicts the
|
|
1380
|
+
* entry, as it does for any re-read failure, so the cache never keeps the
|
|
1381
|
+
* superseded resource (`CACHE-AUTH-005`, pulpit#107). A hidden write
|
|
1382
|
+
* answers its {@link CommittedWrite} with no resource, not the
|
|
1383
|
+
* `NodeNotFound`: the write committed. Any other re-read failure (a store
|
|
1384
|
+
* error, a defect, an interrupt) fails the write with its own cause. The
|
|
1385
|
+
* chain write runs inside `transact` (the handler seam's boundary, website
|
|
1386
|
+
* #1883); the re-read stays outside it. An interrupt can't separate a
|
|
1387
|
+
* committed write from its write-through or eviction: a failure of the
|
|
1388
|
+
* boundary after the operation itself succeeded (an interrupt included)
|
|
1389
|
+
* evicts too, since the write may have committed, while a write the gate
|
|
1390
|
+
* or the precondition refuses never touches the cache, even when a
|
|
1391
|
+
* `transact` that retries internally rolled back an earlier attempt that
|
|
1392
|
+
* wrote: only the last attempt counts. That holds for `delete`'s eviction
|
|
1393
|
+
* as well.
|
|
1394
|
+
*
|
|
1395
|
+
* `restore` is the operations' `restore` with the same write-through.
|
|
1396
|
+
* `update`, `restore`, `delete` and `restoreVersion` take the operations' optional
|
|
1397
|
+
* expected version ({@link Precondition}); the HTTP layer maps a request's
|
|
1398
|
+
* `If-Match` onto it and a `StaleVersion` failure onto the 412.
|
|
1399
|
+
*
|
|
1400
|
+
* @since 0.1.0
|
|
1401
|
+
*/
|
|
1402
|
+
export interface SubtypeHandlers<Resource, E, R> {
|
|
1403
|
+
readonly list: SubtypeOps<Resource, E, R>["list"];
|
|
1404
|
+
readonly get: SubtypeOps<Resource, E, R>["get"];
|
|
1405
|
+
readonly create: (input: CreateRequest) => Effect.Effect<CommittedWrite<Resource>, Gate | WriteRejected | NodeAlreadyExists | NodeNotFound | VersionConflict | E, R>;
|
|
1406
|
+
readonly update: (input: RestoreRequest) => Effect.Effect<CommittedWrite<Resource>, Gate | WriteRejected | NodeNotFound | StaleVersion | VersionConflict | E, R>;
|
|
1407
|
+
readonly restore: (input: RestoreRequest) => Effect.Effect<CommittedWrite<Resource>, Gate | WriteRejected | NodeNotFound | StaleVersion | VersionConflict | E, R>;
|
|
1408
|
+
readonly delete: (id: string, precondition?: Precondition) => Effect.Effect<void, Gate | WriteRejected | NodeNotFound | StaleVersion | VersionConflict | E, R>;
|
|
1409
|
+
readonly restoreVersion: (input: {
|
|
1410
|
+
readonly id: string;
|
|
1411
|
+
readonly version: bigint;
|
|
1412
|
+
} & Precondition) => Effect.Effect<CommittedWrite<Resource>, Gate | WriteRejected | NodeNotFound | VersionNotFound | StaleVersion | VersionConflict | E, R>;
|
|
1413
|
+
/**
|
|
1414
|
+
* On a publish-gated leaf, its publication operations, each run in
|
|
1415
|
+
* `transact` and followed by the after hooks (`publish` / `unpublish`,
|
|
1416
|
+
* or `restoreVersion` for `discardDraft`) and the cache refresh. Their
|
|
1417
|
+
* cache stamps are the node's head, so two of them on one node compare
|
|
1418
|
+
* equal: the application runs them one at a time per node, the whole
|
|
1419
|
+
* call under one lock (README → Seams → the live pointer, pulpit#164).
|
|
1420
|
+
*/
|
|
1421
|
+
readonly publication: Option.Option<PublicationOps<E, R>>;
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* What {@link subtypeHandlers} needs besides the operations.
|
|
1425
|
+
*
|
|
1426
|
+
* @since 0.1.0
|
|
1427
|
+
*/
|
|
1428
|
+
export interface SubtypeHandlersConfig<Resource, R> {
|
|
1429
|
+
/** The write boundary — always-open: a nested call is a SAVEPOINT (website #1883). */
|
|
1430
|
+
readonly transact: <A, E2>(effect: Effect.Effect<A, E2, R>) => Effect.Effect<A, E2, R>;
|
|
1431
|
+
/**
|
|
1432
|
+
* Whether the caller is already inside a transaction, read once a write's
|
|
1433
|
+
* `transact` has returned. When it is, that `transact` was a SAVEPOINT and
|
|
1434
|
+
* nothing has committed yet, so the write refuses its cache write-through
|
|
1435
|
+
* and evicts instead: a resource that may still roll back is never
|
|
1436
|
+
* cached (`CACHE-AUTH-005`, pulpit#124). Over `effect/sql`, it is whether
|
|
1437
|
+
* the fiber's context holds the client's `TransactionConnection`.
|
|
1438
|
+
*/
|
|
1439
|
+
readonly inTransaction: Effect.Effect<boolean, never, R>;
|
|
1440
|
+
/** The leaf's cache, when it has one. */
|
|
1441
|
+
readonly cache: Option.Option<ResourceCacheSeam<Resource, R>>;
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* Wrap a leaf's operations as its handler record. An owned leaf may still
|
|
1445
|
+
* be cached — `CACHE-AUTH-005` is enforced by the cache's serveability
|
|
1446
|
+
* policy (`./publication`'s `isPubliclyServeable` with `owned: true`), which
|
|
1447
|
+
* only ever holds its public rows, and by the handlers' own eviction when a
|
|
1448
|
+
* committed write can't be re-read or written through (see
|
|
1449
|
+
* {@link SubtypeHandlers}). Each write fails with its operation's error, or
|
|
1450
|
+
* with the re-read's unless that is `NodeNotFound` (a write hidden from its
|
|
1451
|
+
* writer, answered as a {@link CommittedWrite} with no resource).
|
|
1452
|
+
*
|
|
1453
|
+
* @since 0.1.0
|
|
1454
|
+
*/
|
|
1455
|
+
export declare const subtypeHandlers: <Resource, E, R>(ops: SubtypeOps<Resource, E, R>, config: SubtypeHandlersConfig<Resource, R>) => SubtypeHandlers<Resource, E, R>;
|
|
1456
|
+
/**
|
|
1457
|
+
* What {@link subtypeResourceHandlers} needs besides the handler record:
|
|
1458
|
+
* effect-jsonapi's `ResourceHandlers.make` config without its `ops`, `cache`
|
|
1459
|
+
* and `transact` (the record already owns the write boundary, the re-read and
|
|
1460
|
+
* the cache write-through), plus the three mappings from the endpoint's
|
|
1461
|
+
* decoded inputs onto the operations' own.
|
|
1462
|
+
*
|
|
1463
|
+
* @since 0.1.0
|
|
1464
|
+
*/
|
|
1465
|
+
export interface SubtypeResourceHandlersConfig<TQuery, TCreate, TUpdate extends {
|
|
1466
|
+
readonly id: string;
|
|
1467
|
+
}, V, RV, RL, Targets extends Handlers.IncludeTargets, Type extends string = string> extends Omit<ResourceHandlers.ReadOnlyConfig<TQuery, Assemble.ResourceLike, string, V, never, never, RV, RL, never, Targets>, "ops" | "cache"> {
|
|
1468
|
+
/**
|
|
1469
|
+
* The resource's JSON:API type, which a hidden write's identifier
|
|
1470
|
+
* document names (`data.type`): effect-jsonapi's `hiddenWrite.type`.
|
|
1471
|
+
*/
|
|
1472
|
+
readonly type: Type;
|
|
1473
|
+
/** The decoded list query as a {@link ListRequest}: its page, `includeDeleted`, `where` and order. */
|
|
1474
|
+
readonly listRequest: (query: TQuery) => ListRequest;
|
|
1475
|
+
/** The decoded create payload as the operation's input: the client id, if any, and the state. */
|
|
1476
|
+
readonly createInput: (payload: TCreate) => {
|
|
1477
|
+
readonly id: Option.Option<string>;
|
|
1478
|
+
readonly state: ChainState;
|
|
1479
|
+
};
|
|
1480
|
+
/**
|
|
1481
|
+
* The decoded update payload as the operation's input: the id, the
|
|
1482
|
+
* tri-state patch and the optional expected version.
|
|
1483
|
+
*/
|
|
1484
|
+
readonly updateInput: (payload: TUpdate) => {
|
|
1485
|
+
readonly id: string;
|
|
1486
|
+
readonly patch: ChainPatch;
|
|
1487
|
+
} & Precondition;
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* A leaf's {@link SubtypeHandlers} as effect-jsonapi's CRUD handlers
|
|
1491
|
+
* (`ResourceHandlers.make`), keyed by the endpoint names `Group.flat`
|
|
1492
|
+
* declares (pulpit#5). Each write goes through the record, so it keeps the
|
|
1493
|
+
* record's `transact` boundary, writer re-read, lifecycle hooks and cache
|
|
1494
|
+
* write-through (`CACHE-AUTH-005`); effect-jsonapi is given no `cache` and no
|
|
1495
|
+
* `transact` of its own, and its mutation tail re-reads the row once more
|
|
1496
|
+
* with `get` to assemble the document. `list` answers the page with the
|
|
1497
|
+
* unpaged total, and `delete` the 204. `restore` and `restoreVersion` have no
|
|
1498
|
+
* JSON:API endpoint and stay on the record. The handlers fail with the
|
|
1499
|
+
* record's errors (and effect-jsonapi's `Conflict` for an `update` whose
|
|
1500
|
+
* payload id is not the route's, before any operation runs); mapping them to
|
|
1501
|
+
* the wire is the application's `ServerErrors`.
|
|
1502
|
+
*
|
|
1503
|
+
* A `create` / `update` whose committed write hides the row from its writer
|
|
1504
|
+
* (the re-read fails `NodeNotFound`) answers effect-jsonapi's committed
|
|
1505
|
+
* identifier document, `{ data: { type, id, meta: { version } } }` at the
|
|
1506
|
+
* endpoint's own status, not a 404 (effect-jsonapi's `hiddenWrite`,
|
|
1507
|
+
* pulpit#107). Declare the group with `Group.flat({ …, hiddenWrites: true })`
|
|
1508
|
+
* so its `create` / `update` success admits that document.
|
|
1509
|
+
*
|
|
1510
|
+
* @since 0.1.0
|
|
1511
|
+
*/
|
|
1512
|
+
export declare const subtypeResourceHandlers: <Resource extends Assemble.ResourceLike, E, R, TQuery, TCreate, TUpdate extends {
|
|
1513
|
+
readonly id: string;
|
|
1514
|
+
}, V = undefined, RV = never, RL = never, Targets extends Handlers.IncludeTargets = never>(handlers: SubtypeHandlers<Resource, E, R>, config: SubtypeResourceHandlersConfig<TQuery, TCreate, TUpdate, V, RV, RL, Targets, Resource["type"]>) => ResourceHandlers.CrudHandlers<TCreate, TUpdate, Resource, string, TQuery, E | InvalidListOrder | NodeAlreadyExists | NodeNotFound | StaleVersion | VersionConflict | WriteRejected | Gate, R, R, RV, RL, never, Targets, never, never, never, [NoInfer<Resource["type"]>] extends [never] ? never : ResourceHandlers.CommittedDocument<NoInfer<Resource["type"]>, string>>;
|
|
1515
|
+
/**
|
|
1516
|
+
* A leaf's locale level (pulpit#56, model (b)): the columns translated per
|
|
1517
|
+
* locale. Every other column of the leaf is shared by all of a node's
|
|
1518
|
+
* locales, so one value serves the whole node; history, draft, visibility and
|
|
1519
|
+
* publication are the node's, never a locale's. Declared once, beside the
|
|
1520
|
+
* leaf's registry entry (flagged `localised`), so the application's view
|
|
1521
|
+
* generator and {@link defineSubtype} (`locales`) read the same value, as a
|
|
1522
|
+
* {@link SearchProjection} is.
|
|
1523
|
+
*
|
|
1524
|
+
* @since 0.1.0
|
|
1525
|
+
*/
|
|
1526
|
+
export interface LocaleLevel {
|
|
1527
|
+
/** The translated columns, each once. */
|
|
1528
|
+
readonly columns: ReadonlyArray<string>;
|
|
1529
|
+
}
|
|
1530
|
+
/**
|
|
1531
|
+
* What {@link SubtypeOps.getLocalised} returns: the locale it fell back to
|
|
1532
|
+
* and the resource in that locale.
|
|
1533
|
+
*
|
|
1534
|
+
* @since 0.1.0
|
|
1535
|
+
*/
|
|
1536
|
+
export interface LocalisedResource<Resource> {
|
|
1537
|
+
readonly locale: string;
|
|
1538
|
+
readonly resource: Resource;
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Declare a {@link LocaleLevel}. Throws {@link SubtypeDeclarationError} at
|
|
1542
|
+
* import, as a registry violation is, for no column, a column named twice or
|
|
1543
|
+
* one the view keeps for itself (`id`, `version`, `action`,
|
|
1544
|
+
* {@link LOCALE_COLUMN}); {@link defineSubtype} checks the columns against
|
|
1545
|
+
* the view.
|
|
1546
|
+
*
|
|
1547
|
+
* @example
|
|
1548
|
+
* ```ts
|
|
1549
|
+
* import { deepStrictEqual } from "node:assert";
|
|
1550
|
+
* import { defineLocaleLevel } from "@thomasfosterau/pulpit/subtype";
|
|
1551
|
+
*
|
|
1552
|
+
* const pages = defineLocaleLevel({ columns: ["title", "body", "slug"] });
|
|
1553
|
+
* deepStrictEqual(pages.columns, ["title", "body", "slug"]);
|
|
1554
|
+
* ```
|
|
1555
|
+
*
|
|
1556
|
+
* @since 0.1.0
|
|
1557
|
+
*/
|
|
1558
|
+
export declare const defineLocaleLevel: (declaration: {
|
|
1559
|
+
readonly columns: ReadonlyArray<string>;
|
|
1560
|
+
}) => LocaleLevel;
|
|
1561
|
+
declare const OwnerSetTooLarge_base: Schema.Class<OwnerSetTooLarge, Schema.TaggedStruct<"OwnerSetTooLarge", {
|
|
1562
|
+
readonly type: Schema.String;
|
|
1563
|
+
}>, Cause.YieldableError & {
|
|
1564
|
+
readonly brand: unique symbol;
|
|
1565
|
+
}>;
|
|
1566
|
+
/**
|
|
1567
|
+
* {@link readableOwners} refused a union it could not make exact: a leaf
|
|
1568
|
+
* has more readable owners than one read returns ({@link MAX_PAGE_LIMIT}),
|
|
1569
|
+
* so the union and its total would be short (`SEC-INPUT-003`).
|
|
1570
|
+
*
|
|
1571
|
+
* @since 0.1.0
|
|
1572
|
+
*/
|
|
1573
|
+
export declare class OwnerSetTooLarge extends OwnerSetTooLarge_base {
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* The accounts that own at least one row the viewer may list in any of
|
|
1577
|
+
* `leaves` (pulpit#186): the union of each leaf's
|
|
1578
|
+
* {@link SubtypeOps.owners}, deduplicated in code-point order, paged by
|
|
1579
|
+
* `request`, with the exact union size as `total`. One read per leaf, each
|
|
1580
|
+
* for every owner it has (up to {@link MAX_PAGE_LIMIT}): a leaf with more
|
|
1581
|
+
* fails {@link OwnerSetTooLarge} rather than answer a short union. With
|
|
1582
|
+
* `among` (at most `MAX_SET_VALUES` ids) no leaf can exceed it. A hidden row
|
|
1583
|
+
* never contributes its owner (`SEC-AUTH-ENUM-001`). Fails otherwise only
|
|
1584
|
+
* with the stores' `E`.
|
|
1585
|
+
*
|
|
1586
|
+
* @example
|
|
1587
|
+
* ```ts
|
|
1588
|
+
* import { deepStrictEqual } from "node:assert";
|
|
1589
|
+
* import * as Effect from "effect/Effect";
|
|
1590
|
+
* import { readableOwners } from "@thomasfosterau/pulpit/subtype";
|
|
1591
|
+
*
|
|
1592
|
+
* const leaf = (type: string, owners: ReadonlyArray<string>) => ({
|
|
1593
|
+
* type,
|
|
1594
|
+
* owners: () => Effect.succeed({ rows: owners, total: owners.length }),
|
|
1595
|
+
* });
|
|
1596
|
+
* const page = Effect.runSync(
|
|
1597
|
+
* readableOwners([leaf("posts", ["ana", "bo"]), leaf("pages", ["bo", "cy"])], {
|
|
1598
|
+
* limit: 10,
|
|
1599
|
+
* offset: 0,
|
|
1600
|
+
* includeDeleted: false,
|
|
1601
|
+
* }),
|
|
1602
|
+
* );
|
|
1603
|
+
* deepStrictEqual(page, { rows: ["ana", "bo", "cy"], total: 3 });
|
|
1604
|
+
* ```
|
|
1605
|
+
*
|
|
1606
|
+
* @since 0.1.0
|
|
1607
|
+
*/
|
|
1608
|
+
export declare const readableOwners: <E, R>(leaves: ReadonlyArray<{
|
|
1609
|
+
readonly type: string;
|
|
1610
|
+
readonly owners: (request: OwnersRequest) => Effect.Effect<Page<string>, E, R>;
|
|
1611
|
+
}>, request: OwnersRequest) => Effect.Effect<Page<string>, OwnerSetTooLarge | E, R>;
|
|
1612
|
+
export {};
|
|
1613
|
+
//# sourceMappingURL=subtype.d.ts.map
|