@thomasfosterau/pulpit 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1271 -1
- package/dist/blobs.d.ts +249 -0
- package/dist/blobs.d.ts.map +1 -0
- package/dist/blobs.js +193 -0
- package/dist/blobs.js.map +1 -0
- package/dist/budgets.d.ts +255 -0
- package/dist/budgets.d.ts.map +1 -0
- package/dist/budgets.js +211 -0
- package/dist/budgets.js.map +1 -0
- package/dist/cache.d.ts +154 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +118 -0
- package/dist/cache.js.map +1 -0
- package/dist/collections.d.ts +242 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +215 -0
- package/dist/collections.js.map +1 -0
- package/dist/comments.d.ts +212 -0
- package/dist/comments.d.ts.map +1 -0
- package/dist/comments.js +192 -0
- package/dist/comments.js.map +1 -0
- package/dist/content.d.ts +231 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +204 -0
- package/dist/content.js.map +1 -0
- package/dist/documents.d.ts +184 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +190 -0
- package/dist/documents.js.map +1 -0
- package/dist/edges.d.ts +645 -0
- package/dist/edges.d.ts.map +1 -0
- package/dist/edges.js +558 -0
- package/dist/edges.js.map +1 -0
- package/dist/filter.d.ts +461 -0
- package/dist/filter.d.ts.map +1 -0
- package/dist/filter.js +387 -0
- package/dist/filter.js.map +1 -0
- package/dist/index.d.ts +133 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +133 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/filter-columns.d.ts +8 -0
- package/dist/internal/filter-columns.d.ts.map +1 -0
- package/dist/internal/filter-columns.js +43 -0
- package/dist/internal/filter-columns.js.map +1 -0
- package/dist/internal/list-order.d.ts +26 -0
- package/dist/internal/list-order.d.ts.map +1 -0
- package/dist/internal/list-order.js +92 -0
- package/dist/internal/list-order.js.map +1 -0
- package/dist/internal/owned-columns.d.ts +93 -0
- package/dist/internal/owned-columns.d.ts.map +1 -0
- package/dist/internal/owned-columns.js +98 -0
- package/dist/internal/owned-columns.js.map +1 -0
- package/dist/internal/plain-rows.d.ts +32 -0
- package/dist/internal/plain-rows.d.ts.map +1 -0
- package/dist/internal/plain-rows.js +32 -0
- package/dist/internal/plain-rows.js.map +1 -0
- package/dist/internal/reference-columns.d.ts +80 -0
- package/dist/internal/reference-columns.d.ts.map +1 -0
- package/dist/internal/reference-columns.js +102 -0
- package/dist/internal/reference-columns.js.map +1 -0
- package/dist/moderation.d.ts +392 -0
- package/dist/moderation.d.ts.map +1 -0
- package/dist/moderation.js +375 -0
- package/dist/moderation.js.map +1 -0
- package/dist/paths.d.ts +204 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +165 -0
- package/dist/paths.js.map +1 -0
- package/dist/proposals.d.ts +523 -0
- package/dist/proposals.d.ts.map +1 -0
- package/dist/proposals.js +505 -0
- package/dist/proposals.js.map +1 -0
- package/dist/publication.d.ts +148 -0
- package/dist/publication.d.ts.map +1 -0
- package/dist/publication.js +183 -0
- package/dist/publication.js.map +1 -0
- package/dist/purge.d.ts +224 -0
- package/dist/purge.d.ts.map +1 -0
- package/dist/purge.js +151 -0
- package/dist/purge.js.map +1 -0
- package/dist/reactions.d.ts +246 -0
- package/dist/reactions.d.ts.map +1 -0
- package/dist/reactions.js +183 -0
- package/dist/reactions.js.map +1 -0
- package/dist/registry.d.ts +316 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +305 -0
- package/dist/registry.js.map +1 -0
- package/dist/search.d.ts +366 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +273 -0
- package/dist/search.js.map +1 -0
- package/dist/social.d.ts +311 -0
- package/dist/social.d.ts.map +1 -0
- package/dist/social.js +205 -0
- package/dist/social.js.map +1 -0
- package/dist/subtype.d.ts +1613 -0
- package/dist/subtype.d.ts.map +1 -0
- package/dist/subtype.js +1300 -0
- package/dist/subtype.js.map +1 -0
- package/dist/testing.d.ts +675 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +1022 -0
- package/dist/testing.js.map +1 -0
- package/dist/version-chain.d.ts +702 -0
- package/dist/version-chain.d.ts.map +1 -0
- package/dist/version-chain.js +489 -0
- package/dist/version-chain.js.map +1 -0
- package/dist/working-copy.d.ts +116 -0
- package/dist/working-copy.d.ts.map +1 -0
- package/dist/working-copy.js +95 -0
- package/dist/working-copy.js.map +1 -0
- package/package.json +138 -4
- package/src/blobs.ts +384 -0
- package/src/budgets.ts +338 -0
- package/src/cache.ts +218 -0
- package/src/collections.ts +427 -0
- package/src/comments.ts +392 -0
- package/src/content.ts +238 -0
- package/src/documents.ts +284 -0
- package/src/edges.ts +1185 -0
- package/src/filter.ts +523 -0
- package/src/index.ts +153 -0
- package/src/internal/filter-columns.ts +50 -0
- package/src/internal/list-order.ts +117 -0
- package/src/internal/owned-columns.ts +140 -0
- package/src/internal/plain-rows.ts +54 -0
- package/src/internal/reference-columns.ts +156 -0
- package/src/moderation.ts +728 -0
- package/src/paths.ts +267 -0
- package/src/proposals.ts +1014 -0
- package/src/publication.ts +225 -0
- package/src/purge.ts +313 -0
- package/src/reactions.ts +347 -0
- package/src/registry.ts +524 -0
- package/src/search.ts +540 -0
- package/src/social.ts +469 -0
- package/src/subtype.ts +3095 -0
- package/src/testing.ts +1873 -0
- package/src/version-chain.ts +1093 -0
- package/src/working-copy.ts +192 -0
package/src/proposals.ts
ADDED
|
@@ -0,0 +1,1014 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **Suggested edits (proposals)** — a contributor's patch against a node
|
|
3
|
+
* they can read, stamped with the version it was written against, which
|
|
4
|
+
* the node's writer accepts (appending it) or rejects.
|
|
5
|
+
*
|
|
6
|
+
* The behavioural spec is gardeneria's `apps/api/src/operations/proposals.ts`,
|
|
7
|
+
* `proposal-patch.ts` and `proposal-conflict.ts` (#565), with their tests; a
|
|
8
|
+
* `#NNN` here names a gardeneria issue.
|
|
9
|
+
*
|
|
10
|
+
* ## The model (owner decisions on pulpit#49)
|
|
11
|
+
*
|
|
12
|
+
* - **A proposal is a plain row, not a node.** It carries the target, the
|
|
13
|
+
* patch as submitted, the `baseVersion` it was written against (stamped
|
|
14
|
+
* from the target's head, never client-supplied), its status (`pending |
|
|
15
|
+
* accepted | rejected | withdrawn`), the reviewer and the timestamps.
|
|
16
|
+
* Rejecting or withdrawing changes its status; only accepting writes to
|
|
17
|
+
* the target.
|
|
18
|
+
* - **Accepting appends through the target's own `update`** (`PUB-003`), as
|
|
19
|
+
* the account that accepted, so it runs the target's gate, its precondition
|
|
20
|
+
* and its before hooks, and lands as an ordinary `update` version
|
|
21
|
+
* (`VERSION-CHAIN-001/002`). The version records the proposer as its
|
|
22
|
+
* `proposedBy` fact beside that actor, so history shows both.
|
|
23
|
+
*
|
|
24
|
+
* ## Classification, in two tiers (#565, decision 2)
|
|
25
|
+
*
|
|
26
|
+
* - {@link classifyProposalBase} — `clean` or `moved`, from two version
|
|
27
|
+
* numbers in hand: no read, safe for every row of a list page.
|
|
28
|
+
* - {@link classifyProposalConflict} — `clean`, `stale` or `conflicting`,
|
|
29
|
+
* from the target's states at the base version and at the head
|
|
30
|
+
* (`diffStates`): two version reads, so only {@link ProposalOps.review}
|
|
31
|
+
* and {@link ProposalOps.accept} run it (`PERF-DB-003`).
|
|
32
|
+
*
|
|
33
|
+
* A touched attribute either state lacks (one kept in a side table, outside
|
|
34
|
+
* the chain) cannot be compared, so on a moved target it counts as
|
|
35
|
+
* conflicting: fail closed, since `stale` would let an accept overwrite a
|
|
36
|
+
* change nobody looked at.
|
|
37
|
+
*
|
|
38
|
+
* ## The gates
|
|
39
|
+
*
|
|
40
|
+
* - **Submit:** a signed-in account at the application's propose rung
|
|
41
|
+
* ({@link ProposalsDefinition.canPropose}) that can `get` the target; a
|
|
42
|
+
* target it can't read fails `NodeNotFound`, never `Forbidden`
|
|
43
|
+
* (`SEC-AUTH-ENUM-001`).
|
|
44
|
+
* - **Accept / reject / review:** the target's write gate (`requireWriter`:
|
|
45
|
+
* owner-or-moderator on an owned leaf, the corpus-write rung on a reference
|
|
46
|
+
* one), before the proposal's status is disclosed. Accept and review then
|
|
47
|
+
* read the two versions through the target's gated `compare` (`READ-009`).
|
|
48
|
+
* - **Withdraw:** the proposal's own author, while it is pending.
|
|
49
|
+
* - **Reads:** the moderate rung reads every proposal; any other signed-in
|
|
50
|
+
* account reads its own and those on targets it owns; the anonymous read
|
|
51
|
+
* none. A proposal is never public.
|
|
52
|
+
*
|
|
53
|
+
* ## A localised leaf (pulpit#184)
|
|
54
|
+
*
|
|
55
|
+
* On a leaf with a locale level (pulpit#56) a proposal may also carry a
|
|
56
|
+
* per-locale patch over the leaf's translated columns
|
|
57
|
+
* ({@link ProposalsDefinition.localeAttributes}), stored beside its shared
|
|
58
|
+
* patch and accepted through the same `update` as one version. Its conflict
|
|
59
|
+
* check reads each touched locale at the base and at the head: a touched
|
|
60
|
+
* translated column conflicts when it changed, when either version lacks it,
|
|
61
|
+
* or when the locale was added or dropped in between. A proposal may add a
|
|
62
|
+
* translation but never drop one (a `null` locale is refused): removing a
|
|
63
|
+
* locale is the owner's.
|
|
64
|
+
*
|
|
65
|
+
* A write budget (pulpit#199) is an opt-in: `budget` throttles `submit`.
|
|
66
|
+
* Notifications and the wire shape are the application's.
|
|
67
|
+
*
|
|
68
|
+
* @copyright Thomas Foster
|
|
69
|
+
* @module pulpit/proposals
|
|
70
|
+
* @since 0.1.0
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
import * as Arr from "effect/Array";
|
|
74
|
+
import * as Effect from "effect/Effect";
|
|
75
|
+
import * as Option from "effect/Option";
|
|
76
|
+
import * as Predicate from "effect/Predicate";
|
|
77
|
+
import * as Record from "effect/Record";
|
|
78
|
+
import * as Schema from "effect/Schema";
|
|
79
|
+
import type { BoundBudget } from "./budgets.ts";
|
|
80
|
+
import { always, eq, never, or, type ListFilter } from "./filter.ts";
|
|
81
|
+
import { nowDate, plainRowQuery, signedIn as signedInAs } from "./internal/plain-rows.ts";
|
|
82
|
+
import {
|
|
83
|
+
Forbidden,
|
|
84
|
+
requireWriter,
|
|
85
|
+
Unauthorized,
|
|
86
|
+
type AccessPolicy,
|
|
87
|
+
type Page,
|
|
88
|
+
type SubtypeOps,
|
|
89
|
+
type Viewer,
|
|
90
|
+
type ViewerAccount,
|
|
91
|
+
type WriteRejected,
|
|
92
|
+
} from "./subtype.ts";
|
|
93
|
+
import {
|
|
94
|
+
diffStates,
|
|
95
|
+
localesOf,
|
|
96
|
+
NodeNotFound,
|
|
97
|
+
type ChainPatch,
|
|
98
|
+
type LocalePatch,
|
|
99
|
+
type ChainWrite,
|
|
100
|
+
type StaleVersion,
|
|
101
|
+
type VersionChainStore,
|
|
102
|
+
type VersionConflict,
|
|
103
|
+
type VersionNotFound,
|
|
104
|
+
type VersionSnapshot,
|
|
105
|
+
} from "./version-chain.ts";
|
|
106
|
+
|
|
107
|
+
// ── Vocabulary ────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A proposal's status. `pending` is the only open one; the other three are
|
|
111
|
+
* terminal, and a withdrawn proposal is recorded, not deleted.
|
|
112
|
+
*
|
|
113
|
+
* @since 0.1.0
|
|
114
|
+
*/
|
|
115
|
+
export const ProposalStatus = Schema.Literals(["pending", "accepted", "rejected", "withdrawn"]);
|
|
116
|
+
/**
|
|
117
|
+
* The {@link ProposalStatus} literal union.
|
|
118
|
+
*
|
|
119
|
+
* @since 0.1.0
|
|
120
|
+
*/
|
|
121
|
+
export type ProposalStatus = typeof ProposalStatus.Type;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The cheap verdict: `moved` means "stale or conflicting, not yet told
|
|
125
|
+
* apart", never a claim that the patch still applies.
|
|
126
|
+
*
|
|
127
|
+
* @since 0.1.0
|
|
128
|
+
*/
|
|
129
|
+
export type ProposalBaseState = "clean" | "moved";
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The full verdict: `clean` (the target has not moved), `stale` (it moved,
|
|
133
|
+
* but no touched attribute changed) or `conflicting`.
|
|
134
|
+
*
|
|
135
|
+
* @since 0.1.0
|
|
136
|
+
*/
|
|
137
|
+
export type ProposalConflictState = "clean" | "stale" | "conflicting";
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* One proposal row, as the {@link ProposalStore} keeps it.
|
|
141
|
+
*
|
|
142
|
+
* @since 0.1.0
|
|
143
|
+
*/
|
|
144
|
+
export interface ProposalRow {
|
|
145
|
+
readonly id: string;
|
|
146
|
+
/** The target node and its leaf type. */
|
|
147
|
+
readonly targetId: string;
|
|
148
|
+
readonly targetType: string;
|
|
149
|
+
/** The proposer. */
|
|
150
|
+
readonly authorId: string;
|
|
151
|
+
/** The patch as submitted (what the reviewer reads), decoded again at accept. */
|
|
152
|
+
readonly patch: Schema.Json;
|
|
153
|
+
/**
|
|
154
|
+
* A localised leaf's per-locale patch as submitted (pulpit#184), decoded
|
|
155
|
+
* again at accept; none for a proposal that touches no translation.
|
|
156
|
+
*/
|
|
157
|
+
readonly locales: Option.Option<Schema.Json>;
|
|
158
|
+
readonly rationale: Option.Option<string>;
|
|
159
|
+
/** The target's head when the proposal was submitted. */
|
|
160
|
+
readonly baseVersion: bigint;
|
|
161
|
+
readonly status: ProposalStatus;
|
|
162
|
+
readonly submittedAt: Date;
|
|
163
|
+
/** When, and by whom, it left `pending` (the author, for a withdrawal). */
|
|
164
|
+
readonly decidedAt: Option.Option<Date>;
|
|
165
|
+
readonly decidedBy: Option.Option<string>;
|
|
166
|
+
readonly decisionNote: Option.Option<string>;
|
|
167
|
+
/** The version an accepted proposal produced: its provenance pointer. */
|
|
168
|
+
readonly appliedVersion: Option.Option<bigint>;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A proposal row as a read returns it, with the target's head version (none
|
|
173
|
+
* when the target has no versions), read in the same query.
|
|
174
|
+
*
|
|
175
|
+
* @since 0.1.0
|
|
176
|
+
*/
|
|
177
|
+
export interface ProposalListing {
|
|
178
|
+
readonly row: ProposalRow;
|
|
179
|
+
readonly targetVersion: Option.Option<bigint>;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The write that closes a pending proposal.
|
|
184
|
+
*
|
|
185
|
+
* @since 0.1.0
|
|
186
|
+
*/
|
|
187
|
+
export interface ProposalDecision {
|
|
188
|
+
readonly status: Exclude<ProposalStatus, "pending">;
|
|
189
|
+
readonly decidedAt: Date;
|
|
190
|
+
readonly decidedBy: string;
|
|
191
|
+
readonly decisionNote: Option.Option<string>;
|
|
192
|
+
readonly appliedVersion: Option.Option<bigint>;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Errors ────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* No proposal at that id that the viewer may read: the wire's 404.
|
|
199
|
+
*
|
|
200
|
+
* @since 0.1.0
|
|
201
|
+
*/
|
|
202
|
+
export class ProposalNotFound extends Schema.TaggedError<
|
|
203
|
+
ProposalNotFound,
|
|
204
|
+
{ readonly brand: unique symbol }
|
|
205
|
+
>()("ProposalNotFound", {
|
|
206
|
+
id: Schema.String,
|
|
207
|
+
}) {}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The proposal is no longer pending, so it can't be decided or withdrawn
|
|
211
|
+
* again: the wire's 409.
|
|
212
|
+
*
|
|
213
|
+
* @since 0.1.0
|
|
214
|
+
*/
|
|
215
|
+
export class ProposalClosed extends Schema.TaggedError<
|
|
216
|
+
ProposalClosed,
|
|
217
|
+
{ readonly brand: unique symbol }
|
|
218
|
+
>()("ProposalClosed", {
|
|
219
|
+
id: Schema.String,
|
|
220
|
+
status: ProposalStatus,
|
|
221
|
+
}) {}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The target changed underneath a touched attribute since the proposal was
|
|
225
|
+
* written (or one can't be compared), so accepting would overwrite an edit
|
|
226
|
+
* the reviewer never saw: the wire's 409. Reject it and resubmit against
|
|
227
|
+
* the new head.
|
|
228
|
+
*
|
|
229
|
+
* @since 0.1.0
|
|
230
|
+
*/
|
|
231
|
+
export class ProposalConflict extends Schema.TaggedError<
|
|
232
|
+
ProposalConflict,
|
|
233
|
+
{ readonly brand: unique symbol }
|
|
234
|
+
>()("ProposalConflict", {
|
|
235
|
+
id: Schema.String,
|
|
236
|
+
attributes: Schema.Array(Schema.String),
|
|
237
|
+
/** The conflicting translated columns, by locale (pulpit#184); `{}` when none. */
|
|
238
|
+
locales: Schema.Record(Schema.String, Schema.Array(Schema.String)),
|
|
239
|
+
}) {}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A patch that can't be proposed: not an object, empty, touching a
|
|
243
|
+
* forbidden or unknown attribute, or refused by the leaf's decoder. The
|
|
244
|
+
* wire's 422.
|
|
245
|
+
*
|
|
246
|
+
* @since 0.1.0
|
|
247
|
+
*/
|
|
248
|
+
export class InvalidProposalPatch extends Schema.TaggedError<
|
|
249
|
+
InvalidProposalPatch,
|
|
250
|
+
{ readonly brand: unique symbol }
|
|
251
|
+
>()("InvalidProposalPatch", { type: Schema.String, reason: Schema.String }) {}
|
|
252
|
+
|
|
253
|
+
// ── Classification ────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The cheap verdict, from the base version and the target's head: `clean`
|
|
257
|
+
* only when they are equal.
|
|
258
|
+
*
|
|
259
|
+
* @example
|
|
260
|
+
* ```ts
|
|
261
|
+
* import { strictEqual } from "node:assert";
|
|
262
|
+
* import { classifyProposalBase } from "@thomasfosterau/pulpit/proposals";
|
|
263
|
+
*
|
|
264
|
+
* strictEqual(classifyProposalBase(4n, 4n), "clean");
|
|
265
|
+
* strictEqual(classifyProposalBase(4n, 5n), "moved");
|
|
266
|
+
* ```
|
|
267
|
+
*
|
|
268
|
+
* @since 0.1.0
|
|
269
|
+
*/
|
|
270
|
+
export const classifyProposalBase = (
|
|
271
|
+
baseVersion: bigint,
|
|
272
|
+
currentVersion: bigint,
|
|
273
|
+
): ProposalBaseState => (baseVersion === currentVersion ? "clean" : "moved");
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The full verdict, and the attributes responsible for it.
|
|
277
|
+
*
|
|
278
|
+
* @since 0.1.0
|
|
279
|
+
*/
|
|
280
|
+
export interface ProposalConflictVerdict {
|
|
281
|
+
readonly state: ProposalConflictState;
|
|
282
|
+
/** The touched attributes that changed or can't be compared; empty unless `conflicting`. */
|
|
283
|
+
readonly conflictingAttributes: ReadonlyArray<string>;
|
|
284
|
+
/**
|
|
285
|
+
* The touched translated columns that changed or can't be compared, by
|
|
286
|
+
* locale (pulpit#184); a locale with none is omitted, so it is `{}` unless
|
|
287
|
+
* `conflicting`.
|
|
288
|
+
*/
|
|
289
|
+
readonly conflictingLocales: TouchedLocales;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The translated columns a proposal touches, by locale (pulpit#184).
|
|
294
|
+
*
|
|
295
|
+
* @since 0.1.0
|
|
296
|
+
*/
|
|
297
|
+
export type TouchedLocales = { readonly [locale: string]: ReadonlyArray<string> };
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The full verdict from the target's states at the base version and at the
|
|
301
|
+
* head, over the attributes the patch touches. Equal versions are `clean`
|
|
302
|
+
* without comparing anything. Otherwise a touched attribute conflicts when
|
|
303
|
+
* `diffStates` reports it changed (values compare structurally: a document
|
|
304
|
+
* by content, `null` against a value is a change, two `null`s are not) or
|
|
305
|
+
* when either state lacks it (fail closed).
|
|
306
|
+
*
|
|
307
|
+
* On a localised leaf, `touchedLocales` names the translated columns the
|
|
308
|
+
* proposal touches, by locale (pulpit#184), and each is read from the two
|
|
309
|
+
* versions' locale levels the same way. A locale neither version carries
|
|
310
|
+
* cannot conflict (a new translation); a locale only one carries (added or
|
|
311
|
+
* dropped in between) conflicts on every touched column. Pure.
|
|
312
|
+
*
|
|
313
|
+
* @example
|
|
314
|
+
* ```ts
|
|
315
|
+
* import { deepStrictEqual } from "node:assert";
|
|
316
|
+
* import { classifyProposalConflict } from "@thomasfosterau/pulpit/proposals";
|
|
317
|
+
*
|
|
318
|
+
* const at = (version: bigint, state: { readonly [column: string]: string }) => ({
|
|
319
|
+
* version,
|
|
320
|
+
* action: "update" as const,
|
|
321
|
+
* timestamp: new Date(0),
|
|
322
|
+
* state,
|
|
323
|
+
* });
|
|
324
|
+
* deepStrictEqual(
|
|
325
|
+
* classifyProposalConflict(at(4n, { name: "a", note: "x" }), at(5n, { name: "b", note: "x" }), [
|
|
326
|
+
* "name",
|
|
327
|
+
* "note",
|
|
328
|
+
* ]),
|
|
329
|
+
* { state: "conflicting", conflictingAttributes: ["name"], conflictingLocales: {} },
|
|
330
|
+
* );
|
|
331
|
+
* ```
|
|
332
|
+
*
|
|
333
|
+
* @since 0.1.0
|
|
334
|
+
*/
|
|
335
|
+
export const classifyProposalConflict = (
|
|
336
|
+
base: VersionSnapshot,
|
|
337
|
+
current: VersionSnapshot,
|
|
338
|
+
touchedKeys: ReadonlyArray<string>,
|
|
339
|
+
touchedLocales: TouchedLocales = {},
|
|
340
|
+
): ProposalConflictVerdict => {
|
|
341
|
+
if (base.version === current.version) {
|
|
342
|
+
return { state: "clean", conflictingAttributes: [], conflictingLocales: {} };
|
|
343
|
+
}
|
|
344
|
+
const conflictingAttributes = conflictsIn(base.state, current.state, touchedKeys);
|
|
345
|
+
const before = localesOf(base);
|
|
346
|
+
const after = localesOf(current);
|
|
347
|
+
const conflictingLocales = Record.filter(
|
|
348
|
+
Record.map(touchedLocales, (keys, locale) =>
|
|
349
|
+
Option.match(Record.get(before, locale), {
|
|
350
|
+
// A locale neither version carries is a new translation; one only the head carries was added since.
|
|
351
|
+
onNone: () => (Record.has(after, locale) ? keys : []),
|
|
352
|
+
onSome: (from) =>
|
|
353
|
+
Option.match(Record.get(after, locale), {
|
|
354
|
+
// Dropped since the base.
|
|
355
|
+
onNone: () => keys,
|
|
356
|
+
onSome: (to) => conflictsIn(from, to, keys),
|
|
357
|
+
}),
|
|
358
|
+
}),
|
|
359
|
+
),
|
|
360
|
+
Arr.isReadonlyArrayNonEmpty,
|
|
361
|
+
);
|
|
362
|
+
return Arr.isReadonlyArrayNonEmpty(conflictingAttributes) ||
|
|
363
|
+
!Record.isEmptyRecord(conflictingLocales)
|
|
364
|
+
? { state: "conflicting", conflictingAttributes, conflictingLocales }
|
|
365
|
+
: { state: "stale", conflictingAttributes: [], conflictingLocales: {} };
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
/** The touched `keys` that changed from `from` to `to`, or that either lacks (fail closed). */
|
|
369
|
+
const conflictsIn = (
|
|
370
|
+
from: VersionSnapshot["state"],
|
|
371
|
+
to: VersionSnapshot["state"],
|
|
372
|
+
keys: ReadonlyArray<string>,
|
|
373
|
+
): ReadonlyArray<string> => {
|
|
374
|
+
const changes = diffStates(from, to);
|
|
375
|
+
return Arr.filter(
|
|
376
|
+
keys,
|
|
377
|
+
(key) => !Record.has(from, key) || !Record.has(to, key) || Record.has(changes, key),
|
|
378
|
+
);
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// ── Patch validation ──────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The attributes no proposal may carry, whatever the leaf declares:
|
|
385
|
+
* identity (`id`, `type`) and the publication axes (`visibility`, `draft`,
|
|
386
|
+
* `publishedAt`), which are the owner's to set (`PUB-005`). An application
|
|
387
|
+
* adds its own through {@link ProposalsDefinition.forbidden} (gardeneria
|
|
388
|
+
* adds `slug`, a node's public URL).
|
|
389
|
+
*
|
|
390
|
+
* @since 0.1.0
|
|
391
|
+
*/
|
|
392
|
+
export const PROPOSAL_FORBIDDEN_KEYS: ReadonlyArray<string> = [
|
|
393
|
+
"id",
|
|
394
|
+
"type",
|
|
395
|
+
"visibility",
|
|
396
|
+
"draft",
|
|
397
|
+
"publishedAt",
|
|
398
|
+
];
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* A patch that passed validation.
|
|
402
|
+
*
|
|
403
|
+
* @since 0.1.0
|
|
404
|
+
*/
|
|
405
|
+
export interface ValidatedPatch {
|
|
406
|
+
/** The decoded patch, ready for the target's `update`. */
|
|
407
|
+
readonly patch: ChainPatch;
|
|
408
|
+
/** The attribute names it touches, in the leaf's declaration order. */
|
|
409
|
+
readonly touchedKeys: ReadonlyArray<string>;
|
|
410
|
+
/** The decoded per-locale patch (pulpit#184), ready for the target's `update`; `{}` for none. */
|
|
411
|
+
readonly locales: LocalePatch;
|
|
412
|
+
/** The translated columns it touches, by locale, each in the declaration order. */
|
|
413
|
+
readonly touchedLocales: TouchedLocales;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const quoted = (keys: ReadonlyArray<string>) => Arr.map(keys, (key) => `\`${key}\``).join(", ");
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Why `keys` is not an admissible key set, or none. Forbidden is checked
|
|
420
|
+
* before unknown, so a `visibility` patch reads as a policy refusal, not a
|
|
421
|
+
* typo, even on a leaf that doesn't declare the attribute.
|
|
422
|
+
*/
|
|
423
|
+
const keyViolation = (
|
|
424
|
+
keys: ReadonlyArray<string>,
|
|
425
|
+
attributes: ReadonlyArray<string>,
|
|
426
|
+
forbidden: ReadonlyArray<string>,
|
|
427
|
+
): Option.Option<string> => {
|
|
428
|
+
const refused = Arr.filter(keys, (key) => Arr.contains(forbidden, key));
|
|
429
|
+
if (Arr.isReadonlyArrayNonEmpty(refused)) {
|
|
430
|
+
return Option.some(
|
|
431
|
+
`${quoted(refused)} can't be proposed: identity and publication are the owner's to set`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
const unknown = Arr.filter(keys, (key) => !Arr.contains(attributes, key));
|
|
435
|
+
return Arr.isReadonlyArrayNonEmpty(unknown)
|
|
436
|
+
? Option.some(`${quoted(unknown)} is not a proposable attribute`)
|
|
437
|
+
: Option.none();
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
/** A JSON object's members, or none for an array, a scalar or `null`. */
|
|
441
|
+
const jsonMembers = (
|
|
442
|
+
value: Schema.Json,
|
|
443
|
+
): Option.Option<ReadonlyArray<readonly [string, Schema.Json]>> =>
|
|
444
|
+
value === null ||
|
|
445
|
+
Predicate.isBoolean(value) ||
|
|
446
|
+
Predicate.isNumber(value) ||
|
|
447
|
+
Predicate.isString(value) ||
|
|
448
|
+
Arr.isArray<Schema.Json>(value)
|
|
449
|
+
? Option.none()
|
|
450
|
+
: Option.some(Record.toEntries(value));
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Check a submitted patch's shape and keys against the leaf's proposable
|
|
454
|
+
* `attributes` (its update fields, in declaration order) and the
|
|
455
|
+
* `forbidden` keys, then decode it with the leaf's own decoder. Unknown keys
|
|
456
|
+
* are refused loudly rather than dropped (a misspelt attribute would
|
|
457
|
+
* otherwise accept and change nothing). Run at submit and again at accept,
|
|
458
|
+
* since the leaf's schema may have moved in between. Fails
|
|
459
|
+
* {@link InvalidProposalPatch}.
|
|
460
|
+
*
|
|
461
|
+
* On a localised leaf, `options.locales` is the per-locale patch as
|
|
462
|
+
* submitted (pulpit#184): an object from a non-empty locale tag to an object
|
|
463
|
+
* of changes to `options.localeAttributes` (the translated columns), each
|
|
464
|
+
* checked and decoded as the shared patch is. A `null` locale (dropping a
|
|
465
|
+
* translation) is refused, as is any locale on a leaf with no
|
|
466
|
+
* `localeAttributes`. The shared patch may then be `{}`, so long as some
|
|
467
|
+
* locale changes something.
|
|
468
|
+
*
|
|
469
|
+
* @since 0.1.0
|
|
470
|
+
*/
|
|
471
|
+
export const validateProposalPatch = <R>(
|
|
472
|
+
type: string,
|
|
473
|
+
patch: Schema.Json,
|
|
474
|
+
options: {
|
|
475
|
+
readonly attributes: ReadonlyArray<string>;
|
|
476
|
+
readonly forbidden: ReadonlyArray<string>;
|
|
477
|
+
readonly decode: (patch: Schema.Json) => Effect.Effect<ChainPatch, InvalidProposalPatch, R>;
|
|
478
|
+
/** The per-locale patch as submitted (pulpit#184); absent, none. */
|
|
479
|
+
// oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "no translation"
|
|
480
|
+
readonly locales?: Schema.Json | undefined;
|
|
481
|
+
/** The translated columns a proposal may touch, in declaration order; absent, none. */
|
|
482
|
+
// oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "no locale level"
|
|
483
|
+
readonly localeAttributes?: ReadonlyArray<string> | undefined;
|
|
484
|
+
},
|
|
485
|
+
): Effect.Effect<ValidatedPatch, InvalidProposalPatch, R> => {
|
|
486
|
+
const invalid = (reason: string) => Effect.fail(new InvalidProposalPatch({ type, reason }));
|
|
487
|
+
if (!Predicate.isObject(patch) || Arr.isArray(patch)) {
|
|
488
|
+
return invalid("expected an object of attribute changes");
|
|
489
|
+
}
|
|
490
|
+
const localeMembers = jsonMembers(options.locales ?? {});
|
|
491
|
+
if (Option.isNone(localeMembers)) return invalid("expected an object of changes per locale");
|
|
492
|
+
const keys = Record.keys(patch);
|
|
493
|
+
const locales = localeMembers.value;
|
|
494
|
+
if (!Arr.isReadonlyArrayNonEmpty(keys) && !Arr.isReadonlyArrayNonEmpty(locales)) {
|
|
495
|
+
return invalid("it proposes no changes");
|
|
496
|
+
}
|
|
497
|
+
const translated = options.localeAttributes ?? [];
|
|
498
|
+
const localeProblem = Arr.findFirst(locales, ([locale, changes]) =>
|
|
499
|
+
localeViolation(locale, changes, translated, options.forbidden),
|
|
500
|
+
);
|
|
501
|
+
if (Option.isSome(localeProblem)) return invalid(localeProblem.value);
|
|
502
|
+
return Option.match(keyViolation(keys, options.attributes, options.forbidden), {
|
|
503
|
+
onSome: invalid,
|
|
504
|
+
onNone: () =>
|
|
505
|
+
Effect.gen(function* () {
|
|
506
|
+
const decoded = Arr.isReadonlyArrayNonEmpty(keys) ? yield* options.decode(patch) : {};
|
|
507
|
+
const decodedLocales = yield* Effect.forEach(
|
|
508
|
+
locales,
|
|
509
|
+
([locale, changes]) =>
|
|
510
|
+
Effect.map(
|
|
511
|
+
options.decode(changes),
|
|
512
|
+
(decodedChanges) => [locale, decodedChanges] as const,
|
|
513
|
+
),
|
|
514
|
+
{ concurrency: 1 },
|
|
515
|
+
);
|
|
516
|
+
return {
|
|
517
|
+
patch: decoded,
|
|
518
|
+
touchedKeys: Arr.filter(options.attributes, (key) => Arr.contains(keys, key)),
|
|
519
|
+
locales: Record.fromEntries(decodedLocales),
|
|
520
|
+
touchedLocales: Record.fromEntries(
|
|
521
|
+
Arr.map(locales, ([locale, changes]) => {
|
|
522
|
+
const touched = Option.match(jsonMembers(changes), {
|
|
523
|
+
onNone: () => [],
|
|
524
|
+
onSome: Arr.map(([key]) => key),
|
|
525
|
+
});
|
|
526
|
+
return [locale, Arr.filter(translated, (key) => Arr.contains(touched, key))] as const;
|
|
527
|
+
}),
|
|
528
|
+
),
|
|
529
|
+
};
|
|
530
|
+
}),
|
|
531
|
+
});
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
/** Why one locale's entry of a per-locale patch is not admissible, or none. */
|
|
535
|
+
const localeViolation = (
|
|
536
|
+
locale: string,
|
|
537
|
+
changes: Schema.Json,
|
|
538
|
+
translated: ReadonlyArray<string>,
|
|
539
|
+
forbidden: ReadonlyArray<string>,
|
|
540
|
+
): Option.Option<string> => {
|
|
541
|
+
if (!Arr.isReadonlyArrayNonEmpty(translated)) {
|
|
542
|
+
return Option.some("this leaf takes no translated attributes");
|
|
543
|
+
}
|
|
544
|
+
if (locale === "") return Option.some("a locale tag is empty");
|
|
545
|
+
if (changes === null) {
|
|
546
|
+
return Option.some(`dropping the \`${locale}\` translation is the owner's to do`);
|
|
547
|
+
}
|
|
548
|
+
const members = jsonMembers(changes);
|
|
549
|
+
if (Option.isNone(members)) {
|
|
550
|
+
return Option.some(`expected an object of attribute changes for \`${locale}\``);
|
|
551
|
+
}
|
|
552
|
+
const keys = Arr.map(members.value, ([key]) => key);
|
|
553
|
+
if (!Arr.isReadonlyArrayNonEmpty(keys)) {
|
|
554
|
+
return Option.some(`it proposes no changes for \`${locale}\``);
|
|
555
|
+
}
|
|
556
|
+
return Option.map(
|
|
557
|
+
keyViolation(keys, translated, forbidden),
|
|
558
|
+
(reason) => `${reason} (in \`${locale}\`)`,
|
|
559
|
+
);
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
// ── The seam ──────────────────────────────────────────────────────────
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* The proposal table an application supplies (gardeneria's
|
|
566
|
+
* `participation.proposal`). It applies no policy, never opens a
|
|
567
|
+
* transaction, and fails only with its `E`. See the package README → Seams
|
|
568
|
+
* for the expected schema.
|
|
569
|
+
*
|
|
570
|
+
* @since 0.1.0
|
|
571
|
+
*/
|
|
572
|
+
export interface ProposalStore<E, R> {
|
|
573
|
+
/** Mint a fresh proposal id (a UUID). */
|
|
574
|
+
readonly mintId: Effect.Effect<string, E, R>;
|
|
575
|
+
/** Insert a new (pending) row. */
|
|
576
|
+
readonly insert: (row: ProposalRow) => Effect.Effect<void, E, R>;
|
|
577
|
+
/** One row with its target's head version, or none. */
|
|
578
|
+
readonly get: (id: string) => Effect.Effect<Option.Option<ProposalListing>, E, R>;
|
|
579
|
+
/**
|
|
580
|
+
* One row, locked for the rest of the transaction (`SELECT … FOR
|
|
581
|
+
* UPDATE`), so two decisions on one proposal serialise and the second
|
|
582
|
+
* sees it closed.
|
|
583
|
+
*/
|
|
584
|
+
readonly lock: (id: string) => Effect.Effect<Option.Option<ProposalRow>, E, R>;
|
|
585
|
+
/** Close a pending row: write its terminal status, decider and provenance. */
|
|
586
|
+
readonly close: (id: string, decision: ProposalDecision) => Effect.Effect<void, E, R>;
|
|
587
|
+
/**
|
|
588
|
+
* The page under `where`, newest first (`submitted_at DESC, id DESC`,
|
|
589
|
+
* a total order), with the unpaged total. `where` reads the columns
|
|
590
|
+
* `authorId`, `targetId`, `targetType`, `status` and `targetOwnerId`
|
|
591
|
+
* (the target's owner, `core.node_owner`, or `NULL`).
|
|
592
|
+
*/
|
|
593
|
+
readonly list: (query: {
|
|
594
|
+
readonly where: ListFilter<never>;
|
|
595
|
+
readonly limit: number;
|
|
596
|
+
readonly offset: number;
|
|
597
|
+
}) => Effect.Effect<Page<ProposalListing>, E, R>;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ── The operations ────────────────────────────────────────────────────
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* One leaf's proposal surface: its target operations, the stores, and the
|
|
604
|
+
* patch rules.
|
|
605
|
+
*
|
|
606
|
+
* @since 0.1.0
|
|
607
|
+
*/
|
|
608
|
+
export interface ProposalsDefinition<Resource, E, R> {
|
|
609
|
+
/** The target leaf's wire type: the proposals' `targetType`. */
|
|
610
|
+
readonly type: string;
|
|
611
|
+
/** The target leaf's operations: `get` gates a submission, `compare` and `update` an acceptance. */
|
|
612
|
+
readonly target: SubtypeOps<Resource, E, R>;
|
|
613
|
+
/** The target leaf's chain store, for the head a proposal is stamped with. */
|
|
614
|
+
readonly chain: VersionChainStore<E, R>;
|
|
615
|
+
/** The target's owner (its `LeafStore.ownerOf`), for the write gate and the reads. */
|
|
616
|
+
readonly ownerOf: (id: string) => Effect.Effect<Option.Option<string>, E, R>;
|
|
617
|
+
readonly proposals: ProposalStore<E, R>;
|
|
618
|
+
/** The request's viewer. */
|
|
619
|
+
readonly viewer: Effect.Effect<Viewer, never, R>;
|
|
620
|
+
readonly policy: AccessPolicy;
|
|
621
|
+
/** The rung that may propose (gardeneria: any signed-in account; website: contributor+). */
|
|
622
|
+
readonly canPropose: (account: ViewerAccount) => boolean;
|
|
623
|
+
/** The attributes a proposal may touch: the leaf's update fields, in declaration order. */
|
|
624
|
+
readonly attributes: ReadonlyArray<string>;
|
|
625
|
+
/** The keys no proposal may carry; {@link PROPOSAL_FORBIDDEN_KEYS} when omitted. */
|
|
626
|
+
// oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is the kernel's default list
|
|
627
|
+
readonly forbidden?: ReadonlyArray<string> | undefined;
|
|
628
|
+
/** The leaf's update decoder over a patch's values (`SEC-INPUT-001`), shared or one locale's. */
|
|
629
|
+
readonly decode: (patch: Schema.Json) => Effect.Effect<ChainPatch, InvalidProposalPatch, R>;
|
|
630
|
+
/**
|
|
631
|
+
* The translated columns a proposal may touch on a localised leaf
|
|
632
|
+
* (pulpit#184), in declaration order: the leaf's locale level's columns,
|
|
633
|
+
* or fewer. Omitted, a proposal carries no per-locale patch.
|
|
634
|
+
*/
|
|
635
|
+
// oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "no translation is proposable"
|
|
636
|
+
readonly localeAttributes?: ReadonlyArray<string> | undefined;
|
|
637
|
+
/**
|
|
638
|
+
* A write budget on `submit` (pulpit#199, `./budgets`' `bindBudget`),
|
|
639
|
+
* spent as the `submit` action once the submission validates and before
|
|
640
|
+
* it is stored. Omitted, submissions are unthrottled.
|
|
641
|
+
*/
|
|
642
|
+
// oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "no budget"
|
|
643
|
+
readonly budget?: BoundBudget<E, R> | undefined;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* A proposal as a read returns it: the row, and the cheap verdict while it
|
|
648
|
+
* is pending.
|
|
649
|
+
*
|
|
650
|
+
* @since 0.1.0
|
|
651
|
+
*/
|
|
652
|
+
export interface ProposalView {
|
|
653
|
+
readonly proposal: ProposalRow;
|
|
654
|
+
readonly baseState: Option.Option<ProposalBaseState>;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* A pending proposal as the review screen reads it: the full verdict too.
|
|
659
|
+
*
|
|
660
|
+
* @since 0.1.0
|
|
661
|
+
*/
|
|
662
|
+
export interface ProposalReview extends ProposalView {
|
|
663
|
+
readonly conflict: ProposalConflictVerdict;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
type WriteGate = Unauthorized | Forbidden | NodeNotFound;
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* The operations {@link defineProposals} produces.
|
|
670
|
+
*
|
|
671
|
+
* @since 0.1.0
|
|
672
|
+
*/
|
|
673
|
+
export interface ProposalOps<E, R> {
|
|
674
|
+
/**
|
|
675
|
+
* Submit a pending proposal against `targetId`, stamped with its head.
|
|
676
|
+
* Fails `Unauthorized` when anonymous, `Forbidden` below the propose rung,
|
|
677
|
+
* `NodeNotFound` when the viewer can't `get` the target,
|
|
678
|
+
* {@link InvalidProposalPatch}, and `WriteRejected` (`RATE_LIMITED`) when
|
|
679
|
+
* the declared budget is spent.
|
|
680
|
+
*/
|
|
681
|
+
readonly submit: (input: {
|
|
682
|
+
readonly targetId: string;
|
|
683
|
+
readonly patch: Schema.Json;
|
|
684
|
+
/** A localised leaf's per-locale patch (pulpit#184); absent, none. */
|
|
685
|
+
// oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "no translation"
|
|
686
|
+
readonly locales?: Schema.Json | undefined;
|
|
687
|
+
readonly rationale: Option.Option<string>;
|
|
688
|
+
}) => Effect.Effect<
|
|
689
|
+
{ readonly id: string },
|
|
690
|
+
Unauthorized | Forbidden | NodeNotFound | InvalidProposalPatch | WriteRejected | E,
|
|
691
|
+
R
|
|
692
|
+
>;
|
|
693
|
+
/**
|
|
694
|
+
* One proposal the viewer may read (the moderate rung, its author, or the
|
|
695
|
+
* target's owner), with its cheap verdict while pending. Fails
|
|
696
|
+
* {@link ProposalNotFound} for a missing or unreadable one.
|
|
697
|
+
*/
|
|
698
|
+
readonly get: (id: string) => Effect.Effect<ProposalView, ProposalNotFound | E, R>;
|
|
699
|
+
/** One page of the proposals the viewer may read, newest first, optionally narrowed. */
|
|
700
|
+
readonly list: (request: {
|
|
701
|
+
readonly targetId: Option.Option<string>;
|
|
702
|
+
readonly status: Option.Option<ProposalStatus>;
|
|
703
|
+
readonly limit: number;
|
|
704
|
+
readonly offset: number;
|
|
705
|
+
}) => Effect.Effect<Page<ProposalView>, E, R>;
|
|
706
|
+
/**
|
|
707
|
+
* A pending proposal with the full verdict, behind the target's write
|
|
708
|
+
* gate and its gated `compare`. Fails `ProposalNotFound`, the gate's
|
|
709
|
+
* failures, `ProposalClosed`, `VersionNotFound`, or
|
|
710
|
+
* `InvalidProposalPatch` when the stored patch no longer validates.
|
|
711
|
+
*/
|
|
712
|
+
readonly review: (
|
|
713
|
+
id: string,
|
|
714
|
+
) => Effect.Effect<
|
|
715
|
+
ProposalReview,
|
|
716
|
+
ProposalNotFound | WriteGate | ProposalClosed | VersionNotFound | InvalidProposalPatch | E,
|
|
717
|
+
R
|
|
718
|
+
>;
|
|
719
|
+
/**
|
|
720
|
+
* Accept a pending proposal: re-validate its patch, classify it against
|
|
721
|
+
* the head (failing {@link ProposalConflict} when conflicting), append it
|
|
722
|
+
* through the target's `update` with the head as its expected version and
|
|
723
|
+
* the author as `proposedBy`, and close it with the version it produced.
|
|
724
|
+
* Fails as `review` does, and as the target's `update` does
|
|
725
|
+
* (`StaleVersion` when the head moved after the classification;
|
|
726
|
+
* `WriteRejected` when one of its before hooks refuses the write).
|
|
727
|
+
*/
|
|
728
|
+
readonly accept: (
|
|
729
|
+
id: string,
|
|
730
|
+
options?: { readonly note: Option.Option<string> },
|
|
731
|
+
) => Effect.Effect<
|
|
732
|
+
ChainWrite,
|
|
733
|
+
| ProposalNotFound
|
|
734
|
+
| WriteGate
|
|
735
|
+
| ProposalClosed
|
|
736
|
+
| ProposalConflict
|
|
737
|
+
| VersionNotFound
|
|
738
|
+
| InvalidProposalPatch
|
|
739
|
+
| StaleVersion
|
|
740
|
+
| VersionConflict
|
|
741
|
+
| WriteRejected
|
|
742
|
+
| E,
|
|
743
|
+
R
|
|
744
|
+
>;
|
|
745
|
+
/**
|
|
746
|
+
* Reject a pending proposal, writing nothing to the target, behind its
|
|
747
|
+
* write gate (a proposal whose target was deleted can still be rejected).
|
|
748
|
+
* Fails `ProposalNotFound`, the gate's failures, `ProposalClosed`.
|
|
749
|
+
*/
|
|
750
|
+
readonly reject: (
|
|
751
|
+
id: string,
|
|
752
|
+
options?: { readonly note: Option.Option<string> },
|
|
753
|
+
) => Effect.Effect<void, ProposalNotFound | WriteGate | ProposalClosed | E, R>;
|
|
754
|
+
/**
|
|
755
|
+
* Withdraw the caller's own pending proposal. Fails `Unauthorized` when
|
|
756
|
+
* anonymous, `ProposalNotFound`, `Forbidden` for someone else's, and
|
|
757
|
+
* `ProposalClosed`.
|
|
758
|
+
*/
|
|
759
|
+
readonly withdraw: (
|
|
760
|
+
id: string,
|
|
761
|
+
) => Effect.Effect<void, Unauthorized | ProposalNotFound | Forbidden | ProposalClosed | E, R>;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** A decision's options: the reviewer's note. */
|
|
765
|
+
interface DecisionOptions {
|
|
766
|
+
readonly note: Option.Option<string>;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const noNote: DecisionOptions = { note: Option.none() };
|
|
770
|
+
|
|
771
|
+
/** The cheap verdict, while pending and the target has a head. */
|
|
772
|
+
const viewOf = ({ row, targetVersion }: ProposalListing): ProposalView => ({
|
|
773
|
+
proposal: row,
|
|
774
|
+
baseState:
|
|
775
|
+
row.status === "pending"
|
|
776
|
+
? Option.map(targetVersion, (head) => classifyProposalBase(row.baseVersion, head))
|
|
777
|
+
: Option.none(),
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Bind one leaf's proposal operations. Every operation is traced as
|
|
782
|
+
* `${type}.proposals.<operation>`, and the writes run in the caller's
|
|
783
|
+
* transaction: an accept's append and its close commit or roll back
|
|
784
|
+
* together. An accept runs the target's `update`, so its before hooks, but
|
|
785
|
+
* not `subtypeHandlers`' write-through: an application that caches the
|
|
786
|
+
* target writes the accepted version through itself.
|
|
787
|
+
*
|
|
788
|
+
* @since 0.1.0
|
|
789
|
+
*/
|
|
790
|
+
export const defineProposals = <Resource, E, R>(
|
|
791
|
+
definition: ProposalsDefinition<Resource, E, R>,
|
|
792
|
+
): ProposalOps<E, R> => {
|
|
793
|
+
const { type, target, proposals, policy } = definition;
|
|
794
|
+
const forbidden = definition.forbidden ?? PROPOSAL_FORBIDDEN_KEYS;
|
|
795
|
+
const validate = (patch: Schema.Json, locales: Option.Option<Schema.Json>) =>
|
|
796
|
+
validateProposalPatch(type, patch, {
|
|
797
|
+
attributes: definition.attributes,
|
|
798
|
+
forbidden,
|
|
799
|
+
decode: definition.decode,
|
|
800
|
+
locales: Option.getOrUndefined(locales),
|
|
801
|
+
localeAttributes: definition.localeAttributes,
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
const signedIn = signedInAs(definition.viewer);
|
|
805
|
+
|
|
806
|
+
/** The target's write gate: before any proposal state is disclosed to the caller. */
|
|
807
|
+
const writerOf = (targetId: string) =>
|
|
808
|
+
Effect.flatMap(definition.viewer, (viewer) =>
|
|
809
|
+
requireWriter(
|
|
810
|
+
target.access,
|
|
811
|
+
viewer,
|
|
812
|
+
policy,
|
|
813
|
+
{ type, id: targetId },
|
|
814
|
+
definition.ownerOf(targetId),
|
|
815
|
+
),
|
|
816
|
+
);
|
|
817
|
+
|
|
818
|
+
/** The locked row, the target's write gate passed, still pending. */
|
|
819
|
+
const pendingForDecision = (id: string) =>
|
|
820
|
+
Effect.gen(function* () {
|
|
821
|
+
const row = yield* proposals.lock(id);
|
|
822
|
+
if (Option.isNone(row) || row.value.targetType !== type) {
|
|
823
|
+
return yield* new ProposalNotFound({ id });
|
|
824
|
+
}
|
|
825
|
+
const account = yield* writerOf(row.value.targetId);
|
|
826
|
+
if (row.value.status !== "pending") {
|
|
827
|
+
return yield* new ProposalClosed({ id, status: row.value.status });
|
|
828
|
+
}
|
|
829
|
+
return { row: row.value, account };
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
/** The target's head entry, or `NodeNotFound`. */
|
|
833
|
+
const headOf = (targetId: string) =>
|
|
834
|
+
Effect.flatMap(definition.chain.head(targetId), (head) =>
|
|
835
|
+
Effect.fromOption(head, () => new NodeNotFound({ type, id: targetId })),
|
|
836
|
+
);
|
|
837
|
+
|
|
838
|
+
/** Re-validate the stored patch and classify it, through the target's gated `compare`. */
|
|
839
|
+
const classify = (row: ProposalRow, head: bigint) =>
|
|
840
|
+
Effect.gen(function* () {
|
|
841
|
+
const validated = yield* validate(row.patch, row.locales);
|
|
842
|
+
const comparison = yield* target.compare(row.targetId, row.baseVersion, head);
|
|
843
|
+
return {
|
|
844
|
+
validated,
|
|
845
|
+
conflict: classifyProposalConflict(
|
|
846
|
+
comparison.from,
|
|
847
|
+
comparison.to,
|
|
848
|
+
validated.touchedKeys,
|
|
849
|
+
validated.touchedLocales,
|
|
850
|
+
),
|
|
851
|
+
};
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
const close = (
|
|
855
|
+
id: string,
|
|
856
|
+
status: ProposalDecision["status"],
|
|
857
|
+
decidedBy: string,
|
|
858
|
+
note: Option.Option<string>,
|
|
859
|
+
appliedVersion: Option.Option<bigint> = Option.none(),
|
|
860
|
+
) =>
|
|
861
|
+
Effect.flatMap(nowDate, (decidedAt) =>
|
|
862
|
+
proposals.close(id, { status, decidedAt, decidedBy, decisionNote: note, appliedVersion }),
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
const submit = Effect.fn(`${type}.proposals.submit`)(function* (input: {
|
|
866
|
+
readonly targetId: string;
|
|
867
|
+
readonly patch: Schema.Json;
|
|
868
|
+
// oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "no translation"
|
|
869
|
+
readonly locales?: Schema.Json | undefined;
|
|
870
|
+
readonly rationale: Option.Option<string>;
|
|
871
|
+
}) {
|
|
872
|
+
const account = yield* signedIn;
|
|
873
|
+
if (!definition.canPropose(account)) return yield* new Forbidden();
|
|
874
|
+
// A target the proposer can't read is missing to them (SEC-AUTH-ENUM-001).
|
|
875
|
+
yield* target.get(input.targetId);
|
|
876
|
+
const locales = Option.fromUndefinedOr(input.locales);
|
|
877
|
+
yield* validate(input.patch, locales);
|
|
878
|
+
if (definition.budget !== undefined) {
|
|
879
|
+
yield* definition.budget.spend(Option.some(account), "submit", input.targetId);
|
|
880
|
+
}
|
|
881
|
+
const head = yield* headOf(input.targetId);
|
|
882
|
+
const id = yield* proposals.mintId;
|
|
883
|
+
yield* proposals.insert({
|
|
884
|
+
id,
|
|
885
|
+
targetId: input.targetId,
|
|
886
|
+
targetType: type,
|
|
887
|
+
authorId: account.id,
|
|
888
|
+
patch: input.patch,
|
|
889
|
+
locales,
|
|
890
|
+
rationale: input.rationale,
|
|
891
|
+
baseVersion: head.version,
|
|
892
|
+
status: "pending",
|
|
893
|
+
submittedAt: yield* nowDate,
|
|
894
|
+
decidedAt: Option.none(),
|
|
895
|
+
decidedBy: Option.none(),
|
|
896
|
+
decisionNote: Option.none(),
|
|
897
|
+
appliedVersion: Option.none(),
|
|
898
|
+
});
|
|
899
|
+
return { id };
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
/** Whether the viewer may read `row`: the moderate rung, its author, or the target's owner. */
|
|
903
|
+
const readable = (viewer: Viewer, row: ProposalRow) =>
|
|
904
|
+
Option.match(viewer, {
|
|
905
|
+
onNone: () => Effect.succeed(false),
|
|
906
|
+
onSome: (account) =>
|
|
907
|
+
policy.canModerate(account) || account.id === row.authorId
|
|
908
|
+
? Effect.succeed(true)
|
|
909
|
+
: Effect.map(definition.ownerOf(row.targetId), (owner) =>
|
|
910
|
+
Option.contains(owner, account.id),
|
|
911
|
+
),
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
const get = Effect.fn(`${type}.proposals.get`)(function* (id: string) {
|
|
915
|
+
const viewer = yield* definition.viewer;
|
|
916
|
+
const listing = yield* proposals.get(id);
|
|
917
|
+
if (
|
|
918
|
+
Option.isNone(listing) ||
|
|
919
|
+
listing.value.row.targetType !== type ||
|
|
920
|
+
!(yield* readable(viewer, listing.value.row))
|
|
921
|
+
) {
|
|
922
|
+
return yield* new ProposalNotFound({ id });
|
|
923
|
+
}
|
|
924
|
+
return viewOf(listing.value);
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
/** The read scope as a list filter: the same three facts as `readable`. */
|
|
928
|
+
const readFilter = (viewer: Viewer): ListFilter<never> =>
|
|
929
|
+
Option.match(viewer, {
|
|
930
|
+
onNone: (): ListFilter<never> => never,
|
|
931
|
+
onSome: (account) =>
|
|
932
|
+
policy.canModerate(account)
|
|
933
|
+
? always
|
|
934
|
+
: or<never>(eq("authorId", account.id), eq("targetOwnerId", account.id)),
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
const list = Effect.fn(`${type}.proposals.list`)(function* (request: {
|
|
938
|
+
readonly targetId: Option.Option<string>;
|
|
939
|
+
readonly status: Option.Option<ProposalStatus>;
|
|
940
|
+
readonly limit: number;
|
|
941
|
+
readonly offset: number;
|
|
942
|
+
}) {
|
|
943
|
+
const viewer = yield* definition.viewer;
|
|
944
|
+
const page = yield* proposals.list(plainRowQuery(type, readFilter(viewer), request));
|
|
945
|
+
return { rows: Arr.map(page.rows, viewOf), total: page.total };
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
const review = Effect.fn(`${type}.proposals.review`)(function* (id: string) {
|
|
949
|
+
const listing = yield* proposals.get(id);
|
|
950
|
+
if (Option.isNone(listing) || listing.value.row.targetType !== type) {
|
|
951
|
+
return yield* new ProposalNotFound({ id });
|
|
952
|
+
}
|
|
953
|
+
const { row } = listing.value;
|
|
954
|
+
yield* writerOf(row.targetId);
|
|
955
|
+
if (row.status !== "pending") return yield* new ProposalClosed({ id, status: row.status });
|
|
956
|
+
const head = yield* headOf(row.targetId);
|
|
957
|
+
const { conflict } = yield* classify(row, head.version);
|
|
958
|
+
return {
|
|
959
|
+
proposal: row,
|
|
960
|
+
baseState: Option.some(classifyProposalBase(row.baseVersion, head.version)),
|
|
961
|
+
conflict,
|
|
962
|
+
};
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
const accept = Effect.fn(`${type}.proposals.accept`)(function* (
|
|
966
|
+
id: string,
|
|
967
|
+
options: DecisionOptions = noNote,
|
|
968
|
+
) {
|
|
969
|
+
const { row, account } = yield* pendingForDecision(id);
|
|
970
|
+
const head = yield* headOf(row.targetId);
|
|
971
|
+
const { validated, conflict } = yield* classify(row, head.version);
|
|
972
|
+
if (conflict.state === "conflicting") {
|
|
973
|
+
return yield* new ProposalConflict({
|
|
974
|
+
id,
|
|
975
|
+
attributes: conflict.conflictingAttributes,
|
|
976
|
+
locales: conflict.conflictingLocales,
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
// Through the target's own update (PUB-003), expecting the head it was
|
|
980
|
+
// classified against: an edit landing in between fails StaleVersion.
|
|
981
|
+
const written = yield* target.update({
|
|
982
|
+
id: row.targetId,
|
|
983
|
+
patch: validated.patch,
|
|
984
|
+
locales: validated.locales,
|
|
985
|
+
expectedVersion: head.version,
|
|
986
|
+
proposedBy: Option.some(row.authorId),
|
|
987
|
+
});
|
|
988
|
+
yield* close(id, "accepted", account.id, options.note, Option.some(written.version));
|
|
989
|
+
return written;
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
const reject = Effect.fn(`${type}.proposals.reject`)(function* (
|
|
993
|
+
id: string,
|
|
994
|
+
options: DecisionOptions = noNote,
|
|
995
|
+
) {
|
|
996
|
+
const { account } = yield* pendingForDecision(id);
|
|
997
|
+
yield* close(id, "rejected", account.id, options.note);
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
const withdraw = Effect.fn(`${type}.proposals.withdraw`)(function* (id: string) {
|
|
1001
|
+
const account = yield* signedIn;
|
|
1002
|
+
const row = yield* proposals.lock(id);
|
|
1003
|
+
if (Option.isNone(row) || row.value.targetType !== type) {
|
|
1004
|
+
return yield* new ProposalNotFound({ id });
|
|
1005
|
+
}
|
|
1006
|
+
if (row.value.authorId !== account.id) return yield* new Forbidden();
|
|
1007
|
+
if (row.value.status !== "pending") {
|
|
1008
|
+
return yield* new ProposalClosed({ id, status: row.value.status });
|
|
1009
|
+
}
|
|
1010
|
+
yield* close(id, "withdrawn", account.id, Option.none());
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
return { submit, get, list, review, accept, reject, withdraw };
|
|
1014
|
+
};
|