@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.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1271 -1
  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 +138 -4
  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,375 @@
1
+ /**
2
+ * **The moderation workflow** — the rules that move a leaf's row between
3
+ * moderation states, the threshold grading of a classifier's scores, the
4
+ * review-queue filter, and member reports.
5
+ *
6
+ * The behavioural spec is gardeneria's comment moderation
7
+ * (`apps/api/src/operations/comments.ts`' `moderateCommentE`), its moderation
8
+ * plane (`apps/api/src/operations/moderation.ts`) and its threshold grader
9
+ * (`packages/jobs/src/moderation/thresholds.ts`), with their tests; a
10
+ * `#NNN` here names a gardeneria issue.
11
+ *
12
+ * ## The model (owner decisions on pulpit#51)
13
+ *
14
+ * The kernel owns the rules; the transport (a queue, the classifier run, the
15
+ * API-call ledger, moderator notifications) stays the application's.
16
+ *
17
+ * - **The state lives on the version row**, in the leaf's
18
+ * `moderationState` column ({@link ModerationState}): a decision is an
19
+ * appended version, so history shows who decided what, and when.
20
+ * - **Transitions.** Every new row starts `pending`. Only the moderate rung
21
+ * changes the state: `pending → approved | rejected`, re-moderation
22
+ * between `approved` and `rejected`, and re-review back to `pending`
23
+ * ({@link isModerationTransition}). A write by anyone else that changes the
24
+ * state fails `WriteRejected`.
25
+ * - **Re-review triggers are the leaf's**: an edit that changes one of its
26
+ * declared `reReviewOn` attributes sends an `approved` row back to
27
+ * `pending` ({@link ModerationDeclaration}).
28
+ * - **Owners withdraw their own content** by deleting it: the owned write
29
+ * gate admits its owner in every moderation state, and a tombstone needs
30
+ * no decision.
31
+ *
32
+ * {@link moderationHook} enforces the rules as a before hook (`./subtype`'s
33
+ * lifecycle seam), so every write path honours them, and
34
+ * {@link defineModeration}'s `moderate` is the decision itself.
35
+ *
36
+ * ## Grading and the queue
37
+ *
38
+ * {@link gradeScores} turns a classifier's per-category scores (shaped like
39
+ * effect-ai-ext's `ModerationModel` output; pulpit doesn't import it) into a
40
+ * verdict against the application's thresholds. Classification itself runs
41
+ * from an application's after hook. The review queue is the leaf's pending
42
+ * rows ({@link reviewQueueFilter}) plus the open member reports
43
+ * ({@link defineReports}), both the moderate rung's alone.
44
+ *
45
+ * @copyright Thomas Foster
46
+ * @module pulpit/moderation
47
+ * @since 0.1.0
48
+ */
49
+ import * as Arr from "effect/Array";
50
+ import * as Effect from "effect/Effect";
51
+ import * as Equal from "effect/Equal";
52
+ import * as Formatter from "effect/Formatter";
53
+ import * as Option from "effect/Option";
54
+ import * as Order from "effect/Order";
55
+ import * as Predicate from "effect/Predicate";
56
+ import * as Record from "effect/Record";
57
+ import * as Result from "effect/Result";
58
+ import * as Schema from "effect/Schema";
59
+ import { always, eq, never } from "./filter.js";
60
+ import { nowDate, plainRowQuery, signedIn as signedInAs } from "./internal/plain-rows.js";
61
+ import { ModerationState } from "./publication.js";
62
+ import { Forbidden, Unauthorized, WriteRejected, } from "./subtype.js";
63
+ // ── Transitions ───────────────────────────────────────────────────────
64
+ /**
65
+ * Whether a row may move from `from` (none for a row being created) to
66
+ * `to`: a new row only to `pending`; an existing one to any other state
67
+ * (decision, re-moderation, re-review). Staying put is no transition.
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * import { strictEqual } from "node:assert";
72
+ * import * as Option from "effect/Option";
73
+ * import { isModerationTransition } from "@thomasfosterau/pulpit/moderation";
74
+ *
75
+ * strictEqual(isModerationTransition(Option.none(), "pending"), true);
76
+ * strictEqual(isModerationTransition(Option.none(), "approved"), false);
77
+ * strictEqual(isModerationTransition(Option.some("pending"), "approved"), true);
78
+ * strictEqual(isModerationTransition(Option.some("approved"), "approved"), false);
79
+ * ```
80
+ *
81
+ * @since 0.1.0
82
+ */
83
+ export const isModerationTransition = (from, to) => Option.match(from, { onNone: () => to === "pending", onSome: (state) => state !== to });
84
+ /**
85
+ * The declaration with the conventional column names.
86
+ *
87
+ * @since 0.1.0
88
+ */
89
+ export const moderatedColumns = (reReviewOn) => ({
90
+ column: "moderationState",
91
+ moderatedAtColumn: Option.some("moderatedAt"),
92
+ moderatedByColumn: Option.some("moderatedBy"),
93
+ reReviewOn,
94
+ });
95
+ const isModerationState = Schema.is(ModerationState);
96
+ /** A column's value in a state or patch, absent (or `undefined`) as none. */
97
+ const valueOf = (values, column) => Option.fromUndefinedOr(values[column]);
98
+ /** The prior state's moderation state, or none (a create, or a value outside the vocabulary). */
99
+ const priorState = (event, column) => Option.flatMap(event.prior, (prior) => Option.filter(valueOf(prior.state, column), isModerationState));
100
+ /** The decision stamps for a patch, by the leaf's declared columns. */
101
+ const stamps = (declaration, account, now) => ({
102
+ ...Option.match(declaration.moderatedAtColumn, {
103
+ onNone: () => ({}),
104
+ onSome: (column) => Record.singleton(column, now),
105
+ }),
106
+ ...Option.match(declaration.moderatedByColumn, {
107
+ onNone: () => ({}),
108
+ onSome: (column) => Record.singleton(column, account.id),
109
+ }),
110
+ });
111
+ /** The prior row's moderation columns, carried over a restored version's. */
112
+ const carried = (event, declaration) => Option.match(event.prior, {
113
+ onNone: () => ({}),
114
+ onSome: (prior) => Record.fromEntries(Arr.map([
115
+ declaration.column,
116
+ ...Option.toArray(declaration.moderatedAtColumn),
117
+ ...Option.toArray(declaration.moderatedByColumn),
118
+ ], (column) => [column, prior.state[column] ?? null])),
119
+ });
120
+ /** Whether the patch changes a re-review attribute from its prior value. */
121
+ const touchesReview = (event, declaration) => Arr.some(declaration.reReviewOn, (column) => Option.match(valueOf(event.patch, column), {
122
+ onNone: () => false,
123
+ onSome: (next) => !Option.exists(event.prior, (prior) => Option.exists(valueOf(prior.state, column), (before) => Equal.equals(before, next))),
124
+ }));
125
+ /**
126
+ * The moderation rules applied to one write: the patch the write should
127
+ * apply, or the reason it is refused. Pure, so every case is testable
128
+ * without a chain.
129
+ *
130
+ * - A **create** stores `pending`; a patch asking for another state is refused.
131
+ * - A **delete** is untouched (withdrawal needs no decision).
132
+ * - A **`restoreVersion`** by anyone but the moderate rung carries the
133
+ * prior row's moderation columns over the restored version's, so an old
134
+ * approval never comes back with old content.
135
+ * - A patch that **changes the state** is a decision: refused below the
136
+ * moderate rung or outside {@link isModerationTransition}, and otherwise
137
+ * stamped (`moderatedAtColumn`, `moderatedByColumn`). A patch that sets
138
+ * the state it already has changes nothing.
139
+ * - Otherwise, an edit that changes a `reReviewOn` attribute of an
140
+ * `approved` row sends it back to `pending`.
141
+ *
142
+ * @since 0.1.0
143
+ */
144
+ export const moderatePatch = (event, declaration, policy, now) => {
145
+ const moderator = Option.filter(event.viewer, policy.canModerate);
146
+ if (event.action === "delete")
147
+ return Result.succeed(event.patch);
148
+ if (event.action === "create")
149
+ return created(event.patch, declaration.column);
150
+ const patch = event.action === "restoreVersion" && Option.isNone(moderator)
151
+ ? { ...event.patch, ...carried(event, declaration) }
152
+ : event.patch;
153
+ const from = priorState(event, declaration.column);
154
+ const asked = Option.filter(valueOf(patch, declaration.column), (value) => !Option.exists(from, (state) => state === value));
155
+ return Option.isSome(asked)
156
+ ? decided({ event, patch, from, asked: asked.value, moderator, declaration, now })
157
+ : Result.succeed(reReviewed({ ...event, patch }, from, declaration));
158
+ };
159
+ /** A create's patch: `pending`, whatever it asked for, unless it asked for another state. */
160
+ const created = (patch, column) => Option.exists(valueOf(patch, column), (asked) => asked !== "pending")
161
+ ? Result.fail(`a new row starts pending; ${column} is the moderate rung's to change`)
162
+ : Result.succeed({ ...patch, [column]: "pending" });
163
+ /** A patch that changes the state: the rung's decision, stamped (a restored version unstamped). */
164
+ const decided = (decision) => {
165
+ const { patch, asked, moderator, declaration } = decision;
166
+ const { column } = declaration;
167
+ if (Option.isNone(moderator))
168
+ return Result.fail(`${column} is the moderate rung's to change`);
169
+ if (!isModerationState(asked) || !isModerationTransition(decision.from, asked)) {
170
+ return Result.fail(`${column} can't move to ${Predicate.isString(asked) ? asked : Formatter.format(asked)}`);
171
+ }
172
+ return Result.succeed(decision.event.action === "restoreVersion"
173
+ ? patch
174
+ : { ...patch, ...stamps(declaration, moderator.value, decision.now) });
175
+ };
176
+ /** An edit that leaves the state alone: back to `pending` when it touches a re-review attribute of an approved row. */
177
+ const reReviewed = (event, from, declaration) => Option.exists(from, (state) => state === "approved") && touchesReview(event, declaration)
178
+ ? { ...event.patch, [declaration.column]: "pending" }
179
+ : event.patch;
180
+ /**
181
+ * The moderation rules ({@link moderatePatch}) as a before hook for the
182
+ * leaf's `LifecycleHooks`: every write applies them, and a refusal
183
+ * fails the write with `WriteRejected`, appending nothing. Compose it first,
184
+ * so later hooks see the state it settled.
185
+ *
186
+ * @since 0.1.0
187
+ */
188
+ export const moderationHook = (declaration, policy) => ({
189
+ name: "moderation",
190
+ run: (event) => Effect.flatMap(nowDate, (now) => {
191
+ return Result.match(moderatePatch(event, declaration, policy, now), {
192
+ onSuccess: Effect.succeed,
193
+ onFailure: (reason) => Effect.fail(new WriteRejected({ type: event.type, id: event.id, action: event.action, reason })),
194
+ });
195
+ }),
196
+ });
197
+ /**
198
+ * Bind a leaf's moderation decision. The leaf's hooks must include
199
+ * {@link moderationHook} with the same declaration: it is what admits the
200
+ * decision and stamps it.
201
+ *
202
+ * @since 0.1.0
203
+ */
204
+ export const defineModeration = (definition) => ({
205
+ moderate: Effect.fn(`${definition.target.type}.moderate`)(function* (id, decision, precondition = {}) {
206
+ const viewer = yield* definition.viewer;
207
+ if (Option.isNone(viewer))
208
+ return yield* new Unauthorized();
209
+ if (!definition.policy.canModerate(viewer.value))
210
+ return yield* new Forbidden();
211
+ return yield* definition.target.update({
212
+ id,
213
+ patch: { [definition.declaration.column]: decision },
214
+ expectedVersion: precondition.expectedVersion,
215
+ });
216
+ }),
217
+ });
218
+ /**
219
+ * The review queue's leaf half as a list filter: the `pending` rows, for
220
+ * the moderate rung only (`never` for anyone else). Pass it as a list
221
+ * request's `where`; the leaf's own read arms still apply.
222
+ *
223
+ * @since 0.1.0
224
+ */
225
+ export const reviewQueueFilter = (viewer, policy, column = "moderationState") => (Option.exists(viewer, policy.canModerate) ? eq(column, "pending") : never);
226
+ /**
227
+ * The band a flagged row lands in, from its worst breach.
228
+ *
229
+ * @since 0.1.0
230
+ */
231
+ export const ModerationPriority = Schema.Literals(["low", "medium", "high", "urgent"]);
232
+ /**
233
+ * The bar a category with no configured threshold is graded against;
234
+ * strictly above it flags. A starting point to tune, not a measured value.
235
+ *
236
+ * @since 0.1.0
237
+ */
238
+ export const DEFAULT_MODERATION_THRESHOLD = 0.5;
239
+ /** The band for a breach: round cut points, a starting policy. */
240
+ const bandFor = (score) => score >= 0.9 ? "urgent" : score >= 0.75 ? "high" : score >= 0.5 ? "medium" : "low";
241
+ /**
242
+ * Grade a classifier's scores against the leaf's thresholds (a category
243
+ * missing from `thresholds` grades against `defaultThreshold`). A category
244
+ * breaches when its score is strictly above its bar; an unassessed one is
245
+ * absent and can't breach. The classifier's own flag is never overridden
246
+ * downward, only added to (raise a category's bar to `1` to ignore it). The
247
+ * band is the worst breaching score's, or the top score's for a flag with
248
+ * no breach. Pure.
249
+ *
250
+ * @example
251
+ * ```ts
252
+ * import { deepStrictEqual } from "node:assert";
253
+ * import { gradeScores } from "@thomasfosterau/pulpit/moderation";
254
+ *
255
+ * deepStrictEqual(
256
+ * gradeScores({ flagged: false, categoryScores: { hate: 0.8, spam: 0.3 } }, { spam: 0.2 }),
257
+ * { isFlagged: true, exceeded: ["hate", "spam"], topScore: 0.8, priority: "high" },
258
+ * );
259
+ * ```
260
+ *
261
+ * @since 0.1.0
262
+ */
263
+ export const gradeScores = (scores, thresholds, defaultThreshold = DEFAULT_MODERATION_THRESHOLD) => {
264
+ const entries = Record.toEntries(scores.categoryScores);
265
+ const breaches = Arr.filter(entries, ([category, score]) => score > (thresholds[category] ?? defaultThreshold));
266
+ const top = (values) => Arr.reduce(values, 0, (worst, [, score]) => Math.max(worst, score));
267
+ const topScore = top(entries);
268
+ const topExceeded = top(breaches);
269
+ return {
270
+ isFlagged: scores.flagged || Arr.isReadonlyArrayNonEmpty(breaches),
271
+ exceeded: Arr.sort(Arr.map(breaches, ([category]) => category), Order.String),
272
+ topScore,
273
+ priority: bandFor(topExceeded > 0 ? topExceeded : scores.flagged ? topScore : 0),
274
+ };
275
+ };
276
+ // ── Member reports ────────────────────────────────────────────────────
277
+ /**
278
+ * A report's status: `open` until the moderate rung resolves it (acted on)
279
+ * or dismisses it.
280
+ *
281
+ * @since 0.1.0
282
+ */
283
+ export const ReportStatus = Schema.Literals(["open", "resolved", "dismissed"]);
284
+ /**
285
+ * No report at that id the viewer may read: the wire's 404.
286
+ *
287
+ * @since 0.1.0
288
+ */
289
+ export class ReportNotFound extends Schema.TaggedError()("ReportNotFound", {
290
+ id: Schema.String,
291
+ }) {
292
+ }
293
+ /**
294
+ * The report is already resolved or dismissed: the wire's 409.
295
+ *
296
+ * @since 0.1.0
297
+ */
298
+ export class ReportClosed extends Schema.TaggedError()("ReportClosed", {
299
+ id: Schema.String,
300
+ status: ReportStatus,
301
+ }) {
302
+ }
303
+ /**
304
+ * Bind member reports against one leaf: filed by a signed-in account that
305
+ * can read the target, read by their reporter and the moderate rung, and
306
+ * resolved by the moderate rung.
307
+ *
308
+ * @since 0.1.0
309
+ */
310
+ export const defineReports = (definition) => {
311
+ const { target, reports, policy } = definition;
312
+ const type = target.type;
313
+ const signedIn = signedInAs(definition.viewer);
314
+ const file = Effect.fn(`${type}.reports.file`)(function* (input) {
315
+ const account = yield* signedIn;
316
+ // A target the reporter can't read is missing to them (SEC-AUTH-ENUM-001).
317
+ yield* target.get(input.targetId);
318
+ if (definition.budget !== undefined) {
319
+ yield* definition.budget.spend(Option.some(account), "file", input.targetId);
320
+ }
321
+ const id = yield* reports.mintId;
322
+ yield* reports.insert({
323
+ id,
324
+ targetId: input.targetId,
325
+ targetType: type,
326
+ reporterId: account.id,
327
+ reason: input.reason,
328
+ details: input.details,
329
+ status: "open",
330
+ filedAt: yield* nowDate,
331
+ resolvedAt: Option.none(),
332
+ resolvedBy: Option.none(),
333
+ resolutionNote: Option.none(),
334
+ });
335
+ return { id };
336
+ });
337
+ const readable = (viewer, row) => row.targetType === type &&
338
+ Option.exists(viewer, (account) => policy.canModerate(account) || account.id === row.reporterId);
339
+ const get = Effect.fn(`${type}.reports.get`)(function* (id) {
340
+ const viewer = yield* definition.viewer;
341
+ const row = yield* reports.get(id);
342
+ if (Option.isNone(row) || !readable(viewer, row.value)) {
343
+ return yield* new ReportNotFound({ id });
344
+ }
345
+ return row.value;
346
+ });
347
+ const list = Effect.fn(`${type}.reports.list`)(function* (request) {
348
+ const viewer = yield* definition.viewer;
349
+ const scope = Option.match(viewer, {
350
+ onNone: () => never,
351
+ onSome: (account) => policy.canModerate(account) ? always : eq("reporterId", account.id),
352
+ });
353
+ return yield* reports.list(plainRowQuery(type, scope, request));
354
+ });
355
+ const resolve = Effect.fn(`${type}.reports.resolve`)(function* (id, outcome, note) {
356
+ const account = yield* signedIn;
357
+ if (!policy.canModerate(account))
358
+ return yield* new Forbidden();
359
+ const row = yield* reports.lock(id);
360
+ if (Option.isNone(row) || row.value.targetType !== type) {
361
+ return yield* new ReportNotFound({ id });
362
+ }
363
+ if (row.value.status !== "open") {
364
+ return yield* new ReportClosed({ id, status: row.value.status });
365
+ }
366
+ yield* reports.close(id, {
367
+ status: outcome,
368
+ resolvedAt: yield* nowDate,
369
+ resolvedBy: account.id,
370
+ resolutionNote: note,
371
+ });
372
+ });
373
+ return { file, get, list, resolve };
374
+ };
375
+ //# sourceMappingURL=moderation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"moderation.js","sourceRoot":"","sources":["../src/moderation.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,204 @@
1
+ /**
2
+ * **Routable content** — a leaf's path claims, tied to its version chain,
3
+ * the slug aliases a path change leaves behind, and the gated resolution of
4
+ * a path to a node or a redirect.
5
+ *
6
+ * The behavioural spec is gardeneria's `apps/api/src/operations/pages.ts`
7
+ * and `packages/database/src/tables/pages.ts` (one live page per canonical
8
+ * path, old slugs kept in `page_slugs` and answered with a 301). A `#NNN`
9
+ * here names a gardeneria issue.
10
+ *
11
+ * ## The model (owner decision on pulpit#57)
12
+ *
13
+ * - **A claim lives on the root node**: one path per content item. The
14
+ * locale is chosen when the resolved node is read, never by the path, so a
15
+ * slug translation is not a claim of its own (pulpit#56's model (b)).
16
+ * - **Claims follow the node's lifecycle.** A write that leaves the node
17
+ * live claims the path its new state holds in the leaf's path column; a
18
+ * tombstone, or a state with no path, frees the claim; a restore re-claims
19
+ * its path, or fails {@link PathTaken} when another node holds it now.
20
+ * - **A path change records an alias**: the old path resolves as a redirect
21
+ * to the new one, for as long as no other node claims it.
22
+ * - **Resolution is gated** (`SEC-AUTH-ENUM-001`): {@link RoutableOps.resolve}
23
+ * reads the node through the leaf's own `get`, so a hidden node's path,
24
+ * and an alias to a hidden node, is {@link PathNotFound}, never a redirect.
25
+ *
26
+ * Free-form redirects (patterns, a 404 log) are the site's. The path's
27
+ * syntax is the leaf attribute's schema (effect-schema-ext's `Ids.slug`, once
28
+ * it publishes).
29
+ *
30
+ * @copyright Thomas Foster
31
+ * @module pulpit/paths
32
+ * @since 0.1.0
33
+ */
34
+ import * as Effect from "effect/Effect";
35
+ import * as Option from "effect/Option";
36
+ import * as Schema from "effect/Schema";
37
+ import type { SubtypeOps } from "./subtype.ts";
38
+ import type { VersionChainStore } from "./version-chain.ts";
39
+ declare const PathTaken_base: Schema.Class<PathTaken, Schema.TaggedStruct<"PathTaken", {
40
+ readonly path: Schema.String;
41
+ }>, import("effect/Cause").YieldableError & {
42
+ readonly brand: unique symbol;
43
+ }>;
44
+ /**
45
+ * Another node holds the path canonically: the wire's 409. It names the
46
+ * path, never the holder, so it says only that the path is taken, as a
47
+ * unique-key conflict does, whoever holds it.
48
+ *
49
+ * @since 0.1.0
50
+ */
51
+ export declare class PathTaken extends PathTaken_base {
52
+ }
53
+ declare const PathNotFound_base: Schema.Class<PathNotFound, Schema.TaggedStruct<"PathNotFound", {
54
+ readonly path: Schema.String;
55
+ }>, import("effect/Cause").YieldableError & {
56
+ readonly brand: unique symbol;
57
+ }>;
58
+ /**
59
+ * No node the viewer can read answers to the path: the wire's 404. A path
60
+ * nobody claims, one whose node is hidden from the viewer, and an alias to a
61
+ * hidden node all answer this, alike (`SEC-AUTH-ENUM-001`).
62
+ *
63
+ * @since 0.1.0
64
+ */
65
+ export declare class PathNotFound extends PathNotFound_base {
66
+ }
67
+ /**
68
+ * What a path is stored as: the node it names, and whether it is that
69
+ * node's canonical path or an alias a path change left behind.
70
+ *
71
+ * @since 0.1.0
72
+ */
73
+ export interface PathEntry {
74
+ readonly nodeId: string;
75
+ readonly canonical: boolean;
76
+ }
77
+ /**
78
+ * The path table of one routable namespace (a collection): each path once,
79
+ * canonical for one node or an alias of one. A store that persists and
80
+ * applies no policy; it runs in the caller's transaction, so a write whose
81
+ * claim fails rolls back with its chain append.
82
+ *
83
+ * @since 0.1.0
84
+ */
85
+ export interface PathStore<E, R> {
86
+ /** The entry for `path`, canonical or alias, or none. */
87
+ readonly lookup: (path: string) => Effect.Effect<Option.Option<PathEntry>, E, R>;
88
+ /** The node's canonical path, or none. */
89
+ readonly canonicalOf: (nodeId: string) => Effect.Effect<Option.Option<string>, E, R>;
90
+ /**
91
+ * Make `path` the node's canonical path, in one step: fails
92
+ * {@link PathTaken} when another node holds it canonically; otherwise
93
+ * replaces an alias at `path` (the node's own, which a revert promotes, or
94
+ * another node's, which the claim takes over) and turns the node's previous
95
+ * canonical path, if any, into its alias.
96
+ */
97
+ readonly claim: (nodeId: string, path: string) => Effect.Effect<void, PathTaken | E, R>;
98
+ /** Free the node's canonical path, if it has one; its aliases stay. */
99
+ readonly release: (nodeId: string) => Effect.Effect<void, E, R>;
100
+ }
101
+ /**
102
+ * What a path resolves to for one viewer: the node's resource at its
103
+ * canonical path, or a redirect from an alias to the canonical path.
104
+ *
105
+ * @since 0.1.0
106
+ */
107
+ export type Resolution<Resource> = {
108
+ readonly kind: "node";
109
+ readonly id: string;
110
+ readonly resource: Resource;
111
+ } | {
112
+ readonly kind: "redirect";
113
+ readonly id: string;
114
+ readonly path: string;
115
+ };
116
+ /**
117
+ * A routable leaf's operations: the leaf's own, each write keeping its path
118
+ * claim in step with the state it wrote (and failing {@link PathTaken}), plus
119
+ * the gated {@link resolve}.
120
+ *
121
+ * @since 0.1.0
122
+ */
123
+ export interface RoutableOps<Resource, E, R> extends SubtypeOps<Resource, E | PathTaken, R> {
124
+ /**
125
+ * Resolve a path for the viewer: its node, through the leaf's own `get`, or
126
+ * a redirect from an alias to the node's canonical path. Fails
127
+ * {@link PathNotFound} for an unclaimed path, a hidden node's path, and an
128
+ * alias to a hidden node, alike, and for an alias whose node has no
129
+ * canonical path now (`SEC-AUTH-ENUM-001`); with the store's `E` otherwise.
130
+ */
131
+ readonly resolve: (path: string) => Effect.Effect<Resolution<Resource>, PathNotFound | E, R>;
132
+ }
133
+ /**
134
+ * What {@link routable} needs besides the leaf's operations.
135
+ *
136
+ * @since 0.1.0
137
+ */
138
+ export interface RoutableConfig<E, R> {
139
+ /** The leaf's path column: a string holds the node's path; any other value, or none, claims nothing. */
140
+ readonly column: string;
141
+ /** The routable namespace's path table. */
142
+ readonly paths: PathStore<E, R>;
143
+ /** The leaf's chain store, read (ungated) for the state a write landed at. */
144
+ readonly chain: VersionChainStore<E, R>;
145
+ }
146
+ /**
147
+ * Make a leaf routable (pulpit#57): its writes keep the node's path claim in
148
+ * step with its chain, and {@link RoutableOps.resolve} answers a path through
149
+ * the leaf's own read gate. After each write's append, in the same
150
+ * transaction, the claim follows the state the write landed at: a live state
151
+ * with a path claims it (a change leaves the old path as an alias; another
152
+ * node's claim fails {@link PathTaken}), and a tombstone or a state with no
153
+ * path frees it. A restore into a path another node took meanwhile therefore
154
+ * fails `PathTaken`, and its append rolls back with it. Wrap the result with
155
+ * `./subtype`'s `subtypeHandlers` as the leaf's own operations, so its
156
+ * `transact` covers the claims.
157
+ *
158
+ * @example
159
+ * ```ts
160
+ * import { strictEqual } from "node:assert";
161
+ * import * as Effect from "effect/Effect";
162
+ * import * as Option from "effect/Option";
163
+ * import { routable } from "@thomasfosterau/pulpit/paths";
164
+ * import { defineSubtype, nobodyFollows } from "@thomasfosterau/pulpit/subtype";
165
+ * import {
166
+ * makeInMemoryChainStore,
167
+ * makeInMemoryLeafStore,
168
+ * makeInMemoryPathStore,
169
+ * } from "@thomasfosterau/pulpit/testing";
170
+ * import * as HashSet from "effect/HashSet";
171
+ *
172
+ * const chain = makeInMemoryChainStore();
173
+ * const pages = routable(
174
+ * defineSubtype({
175
+ * type: "pages",
176
+ * access: { kind: "reference" },
177
+ * chain,
178
+ * leaves: makeInMemoryLeafStore(chain, { columns: ["slug"] }),
179
+ * viewer: Effect.succeed(Option.some({ id: "editor", role: "admin" })),
180
+ * policy: {
181
+ * canViewPrivate: () => true,
182
+ * canViewDeleted: () => true,
183
+ * canModerate: () => true,
184
+ * canWriteCorpus: () => true,
185
+ * },
186
+ * followers: nobodyFollows(HashSet.empty<string>()),
187
+ * toResource: (row) => ({ id: row.id, slug: row["slug"] }),
188
+ * }),
189
+ * { column: "slug", paths: makeInMemoryPathStore(), chain },
190
+ * );
191
+ * const program = Effect.gen(function* () {
192
+ * const { id } = yield* pages.create({ id: Option.none(), state: { slug: "about" } });
193
+ * yield* pages.update({ id, patch: { slug: "about-us" } });
194
+ * return yield* pages.resolve("about");
195
+ * });
196
+ * const resolved = Effect.runSync(program);
197
+ * strictEqual(resolved.kind === "redirect" && resolved.path, "about-us");
198
+ * ```
199
+ *
200
+ * @since 0.1.0
201
+ */
202
+ export declare const routable: <Resource, E, R>(ops: SubtypeOps<Resource, E, R>, config: RoutableConfig<E, R>) => RoutableOps<Resource, E, R>;
203
+ export {};
204
+ //# sourceMappingURL=paths.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"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"}