@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
package/dist/purge.js ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * **Purge**: erasure, the one destructive path through a node's history
3
+ * (pulpit#58).
4
+ *
5
+ * A tombstone hides a node and keeps every version of its content, readable
6
+ * at the view-deleted rung. Where that content is personal data and the law
7
+ * requires it erased rather than hidden, {@link definePurge}'s `purge`
8
+ * hard-deletes it. It removes the node's whole chain, every row the kernel's
9
+ * other seams keep for the node, and the documents and file bytes no other
10
+ * version references any longer.
11
+ *
12
+ * ## The model (owner decision on pulpit#58, 2026-10-14)
13
+ *
14
+ * - **The whole chain goes.** Every version row on every level of the
15
+ * node's chain is deleted, with its identity row. It is the one sanctioned
16
+ * exception to the append-only chain (`VERSION-CHAIN-001/002`, SECURITY.md):
17
+ * nothing else deletes or rewrites a version row. With the chain go the
18
+ * node's working copies, proposals, publication facts, path claims, reports
19
+ * and every edge with the node at an end: rows that name a node that no
20
+ * longer exists.
21
+ * - **Shared content is deleted only when nothing else references it.**
22
+ * Documents (`./documents`) and bytes (`./blobs`) are shared by hash. A
23
+ * purge drops the node's references; a document or a blob is deleted only
24
+ * when no version of any other node still references its hash, so
25
+ * identical content another node uses survives. A deleted document is the
26
+ * one exception to `DOC-001` (a stored document is never rewritten).
27
+ * - **Bytes are deleted after the commit**, as `./blobs` writes them after
28
+ * the commit: the chain's erasure and the document deletes run in
29
+ * `transact`, the caller's write boundary, and the blob store is told only
30
+ * once it has returned. A failed byte delete never fails the purge, which
31
+ * has committed: the report lists the bytes left behind
32
+ * ({@link PurgeReport.bytesLeft}) for a retry through `BlobStore.delete`.
33
+ * - **A dedicated rung.** `purge` runs at the rung the definition's
34
+ * `canPurge` names, checked before anything is read, so a caller below it
35
+ * learns nothing about any id (`Unauthorized` / `Forbidden` for every id
36
+ * alike). At the rung, an unknown or already purged id fails
37
+ * `NodeNotFound`. A live node purges as a tombstoned one does: erasure is
38
+ * one step, never "delete, then purge".
39
+ *
40
+ * ## The seam
41
+ *
42
+ * The erasure itself is the application's {@link PurgeStore}: it knows
43
+ * which tables hold rows keyed to a node and which columns carry a document
44
+ * or content hash. The contract is stated in the package README → Seams.
45
+ *
46
+ * @copyright Thomas Foster
47
+ * @module pulpit/purge
48
+ * @since 0.1.0
49
+ */
50
+ import * as Arr from "effect/Array";
51
+ import * as Effect from "effect/Effect";
52
+ import * as Exit from "effect/Exit";
53
+ import * as Option from "effect/Option";
54
+ import { blobKey } from "./blobs.js";
55
+ import { Forbidden, Unauthorized, } from "./subtype.js";
56
+ import { NodeNotFound } from "./version-chain.js";
57
+ /**
58
+ * Bind a leaf's purge. `purge(id)` checks the purge rung, then, inside
59
+ * `transact`, erases the node through the {@link PurgeStore}, deletes the
60
+ * documents nothing else references, and reads which of its bytes nothing
61
+ * else references; once `transact` has returned it evicts the cache and
62
+ * deletes those bytes. A failed byte delete is logged and reported in
63
+ * {@link PurgeReport.bytesLeft}, never a failure.
64
+ *
65
+ * @example
66
+ * ```ts
67
+ * import { deepStrictEqual } from "node:assert";
68
+ * import * as Effect from "effect/Effect";
69
+ * import * as HashMap from "effect/HashMap";
70
+ * import * as Option from "effect/Option";
71
+ * import { defineVersionChain } from "@thomasfosterau/pulpit/version-chain";
72
+ * import { definePurge } from "@thomasfosterau/pulpit/purge";
73
+ * import {
74
+ * makeInMemoryChainStore,
75
+ * makeInMemoryDocumentStore,
76
+ * makeInMemoryPurgeStore,
77
+ * } from "@thomasfosterau/pulpit/testing";
78
+ *
79
+ * const chain = makeInMemoryChainStore();
80
+ * const documents = makeInMemoryDocumentStore();
81
+ * const notes = defineVersionChain("notes", chain);
82
+ * const purge = definePurge({
83
+ * type: "notes",
84
+ * chain,
85
+ * store: makeInMemoryPurgeStore({ chains: [chain], documents, documentColumns: ["body"] }),
86
+ * viewer: Effect.succeed(Option.some({ id: "u-admin", role: "admin" })),
87
+ * canPurge: (account) => account.role === "admin",
88
+ * transact: (effect) => effect,
89
+ * });
90
+ *
91
+ * const report = await Effect.runPromise(
92
+ * Effect.gen(function* () {
93
+ * const { hash } = yield* documents.put({ text: "personal data" });
94
+ * const { id } = yield* notes.create({
95
+ * id: Option.none(),
96
+ * state: { body: hash },
97
+ * actorId: Option.none(),
98
+ * });
99
+ * return yield* purge.purge(id);
100
+ * }),
101
+ * );
102
+ *
103
+ * // The chain and its only document (a hash no other version references) are gone.
104
+ * deepStrictEqual(chain.rows(), []);
105
+ * deepStrictEqual(HashMap.size(documents.stored()), 0);
106
+ * deepStrictEqual(report.documents.length, 1);
107
+ * ```
108
+ *
109
+ * @since 0.1.0
110
+ */
111
+ export const definePurge = (definition) => {
112
+ const { type, chain, store, transact } = definition;
113
+ /** The purge rung, checked before any row is read. */
114
+ const gate = Effect.flatMap(definition.viewer, (viewer) => Option.match(viewer, {
115
+ onNone: () => Effect.fail(new Unauthorized()),
116
+ onSome: (account) => definition.canPurge(account) ? Effect.succeed(account) : Effect.fail(new Forbidden()),
117
+ }));
118
+ /** The erasure, in the caller's transaction: what it deleted and which bytes it freed. */
119
+ const erase = (id) => Effect.gen(function* () {
120
+ const head = yield* chain.head(id);
121
+ if (Option.isNone(head))
122
+ return yield* new NodeNotFound({ type, id });
123
+ const erased = yield* store.eraseNode(id);
124
+ const documents = yield* store.deleteUnreferencedDocuments(Arr.dedupe(erased.documents));
125
+ const freed = yield* store.unreferencedBlobs(Arr.dedupe(erased.blobs));
126
+ return { head: head.value.version, documents, freed };
127
+ });
128
+ /** Delete one blob's bytes after the commit: whether they are gone. */
129
+ const deleteBytes = (hash) => definition.blobs === undefined
130
+ ? Effect.succeed(false)
131
+ : Effect.exit(definition.blobs.delete(blobKey(hash))).pipe(Effect.tap((exit) => Exit.isFailure(exit)
132
+ ? Effect.logWarning(`${type}: bytes ${hash} left after purge`, exit.cause)
133
+ : Effect.void), Effect.map(Exit.isSuccess));
134
+ const purge = Effect.fn(`${type}.purge`)(function* (id) {
135
+ yield* gate;
136
+ const erased = yield* transact(erase(id));
137
+ if (definition.cache !== undefined)
138
+ yield* definition.cache.delete(id, erased.head + 1n);
139
+ const deleted = yield* Effect.forEach(erased.freed, (hash) => Effect.map(deleteBytes(hash), (gone) => [hash, gone]), { concurrency: 1 });
140
+ const gone = (wanted) => Arr.map(Arr.filter(deleted, ([, deletedNow]) => deletedNow === wanted), ([hash]) => hash);
141
+ const report = {
142
+ id,
143
+ documents: erased.documents,
144
+ blobs: gone(true),
145
+ bytesLeft: gone(false),
146
+ };
147
+ return report;
148
+ });
149
+ return { type, purge };
150
+ };
151
+ //# sourceMappingURL=purge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"purge.js","sourceRoot":"","sources":["../src/purge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AAEH,OAAO,KAAK,GAAG,MAAM,cAAc,CAAC;AACpC,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,IAAI,MAAM,aAAa,CAAC;AACpC,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,EAAE,OAAO,EAAoC,MAAM,YAAY,CAAC;AAEvE,OAAO,EACL,SAAS,EACT,YAAY,GAIb,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,YAAY,EAA0B,MAAM,oBAAoB,CAAC;AAqI1E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAW,UAAqC,EAAkB,EAAE;IAC7F,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,UAAU,CAAC;IAEpD,sDAAsD;IACtD,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CACzB,UAAU,CAAC,MAAM,EACjB,CAAC,MAAM,EAA0D,EAAE,CACjE,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE;QACnB,MAAM,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,YAAY,EAAE,CAAC;QAC7C,MAAM,EAAE,CAAC,OAAO,EAAE,EAAE,CAClB,UAAU,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,SAAS,EAAE,CAAC;KACxF,CAAC,CACL,CAAC;IAEF,0FAA0F;IAC1F,MAAM,KAAK,GAAG,CAAC,EAAU,EAAE,EAAE,CAC3B,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QAClB,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACnC,IAAI,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC;YAAE,OAAO,KAAK,CAAC,CAAC,IAAI,YAAY,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;QACtE,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC;QAC1C,MAAM,SAAS,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,2BAA2B,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC;QACzF,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,iBAAiB,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;QACvE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC;IACxD,CAAC,CAAC,CAAC;IAEL,uEAAuE;IACvE,MAAM,WAAW,GAAG,CAAC,IAAiB,EAAE,EAAE,CACxC,UAAU,CAAC,KAAK,KAAK,SAAS;QAC5B,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC;QACvB,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CACtD,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAClB,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC;YAClB,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,GAAG,IAAI,WAAW,IAAI,mBAAmB,EAAE,IAAI,CAAC,KAAK,CAAC;YAC1E,CAAC,CAAC,MAAM,CAAC,IAAI,CAChB,EACD,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,CAC3B,CAAC;IAER,MAAM,KAAK,GAAG,MAAM,CAAC,EAAE,CAAC,GAAG,IAAI,QAAQ,CAAC,CAAC,QAAQ,CAAC,EAAE,EAAU;QAC5D,KAAK,CAAC,CAAC,IAAI,CAAC;QACZ,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC;QAC1C,IAAI,UAAU,CAAC,KAAK,KAAK,SAAS;YAAE,KAAK,CAAC,CAAC,UAAU,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,IAAI,GAAG,EAAE,CAAC,CAAC;QACzF,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC,MAAM,CAAC,OAAO,CACnC,MAAM,CAAC,KAAK,EACZ,CAAC,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,IAAI,CAAU,CAAC,EACxE,EAAE,WAAW,EAAE,CAAC,EAAE,CACnB,CAAC;QACF,MAAM,IAAI,GAAG,CAAC,MAAe,EAAE,EAAE,CAC/B,GAAG,CAAC,GAAG,CACL,GAAG,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC,EAAE,UAAU,CAAC,EAAE,EAAE,CAAC,UAAU,KAAK,MAAM,CAAC,EAC9D,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,IAAI,CACjB,CAAC;QACJ,MAAM,MAAM,GAAgB;YAC1B,EAAE;YACF,SAAS,EAAE,MAAM,CAAC,SAAS;YAC3B,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC;YACjB,SAAS,EAAE,IAAI,CAAC,KAAK,CAAC;SACvB,CAAC;QACF,OAAO,MAAM,CAAC;IAChB,CAAC,CAAC,CAAC;IAEH,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;AACzB,CAAC,CAAC"}
@@ -0,0 +1,246 @@
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
+ import * as Effect from "effect/Effect";
39
+ import * as HashMap from "effect/HashMap";
40
+ import * as Option from "effect/Option";
41
+ import * as Schema from "effect/Schema";
42
+ import { type SubtypeOps, type Unauthorized, type Viewer } from "./subtype.ts";
43
+ import type { NodeNotFound } from "./version-chain.ts";
44
+ /**
45
+ * A family's reaction vocabulary, as {@link defineReactions} validated it.
46
+ *
47
+ * @since 0.1.0
48
+ */
49
+ export interface ReactionKinds {
50
+ readonly kinds: ReadonlyArray<string>;
51
+ }
52
+ declare const ReactionDeclarationError_base: Schema.Class<ReactionDeclarationError, Schema.TaggedStruct<"ReactionDeclarationError", {
53
+ readonly message: Schema.String;
54
+ }>, import("effect/Cause").YieldableError & {
55
+ readonly brand: unique symbol;
56
+ }>;
57
+ /**
58
+ * Thrown by {@link reactionKinds} at import for a malformed vocabulary: no
59
+ * kind, an empty kind or a kind named twice. Never an Effect failure: no
60
+ * runtime exists when a family is declared.
61
+ *
62
+ * @since 0.1.0
63
+ */
64
+ export declare class ReactionDeclarationError extends ReactionDeclarationError_base {
65
+ }
66
+ /**
67
+ * Declare a family's reaction vocabulary. Throws
68
+ * {@link ReactionDeclarationError} for no kind, an empty kind or a repeated
69
+ * one.
70
+ *
71
+ * @example
72
+ * ```ts
73
+ * import { deepStrictEqual } from "node:assert";
74
+ * import { reactionKinds } from "@thomasfosterau/pulpit/reactions";
75
+ *
76
+ * const kinds = reactionKinds(["like", "celebrate", "helpful"]);
77
+ * deepStrictEqual(kinds.kinds, ["like", "celebrate", "helpful"]);
78
+ * ```
79
+ *
80
+ * @since 0.1.0
81
+ */
82
+ export declare const reactionKinds: (kinds: ReadonlyArray<string>) => ReactionKinds;
83
+ declare const InvalidReactionKind_base: Schema.Class<InvalidReactionKind, Schema.TaggedStruct<"InvalidReactionKind", {
84
+ readonly type: Schema.String;
85
+ readonly kind: Schema.String;
86
+ }>, import("effect/Cause").YieldableError & {
87
+ readonly brand: unique symbol;
88
+ }>;
89
+ /**
90
+ * A kind the family doesn't declare: the wire's 422.
91
+ *
92
+ * @since 0.1.0
93
+ */
94
+ export declare class InvalidReactionKind extends InvalidReactionKind_base {
95
+ }
96
+ /**
97
+ * One stored reaction.
98
+ *
99
+ * @since 0.1.0
100
+ */
101
+ export interface ReactionRow {
102
+ readonly nodeId: string;
103
+ readonly accountId: string;
104
+ readonly kind: string;
105
+ readonly reactedAt: Date;
106
+ }
107
+ /**
108
+ * The reaction table an application supplies (gardeneria's
109
+ * `social.reactions`), keyed `(node_id, account_id)` with both foreign keys
110
+ * cascading. A store seam: no policy, never opens a transaction, fails only
111
+ * with its `E`. See the package README → Seams.
112
+ *
113
+ * @since 0.1.0
114
+ */
115
+ export interface ReactionStore<E, R> {
116
+ /**
117
+ * Insert or replace the account's reaction to the node
118
+ * (`INSERT … ON CONFLICT (node_id, account_id) DO UPDATE`), returning the
119
+ * kind it replaced, or none for a first reaction.
120
+ */
121
+ readonly upsert: (row: ReactionRow) => Effect.Effect<Option.Option<string>, E, R>;
122
+ /** Delete the account's reaction to the node; whether a row went. */
123
+ readonly delete: (nodeId: string, accountId: string) => Effect.Effect<boolean, E, R>;
124
+ /**
125
+ * One `GROUP BY node_id, kind` read over `nodeIds` (never empty, no
126
+ * duplicate, at most `MAX_SET_VALUES`): each node's count per kind. A node
127
+ * with no reaction has no key.
128
+ */
129
+ readonly tally: (nodeIds: ReadonlyArray<string>) => Effect.Effect<HashMap.HashMap<string, {
130
+ readonly [kind: string]: number;
131
+ }>, E, R>;
132
+ /** The account's own reactions, newest first (`reacted_at DESC, node_id`), one page. */
133
+ readonly byAccount: (query: {
134
+ readonly accountId: string;
135
+ readonly limit: number;
136
+ readonly offset: number;
137
+ }) => Effect.Effect<ReadonlyArray<ReactionRow>, E, R>;
138
+ /** Delete every reaction the account made (account erasure, pulpit#207); how many went. */
139
+ readonly eraseAccount: (accountId: string) => Effect.Effect<number, E, R>;
140
+ }
141
+ /**
142
+ * One family's reactions: the kinds, the target leaf's read gate and the
143
+ * store.
144
+ *
145
+ * @since 0.1.0
146
+ */
147
+ export interface ReactionsDefinition<Resource, E, R> {
148
+ /** The target leaf's wire type, named in failures. */
149
+ readonly type: string;
150
+ readonly kinds: ReactionKinds;
151
+ /** The target leaf's gated reads: `get` gates a reaction, `getMany` a tally and the viewer's list. */
152
+ readonly target: Pick<SubtypeOps<Resource, E, R>, "get" | "getMany">;
153
+ readonly reactions: ReactionStore<E, R>;
154
+ /** The request's viewer. */
155
+ readonly viewer: Effect.Effect<Viewer, never, R>;
156
+ }
157
+ /**
158
+ * A node's tally: its count per declared kind (an undeclared kind a store
159
+ * still holds is not counted), and the total.
160
+ *
161
+ * @since 0.1.0
162
+ */
163
+ export interface Tally {
164
+ readonly counts: {
165
+ readonly [kind: string]: number;
166
+ };
167
+ readonly total: number;
168
+ }
169
+ /**
170
+ * The operations {@link defineReactions} produces.
171
+ *
172
+ * @since 0.1.0
173
+ */
174
+ export interface ReactionOps<E, R> {
175
+ /**
176
+ * React to `nodeId` with `kind`, replacing the viewer's earlier reaction
177
+ * to it. Fails `Unauthorized` when anonymous, {@link InvalidReactionKind}
178
+ * for an undeclared kind, and `NodeNotFound` when the viewer can't read
179
+ * the node (`SEC-AUTH-ENUM-001`). Returns the kind it replaced, if any.
180
+ */
181
+ readonly react: (nodeId: string, kind: string) => Effect.Effect<{
182
+ readonly replaced: Option.Option<string>;
183
+ }, Unauthorized | InvalidReactionKind | NodeNotFound | E, R>;
184
+ /**
185
+ * Remove the viewer's reaction to `nodeId`; whether there was one. Fails
186
+ * `Unauthorized` when anonymous and `NodeNotFound` when the viewer can't
187
+ * read the node.
188
+ */
189
+ readonly unreact: (nodeId: string) => Effect.Effect<{
190
+ readonly removed: boolean;
191
+ }, Unauthorized | NodeNotFound | E, R>;
192
+ /**
193
+ * The tally of every node of `nodeIds` the viewer can read, in one
194
+ * `getMany` and one grouped store read (`PERF-DB-001`): each readable node
195
+ * has a key (a zero tally when unreacted), a hidden or missing one has
196
+ * none. More than `MAX_SET_VALUES` distinct ids reads nothing and answers
197
+ * an empty map (`SEC-INPUT-003`).
198
+ */
199
+ readonly tally: (nodeIds: ReadonlyArray<string>) => Effect.Effect<HashMap.HashMap<string, Tally>, E, R>;
200
+ /**
201
+ * One page of the viewer's own reactions, newest first, the limit clamped
202
+ * to `[1, MAX_PAGE_LIMIT]`; a reaction whose node the viewer can no
203
+ * longer read is omitted, so a page may hold fewer rows than the limit.
204
+ * Fails `Unauthorized` when anonymous.
205
+ */
206
+ readonly mine: (request: {
207
+ readonly limit: number;
208
+ readonly offset: number;
209
+ }) => Effect.Effect<ReadonlyArray<ReactionRow>, Unauthorized | E, R>;
210
+ }
211
+ /**
212
+ * Bind one family's reaction operations. Every operation is traced as
213
+ * `${type}.reactions.<operation>`, and the writes run in the caller's
214
+ * transaction.
215
+ *
216
+ * @example
217
+ * ```ts
218
+ * import { deepStrictEqual } from "node:assert";
219
+ * import * as Effect from "effect/Effect";
220
+ * import * as HashMap from "effect/HashMap";
221
+ * import * as Option from "effect/Option";
222
+ * import { defineReactions, reactionKinds } from "@thomasfosterau/pulpit/reactions";
223
+ * import { makeInMemoryReactionStore } from "@thomasfosterau/pulpit/testing";
224
+ *
225
+ * const reactions = defineReactions({
226
+ * type: "posts",
227
+ * kinds: reactionKinds(["like", "celebrate"]),
228
+ * // A target leaf where every node is readable; in an application, its `defineSubtype` operations.
229
+ * target: {
230
+ * get: (id) => Effect.succeed(id),
231
+ * getMany: (ids) => Effect.succeed(HashMap.fromIterable(ids.map((id) => [id, id] as const))),
232
+ * },
233
+ * reactions: makeInMemoryReactionStore(),
234
+ * viewer: Effect.succeedSome({ id: "u-1", role: "user" }),
235
+ * });
236
+ * const tally = Effect.runSync(
237
+ * Effect.andThen(reactions.react("post-1", "like"), reactions.tally(["post-1"])),
238
+ * );
239
+ * deepStrictEqual(Option.map(HashMap.get(tally, "post-1"), (t) => t.total), Option.some(1));
240
+ * ```
241
+ *
242
+ * @since 0.1.0
243
+ */
244
+ export declare const defineReactions: <Resource, E, R>(definition: ReactionsDefinition<Resource, E, R>) => ReactionOps<E, R>;
245
+ export {};
246
+ //# sourceMappingURL=reactions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reactions.d.ts","sourceRoot":"","sources":["../src/reactions.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,183 @@
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
+ import * as Arr from "effect/Array";
39
+ import * as Effect from "effect/Effect";
40
+ import * as HashMap from "effect/HashMap";
41
+ import * as Option from "effect/Option";
42
+ import * as Record from "effect/Record";
43
+ import * as Schema from "effect/Schema";
44
+ import { MAX_SET_VALUES } from "./filter.js";
45
+ import { nowDate, signedIn as signedInAs } from "./internal/plain-rows.js";
46
+ import { MAX_PAGE_LIMIT } from "./subtype.js";
47
+ /**
48
+ * Thrown by {@link reactionKinds} at import for a malformed vocabulary: no
49
+ * kind, an empty kind or a kind named twice. Never an Effect failure: no
50
+ * runtime exists when a family is declared.
51
+ *
52
+ * @since 0.1.0
53
+ */
54
+ export class ReactionDeclarationError extends Schema.TaggedError()("ReactionDeclarationError", { message: Schema.String }) {
55
+ }
56
+ /**
57
+ * Declare a family's reaction vocabulary. Throws
58
+ * {@link ReactionDeclarationError} for no kind, an empty kind or a repeated
59
+ * one.
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * import { deepStrictEqual } from "node:assert";
64
+ * import { reactionKinds } from "@thomasfosterau/pulpit/reactions";
65
+ *
66
+ * const kinds = reactionKinds(["like", "celebrate", "helpful"]);
67
+ * deepStrictEqual(kinds.kinds, ["like", "celebrate", "helpful"]);
68
+ * ```
69
+ *
70
+ * @since 0.1.0
71
+ */
72
+ export const reactionKinds = (kinds) => {
73
+ if (!Arr.isReadonlyArrayNonEmpty(kinds)) {
74
+ throw new ReactionDeclarationError({ message: "reactions declare no kind" });
75
+ }
76
+ if (kinds.some((kind) => kind === "")) {
77
+ throw new ReactionDeclarationError({ message: "a reaction kind is empty" });
78
+ }
79
+ const repeated = Arr.findFirst(kinds, (kind, index) => kinds.slice(0, index).includes(kind));
80
+ if (Option.isSome(repeated)) {
81
+ throw new ReactionDeclarationError({
82
+ message: `the reaction kind '${repeated.value}' is named twice`,
83
+ });
84
+ }
85
+ return { kinds: [...kinds] };
86
+ };
87
+ // ── Errors ────────────────────────────────────────────────────────────
88
+ /**
89
+ * A kind the family doesn't declare: the wire's 422.
90
+ *
91
+ * @since 0.1.0
92
+ */
93
+ export class InvalidReactionKind extends Schema.TaggedError()("InvalidReactionKind", { type: Schema.String, kind: Schema.String }) {
94
+ }
95
+ /**
96
+ * Bind one family's reaction operations. Every operation is traced as
97
+ * `${type}.reactions.<operation>`, and the writes run in the caller's
98
+ * transaction.
99
+ *
100
+ * @example
101
+ * ```ts
102
+ * import { deepStrictEqual } from "node:assert";
103
+ * import * as Effect from "effect/Effect";
104
+ * import * as HashMap from "effect/HashMap";
105
+ * import * as Option from "effect/Option";
106
+ * import { defineReactions, reactionKinds } from "@thomasfosterau/pulpit/reactions";
107
+ * import { makeInMemoryReactionStore } from "@thomasfosterau/pulpit/testing";
108
+ *
109
+ * const reactions = defineReactions({
110
+ * type: "posts",
111
+ * kinds: reactionKinds(["like", "celebrate"]),
112
+ * // A target leaf where every node is readable; in an application, its `defineSubtype` operations.
113
+ * target: {
114
+ * get: (id) => Effect.succeed(id),
115
+ * getMany: (ids) => Effect.succeed(HashMap.fromIterable(ids.map((id) => [id, id] as const))),
116
+ * },
117
+ * reactions: makeInMemoryReactionStore(),
118
+ * viewer: Effect.succeedSome({ id: "u-1", role: "user" }),
119
+ * });
120
+ * const tally = Effect.runSync(
121
+ * Effect.andThen(reactions.react("post-1", "like"), reactions.tally(["post-1"])),
122
+ * );
123
+ * deepStrictEqual(Option.map(HashMap.get(tally, "post-1"), (t) => t.total), Option.some(1));
124
+ * ```
125
+ *
126
+ * @since 0.1.0
127
+ */
128
+ export const defineReactions = (definition) => {
129
+ const { type, target, reactions } = definition;
130
+ const kinds = definition.kinds.kinds;
131
+ const signedIn = signedInAs(definition.viewer);
132
+ const react = Effect.fn(`${type}.reactions.react`)(function* (nodeId, kind) {
133
+ const account = yield* signedIn;
134
+ if (!kinds.includes(kind))
135
+ return yield* new InvalidReactionKind({ type, kind });
136
+ // A node the viewer can't read is missing to them (SEC-AUTH-ENUM-001).
137
+ yield* target.get(nodeId);
138
+ const replaced = yield* reactions.upsert({
139
+ nodeId,
140
+ accountId: account.id,
141
+ kind,
142
+ reactedAt: yield* nowDate,
143
+ });
144
+ return { replaced };
145
+ });
146
+ const unreact = Effect.fn(`${type}.reactions.unreact`)(function* (nodeId) {
147
+ const account = yield* signedIn;
148
+ yield* target.get(nodeId);
149
+ return { removed: yield* reactions.delete(nodeId, account.id) };
150
+ });
151
+ const zero = Record.fromEntries(Arr.map(kinds, (kind) => [kind, 0]));
152
+ const tally = Effect.fn(`${type}.reactions.tally`)(function* (nodeIds) {
153
+ const wanted = Arr.dedupe(nodeIds);
154
+ if (!Arr.isReadonlyArrayNonEmpty(wanted) || wanted.length > MAX_SET_VALUES) {
155
+ return HashMap.empty();
156
+ }
157
+ const readable = yield* target.getMany(wanted);
158
+ const shown = Arr.filter(wanted, (id) => HashMap.has(readable, id));
159
+ if (!Arr.isReadonlyArrayNonEmpty(shown))
160
+ return HashMap.empty();
161
+ const counted = yield* reactions.tally(shown);
162
+ return HashMap.fromIterable(Arr.map(shown, (id) => {
163
+ const stored = Option.getOrElse(HashMap.get(counted, id), () => ({}));
164
+ const counts = Record.map(zero, (_, kind) => Option.getOrElse(Record.get(stored, kind), () => 0));
165
+ const total = Arr.reduce(Record.values(counts), 0, (sum, count) => sum + count);
166
+ return [id, { counts, total }];
167
+ }));
168
+ });
169
+ const mine = Effect.fn(`${type}.reactions.mine`)(function* (request) {
170
+ const account = yield* signedIn;
171
+ const rows = yield* reactions.byAccount({
172
+ accountId: account.id,
173
+ limit: Math.max(1, Math.min(Math.trunc(request.limit), MAX_PAGE_LIMIT)),
174
+ offset: Math.max(0, Math.trunc(request.offset)),
175
+ });
176
+ if (!Arr.isReadonlyArrayNonEmpty(rows))
177
+ return rows;
178
+ const readable = yield* target.getMany(Arr.dedupe(Arr.map(rows, (row) => row.nodeId)));
179
+ return Arr.filter(rows, (row) => HashMap.has(readable, row.nodeId));
180
+ });
181
+ return { react, unreact, tally, mine };
182
+ };
183
+ //# sourceMappingURL=reactions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reactions.js","sourceRoot":"","sources":["../src/reactions.ts"],"names":[],"mappings":"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"}