@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.
- package/LICENSE +21 -0
- package/README.md +1272 -2
- package/dist/blobs.d.ts +249 -0
- package/dist/blobs.d.ts.map +1 -0
- package/dist/blobs.js +193 -0
- package/dist/blobs.js.map +1 -0
- package/dist/budgets.d.ts +255 -0
- package/dist/budgets.d.ts.map +1 -0
- package/dist/budgets.js +211 -0
- package/dist/budgets.js.map +1 -0
- package/dist/cache.d.ts +154 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +118 -0
- package/dist/cache.js.map +1 -0
- package/dist/collections.d.ts +242 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +215 -0
- package/dist/collections.js.map +1 -0
- package/dist/comments.d.ts +212 -0
- package/dist/comments.d.ts.map +1 -0
- package/dist/comments.js +192 -0
- package/dist/comments.js.map +1 -0
- package/dist/content.d.ts +231 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +204 -0
- package/dist/content.js.map +1 -0
- package/dist/documents.d.ts +184 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +190 -0
- package/dist/documents.js.map +1 -0
- package/dist/edges.d.ts +645 -0
- package/dist/edges.d.ts.map +1 -0
- package/dist/edges.js +558 -0
- package/dist/edges.js.map +1 -0
- package/dist/filter.d.ts +461 -0
- package/dist/filter.d.ts.map +1 -0
- package/dist/filter.js +387 -0
- package/dist/filter.js.map +1 -0
- package/dist/index.d.ts +133 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +133 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/filter-columns.d.ts +8 -0
- package/dist/internal/filter-columns.d.ts.map +1 -0
- package/dist/internal/filter-columns.js +43 -0
- package/dist/internal/filter-columns.js.map +1 -0
- package/dist/internal/list-order.d.ts +26 -0
- package/dist/internal/list-order.d.ts.map +1 -0
- package/dist/internal/list-order.js +92 -0
- package/dist/internal/list-order.js.map +1 -0
- package/dist/internal/owned-columns.d.ts +93 -0
- package/dist/internal/owned-columns.d.ts.map +1 -0
- package/dist/internal/owned-columns.js +98 -0
- package/dist/internal/owned-columns.js.map +1 -0
- package/dist/internal/plain-rows.d.ts +32 -0
- package/dist/internal/plain-rows.d.ts.map +1 -0
- package/dist/internal/plain-rows.js +32 -0
- package/dist/internal/plain-rows.js.map +1 -0
- package/dist/internal/reference-columns.d.ts +80 -0
- package/dist/internal/reference-columns.d.ts.map +1 -0
- package/dist/internal/reference-columns.js +102 -0
- package/dist/internal/reference-columns.js.map +1 -0
- package/dist/moderation.d.ts +392 -0
- package/dist/moderation.d.ts.map +1 -0
- package/dist/moderation.js +375 -0
- package/dist/moderation.js.map +1 -0
- package/dist/paths.d.ts +204 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +165 -0
- package/dist/paths.js.map +1 -0
- package/dist/proposals.d.ts +523 -0
- package/dist/proposals.d.ts.map +1 -0
- package/dist/proposals.js +505 -0
- package/dist/proposals.js.map +1 -0
- package/dist/publication.d.ts +148 -0
- package/dist/publication.d.ts.map +1 -0
- package/dist/publication.js +183 -0
- package/dist/publication.js.map +1 -0
- package/dist/purge.d.ts +224 -0
- package/dist/purge.d.ts.map +1 -0
- package/dist/purge.js +151 -0
- package/dist/purge.js.map +1 -0
- package/dist/reactions.d.ts +246 -0
- package/dist/reactions.d.ts.map +1 -0
- package/dist/reactions.js +183 -0
- package/dist/reactions.js.map +1 -0
- package/dist/registry.d.ts +316 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +305 -0
- package/dist/registry.js.map +1 -0
- package/dist/search.d.ts +366 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +273 -0
- package/dist/search.js.map +1 -0
- package/dist/social.d.ts +311 -0
- package/dist/social.d.ts.map +1 -0
- package/dist/social.js +205 -0
- package/dist/social.js.map +1 -0
- package/dist/subtype.d.ts +1613 -0
- package/dist/subtype.d.ts.map +1 -0
- package/dist/subtype.js +1300 -0
- package/dist/subtype.js.map +1 -0
- package/dist/testing.d.ts +675 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +1022 -0
- package/dist/testing.js.map +1 -0
- package/dist/version-chain.d.ts +702 -0
- package/dist/version-chain.d.ts.map +1 -0
- package/dist/version-chain.js +489 -0
- package/dist/version-chain.js.map +1 -0
- package/dist/working-copy.d.ts +116 -0
- package/dist/working-copy.d.ts.map +1 -0
- package/dist/working-copy.js +95 -0
- package/dist/working-copy.js.map +1 -0
- package/package.json +145 -3
- package/src/blobs.ts +384 -0
- package/src/budgets.ts +338 -0
- package/src/cache.ts +218 -0
- package/src/collections.ts +427 -0
- package/src/comments.ts +392 -0
- package/src/content.ts +238 -0
- package/src/documents.ts +284 -0
- package/src/edges.ts +1185 -0
- package/src/filter.ts +523 -0
- package/src/index.ts +153 -0
- package/src/internal/filter-columns.ts +50 -0
- package/src/internal/list-order.ts +117 -0
- package/src/internal/owned-columns.ts +140 -0
- package/src/internal/plain-rows.ts +54 -0
- package/src/internal/reference-columns.ts +156 -0
- package/src/moderation.ts +728 -0
- package/src/paths.ts +267 -0
- package/src/proposals.ts +1014 -0
- package/src/publication.ts +225 -0
- package/src/purge.ts +313 -0
- package/src/reactions.ts +347 -0
- package/src/registry.ts +524 -0
- package/src/search.ts +540 -0
- package/src/social.ts +469 -0
- package/src/subtype.ts +3095 -0
- package/src/testing.ts +1873 -0
- package/src/version-chain.ts +1093 -0
- 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
|
+
};
|