@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,225 @@
1
+ /**
2
+ * **The publication axis, the moderation state, and the cache serveability
3
+ * policy.**
4
+ *
5
+ * Ported from gardeneria's `packages/database/src/publication.ts`
6
+ * (`isPublished` / `isNotPublished` / `publishedFilter`) and the
7
+ * `isPubliclyServeable` gate of its `apps/api/src/cache.ts`, with its tests
8
+ * (`apps/api/tests/resource-cache-readthrough.test.ts`). A `#NNN` here names
9
+ * a gardeneria issue.
10
+ *
11
+ * ## Published means stamped AND arrived
12
+ *
13
+ * A publication stamp (`publishedAt`) is a datetime the author controls, so
14
+ * an author may set a FUTURE one, and that is what scheduling is (#1121): the
15
+ * row exists, it is stamped, and it is not out yet. `IS NOT NULL` and "live"
16
+ * are therefore different predicates, and every reader of the axis gets the
17
+ * two-part answer from here. The unpublished predicate is the exact
18
+ * complement — an owner's "unpublished" list is their drafts AND their
19
+ * scheduled rows. In SQL the clock is the database's (`now()`, one per
20
+ * query); the list filter node carries that contract (`./filter`).
21
+ *
22
+ * ## The serveability policy (`CACHE-AUTH-005`)
23
+ *
24
+ * The KV read-through cache is populated by writes — a privileged editor's
25
+ * save included — so it CAN hold a resource only some viewers may read, and
26
+ * it serves anonymous readers. {@link isPubliclyServeable} is the gate on
27
+ * both of its paths (serving a hit, and populating on a miss, where a
28
+ * non-serveable resource is evicted so a `public → private` flip
29
+ * self-purges). **The rule: every publication axis the operation layer
30
+ * gates on must be present in the cached attributes AND pass.** A present
31
+ * axis whose value is not exactly the serveable literal is refused; an absent
32
+ * axis means the resource does not ride it — except where the family's
33
+ * options say absence is itself a refusal:
34
+ *
35
+ * - `owned` — an owned family whose `visibility` attribute is absent refuses:
36
+ * the projection dropped the one attribute that decides who may see the
37
+ * row, and reading that as public is a fail-open (#1096, #1092);
38
+ * - `publishGated` — a family gated by its stamp refuses a missing,
39
+ * unparseable or future `publishedAt`. A leaf that declares `./subtype`'s
40
+ * live pointer is one: its live view supplies the stamp, and its
41
+ * handlers write the cache only from the anonymous (live) read
42
+ * (pulpit#46).
43
+ *
44
+ * What the predicate structurally cannot express is a gate that depends on
45
+ * state outside the cached resource (a comment's inherited target gate,
46
+ * #1475); a family with one closes it on the write path (evict on the
47
+ * target's mutation) with the cache TTL floor as the backstop.
48
+ *
49
+ * @copyright Thomas Foster
50
+ * @module pulpit/publication
51
+ * @since 0.1.0
52
+ */
53
+
54
+ import * as DateTime from "effect/DateTime";
55
+ import * as Option from "effect/Option";
56
+ import * as Predicate from "effect/Predicate";
57
+ import * as Schema from "effect/Schema";
58
+ import { always, and, gt, lte, notPublished, published, type ListFilter } from "./filter.ts";
59
+
60
+ /**
61
+ * A moderated family's review state (`PUB-005`): only `approved` is
62
+ * serveable from the cache. A `pending` / `rejected` row is still readable by
63
+ * its author at the operation layer — it is refused from the cache because
64
+ * only SOME viewers may read it.
65
+ *
66
+ * @since 0.1.0
67
+ */
68
+ export const ModerationState = Schema.Literals(["pending", "approved", "rejected"]);
69
+ /**
70
+ * The {@link ModerationState} literal union: `"pending" | "approved" | "rejected"`.
71
+ *
72
+ * @since 0.1.0
73
+ */
74
+ export type ModerationState = typeof ModerationState.Type;
75
+
76
+ /**
77
+ * Stamped, and the stamp has arrived at `now`.
78
+ *
79
+ * @since 0.1.0
80
+ */
81
+ export const isPublishedAt = (publishedAt: Option.Option<Date>, now: Date): boolean =>
82
+ Option.exists(publishedAt, (stamp) => stamp.getTime() <= now.getTime());
83
+
84
+ /**
85
+ * Not out at `now`: unstamped, or stamped in the future — the exact complement of {@link isPublishedAt}.
86
+ *
87
+ * @since 0.1.0
88
+ */
89
+ export const isNotPublishedAt = (publishedAt: Option.Option<Date>, now: Date): boolean =>
90
+ !isPublishedAt(publishedAt, now);
91
+
92
+ /**
93
+ * The axis as one list-filter arm, for a `?filter[published]=` that may be
94
+ * absent: none in, `always` out (no predicate) — so a list reads one line per
95
+ * axis.
96
+ *
97
+ * @since 0.1.0
98
+ */
99
+ export const publishedFilter = <Following = never>(
100
+ column: string,
101
+ wanted: Option.Option<boolean>,
102
+ ): ListFilter<Following> =>
103
+ Option.match(wanted, {
104
+ onNone: (): ListFilter<Following> => always,
105
+ onSome: (isWanted) => (isWanted ? published(column) : notPublished(column)),
106
+ });
107
+
108
+ /**
109
+ * The rows whose publication stamp arrived in the window `(since, now]`:
110
+ * `since < column AND column <= now`, as a list filter for an application's
111
+ * cron to drive a scheduled-publication sweep (pulpit#45). A write fires its
112
+ * lifecycle events when it is made, so a version stamped for the future
113
+ * emits nothing when its stamp arrives; a sweep that passes each run's `now`
114
+ * as the next run's `since` sees every such row exactly once, since the
115
+ * windows are half-open and abut. Pass it as a list request's `where`, read
116
+ * as a viewer who may see the rows the sweep is for (the list still applies
117
+ * the leaf's access and soft-delete arms). An unordered instant (an invalid
118
+ * `Date`) matches nothing. `column` defaults to `publishedAt`.
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * import { strictEqual } from "node:assert";
123
+ * import { evaluate } from "@thomasfosterau/pulpit/filter";
124
+ * import { duePublications } from "@thomasfosterau/pulpit/publication";
125
+ *
126
+ * const due = duePublications(new Date("2026-10-22T00:00:00Z"), new Date("2026-10-22T01:00:00Z"));
127
+ * const context = { now: new Date("2026-10-22T01:00:00Z"), contains: () => false };
128
+ * strictEqual(evaluate(due, { publishedAt: new Date("2026-10-22T00:30:00Z") }, context), true);
129
+ * strictEqual(evaluate(due, { publishedAt: new Date("2026-10-22T00:00:00Z") }, context), false);
130
+ * ```
131
+ *
132
+ * @since 0.1.0
133
+ */
134
+ export const duePublications = <Following = never>(
135
+ since: Date,
136
+ now: Date,
137
+ column: string = "publishedAt",
138
+ ): ListFilter<Following> => and<Following>(gt(column, since), lte(column, now));
139
+
140
+ /* oxlint-disable effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions), so a caller can forward its own `undefined` under `exactOptionalPropertyTypes`; the omitted key is the absence, read as such by every reader, so there is no value to model as an `Option` */
141
+ /**
142
+ * A family's cache posture: which absent axes are refusals.
143
+ *
144
+ * @since 0.1.0
145
+ */
146
+ export interface ServeabilityOptions {
147
+ /** An owned family: a missing `visibility` refuses (#1096). */
148
+ readonly owned?: boolean | undefined;
149
+ /** A stamp-gated family: a missing, unparseable or future `publishedAt` refuses. */
150
+ readonly publishGated?: boolean | undefined;
151
+ /** The instant a stamp must have arrived by; the wall clock when omitted. */
152
+ readonly now?: Date | undefined;
153
+ }
154
+ /* oxlint-enable effect/prefer-option-over-null */
155
+
156
+ /* oxlint-disable effect/no-unknown-parameters, effect/no-unknown-returns -- the cache's security gate must accept whatever the KV hands back, including a value that fails to parse: refusing malformed input IS its contract, so the untyped value is the boundary itself */
157
+ /**
158
+ * Whether a serialised `publishedAt` names an instant that has arrived. The
159
+ * value crosses KV as JSON, so it arrives as a string on a hit and as a
160
+ * `Date` on populate; anything that does not parse to a finite instant is
161
+ * refused rather than guessed at.
162
+ */
163
+ const hasArrivedStamp = (publishedAt: unknown, now: Date): boolean => {
164
+ const instant =
165
+ publishedAt instanceof Date
166
+ ? publishedAt.getTime()
167
+ : Predicate.isString(publishedAt)
168
+ ? Option.match(DateTime.make(publishedAt), {
169
+ onNone: () => Number.NaN,
170
+ onSome: DateTime.toEpochMillis,
171
+ })
172
+ : Number.NaN;
173
+ return Number.isFinite(instant) && instant <= now.getTime();
174
+ };
175
+
176
+ /**
177
+ * Whether a cached resource may be served from the cache to a viewer who
178
+ * reaches the cache at all (a viewer who bypasses it for freshness reads the
179
+ * database directly). See the module doc for the rule.
180
+ *
181
+ * @since 0.1.0
182
+ */
183
+ export const isPubliclyServeable = (
184
+ resource: unknown,
185
+ options: ServeabilityOptions = {},
186
+ ): boolean => {
187
+ if (!Predicate.isObject(resource)) return false;
188
+ const attributes = resource["attributes"];
189
+ // No attributes block to inspect: refuse rather than guess.
190
+ if (!Predicate.isObject(attributes)) return false;
191
+ const read = (key: string): unknown => attributes[key];
192
+ const visibility = read("visibility");
193
+ if (options.owned === true && visibility === undefined) return false;
194
+ if (
195
+ options.publishGated === true &&
196
+ !hasArrivedStamp(read("publishedAt"), options.now ?? DateTime.toDate(DateTime.nowUnsafe()))
197
+ ) {
198
+ return false;
199
+ }
200
+ if (visibility !== undefined && visibility !== "public") return false;
201
+ const moderationState = read("moderationState");
202
+ if (moderationState !== undefined && moderationState !== "approved") return false;
203
+ // The draft axis: a draft is a gate wherever it is present, and a present
204
+ // value that is not exactly `false` (`null` included) reads as a draft, as
205
+ // the operation layer's row predicate reads it (SEC-AUTH-ENUM-001).
206
+ const draft = read("draft");
207
+ if (draft !== undefined && draft !== false) return false;
208
+ // A tombstone is an admin-only read, and emitters omit the key while live
209
+ // (#691): present at all — `null` included — refuses.
210
+ return read("deletedAt") === undefined;
211
+ };
212
+
213
+ /**
214
+ * The `serveable` hook the application's resource cache takes, bound to a
215
+ * family's posture: `(resource) => boolean`. The cache, which the application
216
+ * owns (no library is expected to provide one; effect-web#33), applies it on
217
+ * both its read and write paths and evicts on a non-serveable write.
218
+ *
219
+ * @since 0.1.0
220
+ */
221
+ export const serveablePolicy =
222
+ (options: Omit<ServeabilityOptions, "now"> = {}) =>
223
+ (resource: unknown): boolean =>
224
+ isPubliclyServeable(resource, options);
225
+ /* oxlint-enable effect/no-unknown-parameters, effect/no-unknown-returns */
package/src/purge.ts ADDED
@@ -0,0 +1,313 @@
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
+
51
+ import * as Arr from "effect/Array";
52
+ import * as Effect from "effect/Effect";
53
+ import * as Exit from "effect/Exit";
54
+ import * as Option from "effect/Option";
55
+ import { blobKey, type BlobStore, type ContentHash } from "./blobs.ts";
56
+ import type { DocumentHash } from "./documents.ts";
57
+ import {
58
+ Forbidden,
59
+ Unauthorized,
60
+ type ResourceCacheSeam,
61
+ type Viewer,
62
+ type ViewerAccount,
63
+ } from "./subtype.ts";
64
+ import { NodeNotFound, type VersionChainStore } from "./version-chain.ts";
65
+
66
+ // ── The seam ──────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * What {@link PurgeStore.eraseNode} reports: every document hash and every
70
+ * content hash the erased version rows referenced, each once.
71
+ *
72
+ * @since 0.1.0
73
+ */
74
+ export interface ErasedReferences {
75
+ readonly documents: ReadonlyArray<DocumentHash>;
76
+ readonly blobs: ReadonlyArray<ContentHash>;
77
+ }
78
+
79
+ /**
80
+ * The erasure seam an application supplies, over the same tables as its
81
+ * other stores. Like them it applies no policy, fails only with its `E`,
82
+ * and never opens a transaction: {@link definePurge} runs its three methods,
83
+ * in order, inside the caller's `transact`.
84
+ *
85
+ * @since 0.1.0
86
+ */
87
+ export interface PurgeStore<E, R> {
88
+ /**
89
+ * Hard-delete node `id`: every version row on every level of its chain,
90
+ * its identity row and owner row, and every row the kernel's other seams
91
+ * keep for it (working copies, proposals, publication facts, path claims
92
+ * and aliases, reports, and each edge with the node at an end). Returns
93
+ * the document and content hashes the deleted version rows referenced
94
+ * (`DELETE … RETURNING`). An id with no rows deletes nothing and returns
95
+ * none.
96
+ */
97
+ readonly eraseNode: (id: string) => Effect.Effect<ErasedReferences, E, R>;
98
+ /**
99
+ * Delete each document of `hashes` that no version row of any node
100
+ * references any longer, and return the hashes deleted. Runs after
101
+ * {@link PurgeStore.eraseNode} in the same transaction, so the purged
102
+ * node's own references are already gone.
103
+ */
104
+ readonly deleteUnreferencedDocuments: (
105
+ hashes: ReadonlyArray<DocumentHash>,
106
+ ) => Effect.Effect<ReadonlyArray<DocumentHash>, E, R>;
107
+ /**
108
+ * Of `hashes`, the content hashes no version row of any node references
109
+ * any longer: the bytes {@link definePurge} deletes once the transaction
110
+ * has returned. Runs after {@link PurgeStore.eraseNode} in the same
111
+ * transaction.
112
+ */
113
+ readonly unreferencedBlobs: (
114
+ hashes: ReadonlyArray<ContentHash>,
115
+ ) => Effect.Effect<ReadonlyArray<ContentHash>, E, R>;
116
+ }
117
+
118
+ // ── The operation ─────────────────────────────────────────────────────
119
+
120
+ /**
121
+ * What a purge did: the node erased, the documents deleted, the bytes
122
+ * deleted, and the bytes left behind because their delete failed (or no
123
+ * blob store was given), each by hash.
124
+ *
125
+ * @since 0.1.0
126
+ */
127
+ export interface PurgeReport {
128
+ readonly id: string;
129
+ /** The documents deleted: those the node referenced and no other version does. */
130
+ readonly documents: ReadonlyArray<DocumentHash>;
131
+ /** The bytes deleted from the blob store. */
132
+ readonly blobs: ReadonlyArray<ContentHash>;
133
+ /**
134
+ * Unreferenced bytes still in the blob store: their delete failed after
135
+ * the commit, or the definition has no blob store. Delete each with
136
+ * `BlobStore.delete(blobKey(hash))`; nothing references them any more.
137
+ */
138
+ readonly bytesLeft: ReadonlyArray<ContentHash>;
139
+ }
140
+
141
+ /**
142
+ * The operation {@link definePurge} produces.
143
+ *
144
+ * @since 0.1.0
145
+ */
146
+ export interface PurgeOps<E, R> {
147
+ /** The leaf's wire type, as declared. */
148
+ readonly type: string;
149
+ /**
150
+ * Erase node `id`. Fails `Unauthorized` when anonymous and `Forbidden`
151
+ * below the purge rung, for every id alike; at the rung, `NodeNotFound`
152
+ * when `id` has no chain.
153
+ */
154
+ readonly purge: (
155
+ id: string,
156
+ ) => Effect.Effect<PurgeReport, Unauthorized | Forbidden | NodeNotFound | E, R>;
157
+ }
158
+
159
+ /**
160
+ * What {@link definePurge} binds.
161
+ *
162
+ * @since 0.1.0
163
+ */
164
+ export interface PurgeDefinition<E, EB, R> {
165
+ /** The leaf's JSON:API wire type: names spans and tags `NodeNotFound`. */
166
+ readonly type: string;
167
+ /** The leaf's version-chain store: `head` says whether the node exists. */
168
+ readonly chain: VersionChainStore<E, R>;
169
+ /** The erasure seam. */
170
+ readonly store: PurgeStore<E, R>;
171
+ /** The request's viewer (effect-auth's session context). */
172
+ readonly viewer: Effect.Effect<Viewer, never, R>;
173
+ /**
174
+ * The purge rung: whether `account` may erase a node of this leaf. An
175
+ * application rung of its own (an admin, a data-protection officer),
176
+ * which, like the `AccessPolicy` axes, must fail closed on an unknown
177
+ * role.
178
+ */
179
+ readonly canPurge: (account: ViewerAccount) => boolean;
180
+ /** The caller's write boundary; the erasure runs inside it. */
181
+ readonly transact: <A, E2>(effect: Effect.Effect<A, E2, R>) => Effect.Effect<A, E2, R>;
182
+ /**
183
+ * The byte store unreferenced bytes are deleted from, after the commit.
184
+ * Omitted, every unreferenced content hash is reported in
185
+ * {@link PurgeReport.bytesLeft}.
186
+ */
187
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions), so a caller can forward its own `undefined` under `exactOptionalPropertyTypes`; the omitted key is "no blob store"
188
+ readonly blobs?: BlobStore<EB, R> | undefined;
189
+ /**
190
+ * The resource cache, evicted after the commit at the version past the
191
+ * purged head, so no cached copy outlives the erasure (`CACHE-AUTH-005`).
192
+ */
193
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "the leaf is not cached"
194
+ readonly cache?: ResourceCacheSeam<unknown, R> | undefined;
195
+ }
196
+
197
+ /**
198
+ * Bind a leaf's purge. `purge(id)` checks the purge rung, then, inside
199
+ * `transact`, erases the node through the {@link PurgeStore}, deletes the
200
+ * documents nothing else references, and reads which of its bytes nothing
201
+ * else references; once `transact` has returned it evicts the cache and
202
+ * deletes those bytes. A failed byte delete is logged and reported in
203
+ * {@link PurgeReport.bytesLeft}, never a failure.
204
+ *
205
+ * @example
206
+ * ```ts
207
+ * import { deepStrictEqual } from "node:assert";
208
+ * import * as Effect from "effect/Effect";
209
+ * import * as HashMap from "effect/HashMap";
210
+ * import * as Option from "effect/Option";
211
+ * import { defineVersionChain } from "@thomasfosterau/pulpit/version-chain";
212
+ * import { definePurge } from "@thomasfosterau/pulpit/purge";
213
+ * import {
214
+ * makeInMemoryChainStore,
215
+ * makeInMemoryDocumentStore,
216
+ * makeInMemoryPurgeStore,
217
+ * } from "@thomasfosterau/pulpit/testing";
218
+ *
219
+ * const chain = makeInMemoryChainStore();
220
+ * const documents = makeInMemoryDocumentStore();
221
+ * const notes = defineVersionChain("notes", chain);
222
+ * const purge = definePurge({
223
+ * type: "notes",
224
+ * chain,
225
+ * store: makeInMemoryPurgeStore({ chains: [chain], documents, documentColumns: ["body"] }),
226
+ * viewer: Effect.succeed(Option.some({ id: "u-admin", role: "admin" })),
227
+ * canPurge: (account) => account.role === "admin",
228
+ * transact: (effect) => effect,
229
+ * });
230
+ *
231
+ * const report = await Effect.runPromise(
232
+ * Effect.gen(function* () {
233
+ * const { hash } = yield* documents.put({ text: "personal data" });
234
+ * const { id } = yield* notes.create({
235
+ * id: Option.none(),
236
+ * state: { body: hash },
237
+ * actorId: Option.none(),
238
+ * });
239
+ * return yield* purge.purge(id);
240
+ * }),
241
+ * );
242
+ *
243
+ * // The chain and its only document (a hash no other version references) are gone.
244
+ * deepStrictEqual(chain.rows(), []);
245
+ * deepStrictEqual(HashMap.size(documents.stored()), 0);
246
+ * deepStrictEqual(report.documents.length, 1);
247
+ * ```
248
+ *
249
+ * @since 0.1.0
250
+ */
251
+ export const definePurge = <E, EB, R>(definition: PurgeDefinition<E, EB, R>): PurgeOps<E, R> => {
252
+ const { type, chain, store, transact } = definition;
253
+
254
+ /** The purge rung, checked before any row is read. */
255
+ const gate = Effect.flatMap(
256
+ definition.viewer,
257
+ (viewer): Effect.Effect<ViewerAccount, Unauthorized | Forbidden> =>
258
+ Option.match(viewer, {
259
+ onNone: () => Effect.fail(new Unauthorized()),
260
+ onSome: (account) =>
261
+ definition.canPurge(account) ? Effect.succeed(account) : Effect.fail(new Forbidden()),
262
+ }),
263
+ );
264
+
265
+ /** The erasure, in the caller's transaction: what it deleted and which bytes it freed. */
266
+ const erase = (id: string) =>
267
+ Effect.gen(function* () {
268
+ const head = yield* chain.head(id);
269
+ if (Option.isNone(head)) return yield* new NodeNotFound({ type, id });
270
+ const erased = yield* store.eraseNode(id);
271
+ const documents = yield* store.deleteUnreferencedDocuments(Arr.dedupe(erased.documents));
272
+ const freed = yield* store.unreferencedBlobs(Arr.dedupe(erased.blobs));
273
+ return { head: head.value.version, documents, freed };
274
+ });
275
+
276
+ /** Delete one blob's bytes after the commit: whether they are gone. */
277
+ const deleteBytes = (hash: ContentHash) =>
278
+ definition.blobs === undefined
279
+ ? Effect.succeed(false)
280
+ : Effect.exit(definition.blobs.delete(blobKey(hash))).pipe(
281
+ Effect.tap((exit) =>
282
+ Exit.isFailure(exit)
283
+ ? Effect.logWarning(`${type}: bytes ${hash} left after purge`, exit.cause)
284
+ : Effect.void,
285
+ ),
286
+ Effect.map(Exit.isSuccess),
287
+ );
288
+
289
+ const purge = Effect.fn(`${type}.purge`)(function* (id: string) {
290
+ yield* gate;
291
+ const erased = yield* transact(erase(id));
292
+ if (definition.cache !== undefined) yield* definition.cache.delete(id, erased.head + 1n);
293
+ const deleted = yield* Effect.forEach(
294
+ erased.freed,
295
+ (hash) => Effect.map(deleteBytes(hash), (gone) => [hash, gone] as const),
296
+ { concurrency: 1 },
297
+ );
298
+ const gone = (wanted: boolean) =>
299
+ Arr.map(
300
+ Arr.filter(deleted, ([, deletedNow]) => deletedNow === wanted),
301
+ ([hash]) => hash,
302
+ );
303
+ const report: PurgeReport = {
304
+ id,
305
+ documents: erased.documents,
306
+ blobs: gone(true),
307
+ bytesLeft: gone(false),
308
+ };
309
+ return report;
310
+ });
311
+
312
+ return { type, purge };
313
+ };