@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.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1272 -2
  3. package/dist/blobs.d.ts +249 -0
  4. package/dist/blobs.d.ts.map +1 -0
  5. package/dist/blobs.js +193 -0
  6. package/dist/blobs.js.map +1 -0
  7. package/dist/budgets.d.ts +255 -0
  8. package/dist/budgets.d.ts.map +1 -0
  9. package/dist/budgets.js +211 -0
  10. package/dist/budgets.js.map +1 -0
  11. package/dist/cache.d.ts +154 -0
  12. package/dist/cache.d.ts.map +1 -0
  13. package/dist/cache.js +118 -0
  14. package/dist/cache.js.map +1 -0
  15. package/dist/collections.d.ts +242 -0
  16. package/dist/collections.d.ts.map +1 -0
  17. package/dist/collections.js +215 -0
  18. package/dist/collections.js.map +1 -0
  19. package/dist/comments.d.ts +212 -0
  20. package/dist/comments.d.ts.map +1 -0
  21. package/dist/comments.js +192 -0
  22. package/dist/comments.js.map +1 -0
  23. package/dist/content.d.ts +231 -0
  24. package/dist/content.d.ts.map +1 -0
  25. package/dist/content.js +204 -0
  26. package/dist/content.js.map +1 -0
  27. package/dist/documents.d.ts +184 -0
  28. package/dist/documents.d.ts.map +1 -0
  29. package/dist/documents.js +190 -0
  30. package/dist/documents.js.map +1 -0
  31. package/dist/edges.d.ts +645 -0
  32. package/dist/edges.d.ts.map +1 -0
  33. package/dist/edges.js +558 -0
  34. package/dist/edges.js.map +1 -0
  35. package/dist/filter.d.ts +461 -0
  36. package/dist/filter.d.ts.map +1 -0
  37. package/dist/filter.js +387 -0
  38. package/dist/filter.js.map +1 -0
  39. package/dist/index.d.ts +133 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +133 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/internal/filter-columns.d.ts +8 -0
  44. package/dist/internal/filter-columns.d.ts.map +1 -0
  45. package/dist/internal/filter-columns.js +43 -0
  46. package/dist/internal/filter-columns.js.map +1 -0
  47. package/dist/internal/list-order.d.ts +26 -0
  48. package/dist/internal/list-order.d.ts.map +1 -0
  49. package/dist/internal/list-order.js +92 -0
  50. package/dist/internal/list-order.js.map +1 -0
  51. package/dist/internal/owned-columns.d.ts +93 -0
  52. package/dist/internal/owned-columns.d.ts.map +1 -0
  53. package/dist/internal/owned-columns.js +98 -0
  54. package/dist/internal/owned-columns.js.map +1 -0
  55. package/dist/internal/plain-rows.d.ts +32 -0
  56. package/dist/internal/plain-rows.d.ts.map +1 -0
  57. package/dist/internal/plain-rows.js +32 -0
  58. package/dist/internal/plain-rows.js.map +1 -0
  59. package/dist/internal/reference-columns.d.ts +80 -0
  60. package/dist/internal/reference-columns.d.ts.map +1 -0
  61. package/dist/internal/reference-columns.js +102 -0
  62. package/dist/internal/reference-columns.js.map +1 -0
  63. package/dist/moderation.d.ts +392 -0
  64. package/dist/moderation.d.ts.map +1 -0
  65. package/dist/moderation.js +375 -0
  66. package/dist/moderation.js.map +1 -0
  67. package/dist/paths.d.ts +204 -0
  68. package/dist/paths.d.ts.map +1 -0
  69. package/dist/paths.js +165 -0
  70. package/dist/paths.js.map +1 -0
  71. package/dist/proposals.d.ts +523 -0
  72. package/dist/proposals.d.ts.map +1 -0
  73. package/dist/proposals.js +505 -0
  74. package/dist/proposals.js.map +1 -0
  75. package/dist/publication.d.ts +148 -0
  76. package/dist/publication.d.ts.map +1 -0
  77. package/dist/publication.js +183 -0
  78. package/dist/publication.js.map +1 -0
  79. package/dist/purge.d.ts +224 -0
  80. package/dist/purge.d.ts.map +1 -0
  81. package/dist/purge.js +151 -0
  82. package/dist/purge.js.map +1 -0
  83. package/dist/reactions.d.ts +246 -0
  84. package/dist/reactions.d.ts.map +1 -0
  85. package/dist/reactions.js +183 -0
  86. package/dist/reactions.js.map +1 -0
  87. package/dist/registry.d.ts +316 -0
  88. package/dist/registry.d.ts.map +1 -0
  89. package/dist/registry.js +305 -0
  90. package/dist/registry.js.map +1 -0
  91. package/dist/search.d.ts +366 -0
  92. package/dist/search.d.ts.map +1 -0
  93. package/dist/search.js +273 -0
  94. package/dist/search.js.map +1 -0
  95. package/dist/social.d.ts +311 -0
  96. package/dist/social.d.ts.map +1 -0
  97. package/dist/social.js +205 -0
  98. package/dist/social.js.map +1 -0
  99. package/dist/subtype.d.ts +1613 -0
  100. package/dist/subtype.d.ts.map +1 -0
  101. package/dist/subtype.js +1300 -0
  102. package/dist/subtype.js.map +1 -0
  103. package/dist/testing.d.ts +675 -0
  104. package/dist/testing.d.ts.map +1 -0
  105. package/dist/testing.js +1022 -0
  106. package/dist/testing.js.map +1 -0
  107. package/dist/version-chain.d.ts +702 -0
  108. package/dist/version-chain.d.ts.map +1 -0
  109. package/dist/version-chain.js +489 -0
  110. package/dist/version-chain.js.map +1 -0
  111. package/dist/working-copy.d.ts +116 -0
  112. package/dist/working-copy.d.ts.map +1 -0
  113. package/dist/working-copy.js +95 -0
  114. package/dist/working-copy.js.map +1 -0
  115. package/package.json +145 -3
  116. package/src/blobs.ts +384 -0
  117. package/src/budgets.ts +338 -0
  118. package/src/cache.ts +218 -0
  119. package/src/collections.ts +427 -0
  120. package/src/comments.ts +392 -0
  121. package/src/content.ts +238 -0
  122. package/src/documents.ts +284 -0
  123. package/src/edges.ts +1185 -0
  124. package/src/filter.ts +523 -0
  125. package/src/index.ts +153 -0
  126. package/src/internal/filter-columns.ts +50 -0
  127. package/src/internal/list-order.ts +117 -0
  128. package/src/internal/owned-columns.ts +140 -0
  129. package/src/internal/plain-rows.ts +54 -0
  130. package/src/internal/reference-columns.ts +156 -0
  131. package/src/moderation.ts +728 -0
  132. package/src/paths.ts +267 -0
  133. package/src/proposals.ts +1014 -0
  134. package/src/publication.ts +225 -0
  135. package/src/purge.ts +313 -0
  136. package/src/reactions.ts +347 -0
  137. package/src/registry.ts +524 -0
  138. package/src/search.ts +540 -0
  139. package/src/social.ts +469 -0
  140. package/src/subtype.ts +3095 -0
  141. package/src/testing.ts +1873 -0
  142. package/src/version-chain.ts +1093 -0
  143. package/src/working-copy.ts +192 -0
@@ -0,0 +1,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
+ };