@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,347 @@
1
+ /**
2
+ * **Reactions** (pulpit#196) — one reaction per account per node, a kind
3
+ * from the family's own vocabulary, behind the target's read gate, with a
4
+ * tally per kind for a page of nodes in one grouped read.
5
+ *
6
+ * The behavioural spec is gardeneria's `social.reactions` table
7
+ * (`packages/database/src/tables/reactions.ts`), its model
8
+ * (`packages/models/src/models/reactions.ts`) and its operations
9
+ * (`apps/api/src/operations/reactions.ts`), with their tests.
10
+ *
11
+ * ## The model (owner decision on pulpit#196)
12
+ *
13
+ * - **A reaction is a fact beside the node**, like an edge: no version
14
+ * chain and no history. The store keys it `(node, account)`, so an
15
+ * account has at most one reaction per node: reacting again with another
16
+ * kind replaces it, and unreacting hard-deletes it.
17
+ * - **The kinds are the family's.** {@link defineReactions} takes the
18
+ * vocabulary (gardeneria's `like | celebrate | helpful | curious |
19
+ * sympathise`) and throws {@link ReactionDeclarationError} at import for a
20
+ * malformed one; the kernel holds no list of kinds.
21
+ * - **The target's read gate applies to every operation**
22
+ * (`SEC-AUTH-ENUM-001`): reacting to, or unreacting from, a node the
23
+ * viewer can't read fails `NodeNotFound`, never `Forbidden`; a tally
24
+ * counts only nodes the viewer can read; and the viewer's own list omits
25
+ * a reaction whose node has since been hidden from them.
26
+ * - **Account erasure** (pulpit#207) deletes a member's reactions through
27
+ * {@link ReactionStore.eraseAccount}, which the erasure operation calls;
28
+ * no reaction operation exposes it.
29
+ *
30
+ * Mute and block (pulpit#195) suppress interaction only, which a family
31
+ * enforces with its own gate around `react` once the social graph lands;
32
+ * a tally counts every account's reactions.
33
+ *
34
+ * @copyright Thomas Foster
35
+ * @module pulpit/reactions
36
+ * @since 0.1.0
37
+ */
38
+
39
+ import * as Arr from "effect/Array";
40
+ import * as Effect from "effect/Effect";
41
+ import * as HashMap from "effect/HashMap";
42
+ import * as Option from "effect/Option";
43
+ import * as Record from "effect/Record";
44
+ import * as Schema from "effect/Schema";
45
+ import { MAX_SET_VALUES } from "./filter.ts";
46
+ import { nowDate, signedIn as signedInAs } from "./internal/plain-rows.ts";
47
+ import { MAX_PAGE_LIMIT, type SubtypeOps, type Unauthorized, type Viewer } from "./subtype.ts";
48
+ import type { NodeNotFound } from "./version-chain.ts";
49
+
50
+ // ── Vocabulary ────────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * A family's reaction vocabulary, as {@link defineReactions} validated it.
54
+ *
55
+ * @since 0.1.0
56
+ */
57
+ export interface ReactionKinds {
58
+ readonly kinds: ReadonlyArray<string>;
59
+ }
60
+
61
+ /**
62
+ * Thrown by {@link reactionKinds} at import for a malformed vocabulary: no
63
+ * kind, an empty kind or a kind named twice. Never an Effect failure: no
64
+ * runtime exists when a family is declared.
65
+ *
66
+ * @since 0.1.0
67
+ */
68
+ export class ReactionDeclarationError extends Schema.TaggedError<
69
+ ReactionDeclarationError,
70
+ { readonly brand: unique symbol }
71
+ >()("ReactionDeclarationError", { message: Schema.String }) {}
72
+
73
+ /**
74
+ * Declare a family's reaction vocabulary. Throws
75
+ * {@link ReactionDeclarationError} for no kind, an empty kind or a repeated
76
+ * one.
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * import { deepStrictEqual } from "node:assert";
81
+ * import { reactionKinds } from "@thomasfosterau/pulpit/reactions";
82
+ *
83
+ * const kinds = reactionKinds(["like", "celebrate", "helpful"]);
84
+ * deepStrictEqual(kinds.kinds, ["like", "celebrate", "helpful"]);
85
+ * ```
86
+ *
87
+ * @since 0.1.0
88
+ */
89
+ export const reactionKinds = (kinds: ReadonlyArray<string>): ReactionKinds => {
90
+ if (!Arr.isReadonlyArrayNonEmpty(kinds)) {
91
+ throw new ReactionDeclarationError({ message: "reactions declare no kind" });
92
+ }
93
+ if (kinds.some((kind) => kind === "")) {
94
+ throw new ReactionDeclarationError({ message: "a reaction kind is empty" });
95
+ }
96
+ const repeated = Arr.findFirst(kinds, (kind, index) => kinds.slice(0, index).includes(kind));
97
+ if (Option.isSome(repeated)) {
98
+ throw new ReactionDeclarationError({
99
+ message: `the reaction kind '${repeated.value}' is named twice`,
100
+ });
101
+ }
102
+ return { kinds: [...kinds] };
103
+ };
104
+
105
+ // ── Errors ────────────────────────────────────────────────────────────
106
+
107
+ /**
108
+ * A kind the family doesn't declare: the wire's 422.
109
+ *
110
+ * @since 0.1.0
111
+ */
112
+ export class InvalidReactionKind extends Schema.TaggedError<
113
+ InvalidReactionKind,
114
+ { readonly brand: unique symbol }
115
+ >()("InvalidReactionKind", { type: Schema.String, kind: Schema.String }) {}
116
+
117
+ // ── The seam ──────────────────────────────────────────────────────────
118
+
119
+ /**
120
+ * One stored reaction.
121
+ *
122
+ * @since 0.1.0
123
+ */
124
+ export interface ReactionRow {
125
+ readonly nodeId: string;
126
+ readonly accountId: string;
127
+ readonly kind: string;
128
+ readonly reactedAt: Date;
129
+ }
130
+
131
+ /**
132
+ * The reaction table an application supplies (gardeneria's
133
+ * `social.reactions`), keyed `(node_id, account_id)` with both foreign keys
134
+ * cascading. A store seam: no policy, never opens a transaction, fails only
135
+ * with its `E`. See the package README → Seams.
136
+ *
137
+ * @since 0.1.0
138
+ */
139
+ export interface ReactionStore<E, R> {
140
+ /**
141
+ * Insert or replace the account's reaction to the node
142
+ * (`INSERT … ON CONFLICT (node_id, account_id) DO UPDATE`), returning the
143
+ * kind it replaced, or none for a first reaction.
144
+ */
145
+ readonly upsert: (row: ReactionRow) => Effect.Effect<Option.Option<string>, E, R>;
146
+ /** Delete the account's reaction to the node; whether a row went. */
147
+ readonly delete: (nodeId: string, accountId: string) => Effect.Effect<boolean, E, R>;
148
+ /**
149
+ * One `GROUP BY node_id, kind` read over `nodeIds` (never empty, no
150
+ * duplicate, at most `MAX_SET_VALUES`): each node's count per kind. A node
151
+ * with no reaction has no key.
152
+ */
153
+ readonly tally: (
154
+ nodeIds: ReadonlyArray<string>,
155
+ ) => Effect.Effect<HashMap.HashMap<string, { readonly [kind: string]: number }>, E, R>;
156
+ /** The account's own reactions, newest first (`reacted_at DESC, node_id`), one page. */
157
+ readonly byAccount: (query: {
158
+ readonly accountId: string;
159
+ readonly limit: number;
160
+ readonly offset: number;
161
+ }) => Effect.Effect<ReadonlyArray<ReactionRow>, E, R>;
162
+ /** Delete every reaction the account made (account erasure, pulpit#207); how many went. */
163
+ readonly eraseAccount: (accountId: string) => Effect.Effect<number, E, R>;
164
+ }
165
+
166
+ // ── The operations ────────────────────────────────────────────────────
167
+
168
+ /**
169
+ * One family's reactions: the kinds, the target leaf's read gate and the
170
+ * store.
171
+ *
172
+ * @since 0.1.0
173
+ */
174
+ export interface ReactionsDefinition<Resource, E, R> {
175
+ /** The target leaf's wire type, named in failures. */
176
+ readonly type: string;
177
+ readonly kinds: ReactionKinds;
178
+ /** The target leaf's gated reads: `get` gates a reaction, `getMany` a tally and the viewer's list. */
179
+ readonly target: Pick<SubtypeOps<Resource, E, R>, "get" | "getMany">;
180
+ readonly reactions: ReactionStore<E, R>;
181
+ /** The request's viewer. */
182
+ readonly viewer: Effect.Effect<Viewer, never, R>;
183
+ }
184
+
185
+ /**
186
+ * A node's tally: its count per declared kind (an undeclared kind a store
187
+ * still holds is not counted), and the total.
188
+ *
189
+ * @since 0.1.0
190
+ */
191
+ export interface Tally {
192
+ readonly counts: { readonly [kind: string]: number };
193
+ readonly total: number;
194
+ }
195
+
196
+ /**
197
+ * The operations {@link defineReactions} produces.
198
+ *
199
+ * @since 0.1.0
200
+ */
201
+ export interface ReactionOps<E, R> {
202
+ /**
203
+ * React to `nodeId` with `kind`, replacing the viewer's earlier reaction
204
+ * to it. Fails `Unauthorized` when anonymous, {@link InvalidReactionKind}
205
+ * for an undeclared kind, and `NodeNotFound` when the viewer can't read
206
+ * the node (`SEC-AUTH-ENUM-001`). Returns the kind it replaced, if any.
207
+ */
208
+ readonly react: (
209
+ nodeId: string,
210
+ kind: string,
211
+ ) => Effect.Effect<
212
+ { readonly replaced: Option.Option<string> },
213
+ Unauthorized | InvalidReactionKind | NodeNotFound | E,
214
+ R
215
+ >;
216
+ /**
217
+ * Remove the viewer's reaction to `nodeId`; whether there was one. Fails
218
+ * `Unauthorized` when anonymous and `NodeNotFound` when the viewer can't
219
+ * read the node.
220
+ */
221
+ readonly unreact: (
222
+ nodeId: string,
223
+ ) => Effect.Effect<{ readonly removed: boolean }, Unauthorized | NodeNotFound | E, R>;
224
+ /**
225
+ * The tally of every node of `nodeIds` the viewer can read, in one
226
+ * `getMany` and one grouped store read (`PERF-DB-001`): each readable node
227
+ * has a key (a zero tally when unreacted), a hidden or missing one has
228
+ * none. More than `MAX_SET_VALUES` distinct ids reads nothing and answers
229
+ * an empty map (`SEC-INPUT-003`).
230
+ */
231
+ readonly tally: (
232
+ nodeIds: ReadonlyArray<string>,
233
+ ) => Effect.Effect<HashMap.HashMap<string, Tally>, E, R>;
234
+ /**
235
+ * One page of the viewer's own reactions, newest first, the limit clamped
236
+ * to `[1, MAX_PAGE_LIMIT]`; a reaction whose node the viewer can no
237
+ * longer read is omitted, so a page may hold fewer rows than the limit.
238
+ * Fails `Unauthorized` when anonymous.
239
+ */
240
+ readonly mine: (request: {
241
+ readonly limit: number;
242
+ readonly offset: number;
243
+ }) => Effect.Effect<ReadonlyArray<ReactionRow>, Unauthorized | E, R>;
244
+ }
245
+
246
+ /**
247
+ * Bind one family's reaction operations. Every operation is traced as
248
+ * `${type}.reactions.<operation>`, and the writes run in the caller's
249
+ * transaction.
250
+ *
251
+ * @example
252
+ * ```ts
253
+ * import { deepStrictEqual } from "node:assert";
254
+ * import * as Effect from "effect/Effect";
255
+ * import * as HashMap from "effect/HashMap";
256
+ * import * as Option from "effect/Option";
257
+ * import { defineReactions, reactionKinds } from "@thomasfosterau/pulpit/reactions";
258
+ * import { makeInMemoryReactionStore } from "@thomasfosterau/pulpit/testing";
259
+ *
260
+ * const reactions = defineReactions({
261
+ * type: "posts",
262
+ * kinds: reactionKinds(["like", "celebrate"]),
263
+ * // A target leaf where every node is readable; in an application, its `defineSubtype` operations.
264
+ * target: {
265
+ * get: (id) => Effect.succeed(id),
266
+ * getMany: (ids) => Effect.succeed(HashMap.fromIterable(ids.map((id) => [id, id] as const))),
267
+ * },
268
+ * reactions: makeInMemoryReactionStore(),
269
+ * viewer: Effect.succeedSome({ id: "u-1", role: "user" }),
270
+ * });
271
+ * const tally = Effect.runSync(
272
+ * Effect.andThen(reactions.react("post-1", "like"), reactions.tally(["post-1"])),
273
+ * );
274
+ * deepStrictEqual(Option.map(HashMap.get(tally, "post-1"), (t) => t.total), Option.some(1));
275
+ * ```
276
+ *
277
+ * @since 0.1.0
278
+ */
279
+ export const defineReactions = <Resource, E, R>(
280
+ definition: ReactionsDefinition<Resource, E, R>,
281
+ ): ReactionOps<E, R> => {
282
+ const { type, target, reactions } = definition;
283
+ const kinds = definition.kinds.kinds;
284
+ const signedIn = signedInAs(definition.viewer);
285
+
286
+ const react = Effect.fn(`${type}.reactions.react`)(function* (nodeId: string, kind: string) {
287
+ const account = yield* signedIn;
288
+ if (!kinds.includes(kind)) return yield* new InvalidReactionKind({ type, kind });
289
+ // A node the viewer can't read is missing to them (SEC-AUTH-ENUM-001).
290
+ yield* target.get(nodeId);
291
+ const replaced = yield* reactions.upsert({
292
+ nodeId,
293
+ accountId: account.id,
294
+ kind,
295
+ reactedAt: yield* nowDate,
296
+ });
297
+ return { replaced };
298
+ });
299
+
300
+ const unreact = Effect.fn(`${type}.reactions.unreact`)(function* (nodeId: string) {
301
+ const account = yield* signedIn;
302
+ yield* target.get(nodeId);
303
+ return { removed: yield* reactions.delete(nodeId, account.id) };
304
+ });
305
+
306
+ const zero: { readonly [kind: string]: number } = Record.fromEntries(
307
+ Arr.map(kinds, (kind) => [kind, 0] as const),
308
+ );
309
+
310
+ const tally = Effect.fn(`${type}.reactions.tally`)(function* (nodeIds: ReadonlyArray<string>) {
311
+ const wanted = Arr.dedupe(nodeIds);
312
+ if (!Arr.isReadonlyArrayNonEmpty(wanted) || wanted.length > MAX_SET_VALUES) {
313
+ return HashMap.empty<string, Tally>();
314
+ }
315
+ const readable = yield* target.getMany(wanted);
316
+ const shown = Arr.filter(wanted, (id) => HashMap.has(readable, id));
317
+ if (!Arr.isReadonlyArrayNonEmpty(shown)) return HashMap.empty<string, Tally>();
318
+ const counted = yield* reactions.tally(shown);
319
+ return HashMap.fromIterable(
320
+ Arr.map(shown, (id) => {
321
+ const stored = Option.getOrElse(HashMap.get(counted, id), () => ({}));
322
+ const counts = Record.map(zero, (_, kind) =>
323
+ Option.getOrElse(Record.get(stored, kind), () => 0),
324
+ );
325
+ const total = Arr.reduce(Record.values(counts), 0, (sum, count) => sum + count);
326
+ return [id, { counts, total }] as const;
327
+ }),
328
+ );
329
+ });
330
+
331
+ const mine = Effect.fn(`${type}.reactions.mine`)(function* (request: {
332
+ readonly limit: number;
333
+ readonly offset: number;
334
+ }) {
335
+ const account = yield* signedIn;
336
+ const rows = yield* reactions.byAccount({
337
+ accountId: account.id,
338
+ limit: Math.max(1, Math.min(Math.trunc(request.limit), MAX_PAGE_LIMIT)),
339
+ offset: Math.max(0, Math.trunc(request.offset)),
340
+ });
341
+ if (!Arr.isReadonlyArrayNonEmpty(rows)) return rows;
342
+ const readable = yield* target.getMany(Arr.dedupe(Arr.map(rows, (row) => row.nodeId)));
343
+ return Arr.filter(rows, (row) => HashMap.has(readable, row.nodeId));
344
+ });
345
+
346
+ return { react, unreact, tally, mine };
347
+ };