@thomasfosterau/pulpit 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1271 -1
  3. package/dist/blobs.d.ts +249 -0
  4. package/dist/blobs.d.ts.map +1 -0
  5. package/dist/blobs.js +193 -0
  6. package/dist/blobs.js.map +1 -0
  7. package/dist/budgets.d.ts +255 -0
  8. package/dist/budgets.d.ts.map +1 -0
  9. package/dist/budgets.js +211 -0
  10. package/dist/budgets.js.map +1 -0
  11. package/dist/cache.d.ts +154 -0
  12. package/dist/cache.d.ts.map +1 -0
  13. package/dist/cache.js +118 -0
  14. package/dist/cache.js.map +1 -0
  15. package/dist/collections.d.ts +242 -0
  16. package/dist/collections.d.ts.map +1 -0
  17. package/dist/collections.js +215 -0
  18. package/dist/collections.js.map +1 -0
  19. package/dist/comments.d.ts +212 -0
  20. package/dist/comments.d.ts.map +1 -0
  21. package/dist/comments.js +192 -0
  22. package/dist/comments.js.map +1 -0
  23. package/dist/content.d.ts +231 -0
  24. package/dist/content.d.ts.map +1 -0
  25. package/dist/content.js +204 -0
  26. package/dist/content.js.map +1 -0
  27. package/dist/documents.d.ts +184 -0
  28. package/dist/documents.d.ts.map +1 -0
  29. package/dist/documents.js +190 -0
  30. package/dist/documents.js.map +1 -0
  31. package/dist/edges.d.ts +645 -0
  32. package/dist/edges.d.ts.map +1 -0
  33. package/dist/edges.js +558 -0
  34. package/dist/edges.js.map +1 -0
  35. package/dist/filter.d.ts +461 -0
  36. package/dist/filter.d.ts.map +1 -0
  37. package/dist/filter.js +387 -0
  38. package/dist/filter.js.map +1 -0
  39. package/dist/index.d.ts +133 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +133 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/internal/filter-columns.d.ts +8 -0
  44. package/dist/internal/filter-columns.d.ts.map +1 -0
  45. package/dist/internal/filter-columns.js +43 -0
  46. package/dist/internal/filter-columns.js.map +1 -0
  47. package/dist/internal/list-order.d.ts +26 -0
  48. package/dist/internal/list-order.d.ts.map +1 -0
  49. package/dist/internal/list-order.js +92 -0
  50. package/dist/internal/list-order.js.map +1 -0
  51. package/dist/internal/owned-columns.d.ts +93 -0
  52. package/dist/internal/owned-columns.d.ts.map +1 -0
  53. package/dist/internal/owned-columns.js +98 -0
  54. package/dist/internal/owned-columns.js.map +1 -0
  55. package/dist/internal/plain-rows.d.ts +32 -0
  56. package/dist/internal/plain-rows.d.ts.map +1 -0
  57. package/dist/internal/plain-rows.js +32 -0
  58. package/dist/internal/plain-rows.js.map +1 -0
  59. package/dist/internal/reference-columns.d.ts +80 -0
  60. package/dist/internal/reference-columns.d.ts.map +1 -0
  61. package/dist/internal/reference-columns.js +102 -0
  62. package/dist/internal/reference-columns.js.map +1 -0
  63. package/dist/moderation.d.ts +392 -0
  64. package/dist/moderation.d.ts.map +1 -0
  65. package/dist/moderation.js +375 -0
  66. package/dist/moderation.js.map +1 -0
  67. package/dist/paths.d.ts +204 -0
  68. package/dist/paths.d.ts.map +1 -0
  69. package/dist/paths.js +165 -0
  70. package/dist/paths.js.map +1 -0
  71. package/dist/proposals.d.ts +523 -0
  72. package/dist/proposals.d.ts.map +1 -0
  73. package/dist/proposals.js +505 -0
  74. package/dist/proposals.js.map +1 -0
  75. package/dist/publication.d.ts +148 -0
  76. package/dist/publication.d.ts.map +1 -0
  77. package/dist/publication.js +183 -0
  78. package/dist/publication.js.map +1 -0
  79. package/dist/purge.d.ts +224 -0
  80. package/dist/purge.d.ts.map +1 -0
  81. package/dist/purge.js +151 -0
  82. package/dist/purge.js.map +1 -0
  83. package/dist/reactions.d.ts +246 -0
  84. package/dist/reactions.d.ts.map +1 -0
  85. package/dist/reactions.js +183 -0
  86. package/dist/reactions.js.map +1 -0
  87. package/dist/registry.d.ts +316 -0
  88. package/dist/registry.d.ts.map +1 -0
  89. package/dist/registry.js +305 -0
  90. package/dist/registry.js.map +1 -0
  91. package/dist/search.d.ts +366 -0
  92. package/dist/search.d.ts.map +1 -0
  93. package/dist/search.js +273 -0
  94. package/dist/search.js.map +1 -0
  95. package/dist/social.d.ts +311 -0
  96. package/dist/social.d.ts.map +1 -0
  97. package/dist/social.js +205 -0
  98. package/dist/social.js.map +1 -0
  99. package/dist/subtype.d.ts +1613 -0
  100. package/dist/subtype.d.ts.map +1 -0
  101. package/dist/subtype.js +1300 -0
  102. package/dist/subtype.js.map +1 -0
  103. package/dist/testing.d.ts +675 -0
  104. package/dist/testing.d.ts.map +1 -0
  105. package/dist/testing.js +1022 -0
  106. package/dist/testing.js.map +1 -0
  107. package/dist/version-chain.d.ts +702 -0
  108. package/dist/version-chain.d.ts.map +1 -0
  109. package/dist/version-chain.js +489 -0
  110. package/dist/version-chain.js.map +1 -0
  111. package/dist/working-copy.d.ts +116 -0
  112. package/dist/working-copy.d.ts.map +1 -0
  113. package/dist/working-copy.js +95 -0
  114. package/dist/working-copy.js.map +1 -0
  115. package/package.json +138 -4
  116. package/src/blobs.ts +384 -0
  117. package/src/budgets.ts +338 -0
  118. package/src/cache.ts +218 -0
  119. package/src/collections.ts +427 -0
  120. package/src/comments.ts +392 -0
  121. package/src/content.ts +238 -0
  122. package/src/documents.ts +284 -0
  123. package/src/edges.ts +1185 -0
  124. package/src/filter.ts +523 -0
  125. package/src/index.ts +153 -0
  126. package/src/internal/filter-columns.ts +50 -0
  127. package/src/internal/list-order.ts +117 -0
  128. package/src/internal/owned-columns.ts +140 -0
  129. package/src/internal/plain-rows.ts +54 -0
  130. package/src/internal/reference-columns.ts +156 -0
  131. package/src/moderation.ts +728 -0
  132. package/src/paths.ts +267 -0
  133. package/src/proposals.ts +1014 -0
  134. package/src/publication.ts +225 -0
  135. package/src/purge.ts +313 -0
  136. package/src/reactions.ts +347 -0
  137. package/src/registry.ts +524 -0
  138. package/src/search.ts +540 -0
  139. package/src/social.ts +469 -0
  140. package/src/subtype.ts +3095 -0
  141. package/src/testing.ts +1873 -0
  142. package/src/version-chain.ts +1093 -0
  143. package/src/working-copy.ts +192 -0
@@ -0,0 +1,284 @@
1
+ /**
2
+ * **Content-addressed documents** — the store seam a version row's document
3
+ * reference resolves through, the hash it is keyed by, and the field spread
4
+ * that carries the reference.
5
+ *
6
+ * A document (a ProseKit tree, in the apps) is stored once, keyed by a hash of
7
+ * its content, and a version row carries the hash rather than another full
8
+ * copy. Under an append-only chain that matters: history is never pruned
9
+ * (`VERSION-CHAIN-001`), so without content addressing every version of a long
10
+ * document would store the whole document again. The behavioural spec is
11
+ * `packages/database/src/documents.ts` in gardeneria and website (the
12
+ * `inline_documents` / `block_documents` writers) and the generated `hash_id`
13
+ * column of those tables.
14
+ *
15
+ * ## The invariant
16
+ *
17
+ * - **A stored document is never rewritten** (`DOC-001`). `put` stores a
18
+ * document only when its hash is unseen and otherwise writes nothing; a
19
+ * changed document is a new hash. So a hash a version row recorded always
20
+ * reads back the exact document it was written with, and `restoreVersion`
21
+ * restores the reference, never a copy. Only erasure (`./purge`) deletes a
22
+ * document, once no version of any node references its hash.
23
+ *
24
+ * ## The seam
25
+ *
26
+ * Hashing is the store's job, not the kernel's: the apps derive the hash in
27
+ * Postgres and never re-derive it in JavaScript. The algorithm and the
28
+ * canonical serialisation a store must use are stated in the package README →
29
+ * Seams; {@link canonicalText} is that serialisation in JavaScript, for a
30
+ * store that doesn't run on Postgres. The kernel itself never hashes. The
31
+ * document type is a parameter; its schema and validation are the
32
+ * application's (prosekit-utilities), and pulpit never imports them.
33
+ *
34
+ * @copyright Thomas Foster
35
+ * @module pulpit/documents
36
+ * @since 0.1.0
37
+ */
38
+
39
+ import { Resource } from "@thomasfosterau/effect-jsonapi";
40
+ import * as Arr from "effect/Array";
41
+ import * as Effect from "effect/Effect";
42
+ import type * as HashMap from "effect/HashMap";
43
+ import type * as Option from "effect/Option";
44
+ import * as Order from "effect/Order";
45
+ import * as Predicate from "effect/Predicate";
46
+ import * as Record from "effect/Record";
47
+ import * as Schema from "effect/Schema";
48
+
49
+ // ── Vocabulary ────────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * A document's content hash as the kernel exchanges it: the 32-byte SHA-256
53
+ * digest as 64 lowercase hexadecimal characters (what gardeneria's
54
+ * `byteaToHex` renders a `bytea` hash as). A Postgres store keeps the 32 bytes
55
+ * in a `bytea` column and converts at its edge. The algorithm and the bytes it
56
+ * is taken over are the contract in the package README → Seams.
57
+ *
58
+ * @since 0.1.0
59
+ */
60
+ export const DocumentHash = Schema.String.check(
61
+ Schema.isPattern(/^[0-9a-f]{64}$/, {
62
+ description: "a SHA-256 document hash as 64 lowercase hexadecimal characters",
63
+ }),
64
+ );
65
+ /**
66
+ * The {@link DocumentHash} string: 64 lowercase hexadecimal characters.
67
+ *
68
+ * @since 0.1.0
69
+ */
70
+ export type DocumentHash = typeof DocumentHash.Type;
71
+
72
+ // ── The seam ──────────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * What {@link DocumentStore.put} returns: the document's hash, and whether
76
+ * this call inserted it (`false` for a document already stored).
77
+ *
78
+ * @since 0.1.0
79
+ */
80
+ export interface DocumentPut {
81
+ readonly hash: DocumentHash;
82
+ readonly inserted: boolean;
83
+ }
84
+
85
+ /**
86
+ * The content-addressed document store an application supplies (in
87
+ * production, a table per document family, keyed by the hash). `Document`
88
+ * is the application's document type; `E` is the store's own failure and `R`
89
+ * its requirements (a Drizzle store's `EffectDrizzleQueryError` and
90
+ * `CurrentTransaction`: like the chain store, it never opens a transaction).
91
+ *
92
+ * Every method fails only with the store's `E`, and applies no policy: a
93
+ * document is validated against the application's schema before it reaches
94
+ * `put`. Honours `DOC-001`: `put` never rewrites a stored document.
95
+ *
96
+ * @since 0.1.0
97
+ */
98
+ export interface DocumentStore<Document, E, R> {
99
+ /**
100
+ * Store `document` if its hash is unseen and return the hash either way
101
+ * (`INSERT … ON CONFLICT DO NOTHING RETURNING`, then the hash), with
102
+ * `inserted: true` only when this call wrote the row (the `RETURNING`
103
+ * gave one back). Idempotent: a document already stored, or one equal to
104
+ * it under the canonical serialisation, writes nothing and returns the
105
+ * same hash with `inserted: false`. The hash is the store's to derive
106
+ * (README → Seams).
107
+ */
108
+ readonly put: (document: Document) => Effect.Effect<DocumentPut, E, R>;
109
+ /** The document stored at `hash`, or none. */
110
+ readonly get: (hash: DocumentHash) => Effect.Effect<Option.Option<Document>, E, R>;
111
+ /**
112
+ * One `hash = ANY($hashes)` read, never `hashes.length` sequential gets: the
113
+ * documents found, keyed by hash. A hash with no document is absent from
114
+ * the map.
115
+ */
116
+ readonly getMany: (
117
+ hashes: ReadonlyArray<DocumentHash>,
118
+ ) => Effect.Effect<HashMap.HashMap<DocumentHash, Document>, E, R>;
119
+ }
120
+
121
+ // ── The first-insert event ────────────────────────────────────────────
122
+
123
+ /**
124
+ * What an {@link onDocumentInsert} hook is shown: a document this `put`
125
+ * stored for the first time, and its hash.
126
+ *
127
+ * @since 0.1.0
128
+ */
129
+ export interface DocumentInserted<Document> {
130
+ readonly hash: DocumentHash;
131
+ readonly document: Document;
132
+ }
133
+
134
+ /**
135
+ * The document store with a first-insert hook: its `put` runs `hook` after
136
+ * a `put` that inserted (`inserted: true`), and never after one that found
137
+ * the document already stored, so the hook runs once per document (an
138
+ * embedding job, in the apps). The hook runs in the `put`'s own
139
+ * transaction, so a failure (the store's `E`) fails the `put` and rolls
140
+ * the insert back with the caller's transaction; enqueue durable work as a
141
+ * row the same transaction writes (an outbox). `get` and `getMany` are the
142
+ * store's own.
143
+ *
144
+ * @example
145
+ * ```ts
146
+ * import { deepStrictEqual } from "node:assert";
147
+ * import * as Effect from "effect/Effect";
148
+ * import { onDocumentInsert } from "@thomasfosterau/pulpit/documents";
149
+ * import { makeInMemoryDocumentStore } from "@thomasfosterau/pulpit/testing";
150
+ *
151
+ * const inserted: Array<string> = [];
152
+ * const store = onDocumentInsert(makeInMemoryDocumentStore<{ readonly text: string }>(), (event) =>
153
+ * Effect.sync(() => {
154
+ * inserted.push(event.document.text);
155
+ * }),
156
+ * );
157
+ * await Effect.runPromise(Effect.andThen(store.put({ text: "hello" }), store.put({ text: "hello" })));
158
+ * deepStrictEqual(inserted, ["hello"]);
159
+ * ```
160
+ *
161
+ * @since 0.1.0
162
+ */
163
+ export const onDocumentInsert = <Document, E, R>(
164
+ store: DocumentStore<Document, E, R>,
165
+ hook: (event: DocumentInserted<Document>) => Effect.Effect<void, E, R>,
166
+ ): DocumentStore<Document, E, R> => ({
167
+ ...store,
168
+ put: (document) =>
169
+ Effect.tap(store.put(document), ({ hash, inserted }) =>
170
+ inserted ? hook({ hash, document }) : Effect.void,
171
+ ),
172
+ });
173
+
174
+ // ── The hash contract ─────────────────────────────────────────────────
175
+
176
+ const utf8 = new TextEncoder();
177
+
178
+ /** A string's UTF-8 bytes as one character per byte, so that string order is byte order. */
179
+ const byteString = (value: string): string => String.fromCharCode(...utf8.encode(value));
180
+
181
+ /** jsonb's key order: shorter UTF-8 byte length first, then bytewise. */
182
+ const keyOrder: Order.Order<string> = Order.combine(
183
+ Order.mapInput(Order.Number, (key: string) => utf8.encode(key).length),
184
+ Order.mapInput(Order.String, byteString),
185
+ );
186
+
187
+ /**
188
+ * A number as Postgres `numeric` prints it: JavaScript's shortest round-trip
189
+ * digits, written positionally (`1e+21` → `1000000000000000000000`,
190
+ * `1.5e-7` → `0.00000015`), since `numeric` has no exponent form.
191
+ */
192
+ const numericText = (value: number): string => {
193
+ const shortest = String(value);
194
+ const match = /^(-?)(\d)(?:\.(\d+))?e([+-]\d+)$/.exec(shortest);
195
+ if (match === null) return shortest;
196
+ const [, sign = "", lead = "", fraction = "", exponent = "0"] = match;
197
+ const digits = lead + fraction;
198
+ const point = 1 + Number(exponent);
199
+ if (point >= digits.length) return sign + digits + "0".repeat(point - digits.length);
200
+ if (point <= 0) return `${sign}0.${"0".repeat(-point)}${digits}`;
201
+ return `${sign}${digits.slice(0, point)}.${digits.slice(point)}`;
202
+ };
203
+
204
+ /** The short escapes jsonb's `escape_json` writes; every other control character is `\u00xx`. */
205
+ const SHORT_ESCAPES: Record.ReadonlyRecord<string, string> = {
206
+ '"': '\\"',
207
+ "\\": "\\\\",
208
+ "\b": "\\b",
209
+ "\f": "\\f",
210
+ "\n": "\\n",
211
+ "\r": "\\r",
212
+ "\t": "\\t",
213
+ };
214
+
215
+ /** One character as jsonb's `escape_json` writes it: a short escape, `\u00xx` below U+0020, else itself. */
216
+ const escapeCharacter = (character: string): string => {
217
+ const code = character.charCodeAt(0);
218
+ return (
219
+ SHORT_ESCAPES[character] ??
220
+ (code < 0x20 ? `\\u${code.toString(16).padStart(4, "0")}` : character)
221
+ );
222
+ };
223
+
224
+ /** A string literal as jsonb prints it: `"`, `\` and the control characters escaped, nothing else. */
225
+ const stringText = (value: string): string =>
226
+ `"${Arr.map(Arr.fromIterable(value), escapeCharacter).join("")}"`;
227
+
228
+ /**
229
+ * The canonical text of a JSON document, the bytes the {@link DocumentStore}
230
+ * hash contract (README → Seams → `DocumentStore`) takes SHA-256 over:
231
+ * PostgreSQL's `jsonb` text output, with object keys ordered shorter-first by
232
+ * UTF-8 byte length and then bytewise, `": "` after a key, `", "` between
233
+ * members and elements, only `"`, `\` and the control characters escaped, and
234
+ * numbers as `numeric` prints them (never with an exponent). A Postgres store
235
+ * never needs it (the database derives the hash from `document_jsonb::text`);
236
+ * a store that doesn't run on Postgres hashes the UTF-8 bytes of this text to
237
+ * reproduce the contract's hashes. The kernel's suite pins it to text and
238
+ * hashes taken from Postgres. Pure and total.
239
+ *
240
+ * @example
241
+ * ```ts
242
+ * import { strictEqual } from "node:assert";
243
+ * import { canonicalText } from "@thomasfosterau/pulpit/documents";
244
+ *
245
+ * strictEqual(
246
+ * canonicalText({ b: 1e21, aa: [true, null], a: "é" }),
247
+ * '{"a": "é", "b": 1000000000000000000000, "aa": [true, null]}',
248
+ * );
249
+ * ```
250
+ *
251
+ * @since 0.1.0
252
+ */
253
+ export const canonicalText = (value: Schema.Json): string => {
254
+ if (value === null) return "null";
255
+ if (Predicate.isBoolean(value)) return value ? "true" : "false";
256
+ if (Predicate.isNumber(value)) return numericText(value);
257
+ if (Predicate.isString(value)) return stringText(value);
258
+ if (Arr.isArray<Schema.Json>(value)) return `[${value.map(canonicalText).join(", ")}]`;
259
+ const members = Arr.sort(
260
+ Record.toEntries(value),
261
+ Order.mapInput(keyOrder, ([key]: readonly [string, Schema.Json]) => key),
262
+ );
263
+ return `{${members.map(([key, member]) => `${stringText(key)}: ${canonicalText(member)}`).join(", ")}}`;
264
+ };
265
+
266
+ // ── The field spread ──────────────────────────────────────────────────
267
+
268
+ /**
269
+ * The attribute spread for a version row's document reference: `key` carries
270
+ * the {@link DocumentHash} a {@link DocumentStore} returned from `put` (its `hash`).
271
+ * Required on the resource and at create; optional at update, where an absent
272
+ * key carries the previous version's reference forward (the tri-state rule),
273
+ * and never cleared. Spread into a leaf's `Resource.make({ attributes })`,
274
+ * once per document column (`...documentRef("title")`,
275
+ * `...documentRef("body")`).
276
+ *
277
+ * A document the leaf may omit is declared directly, as
278
+ * `Resource.attribute(Schema.NullOr(DocumentHash), { create: "optional" })`,
279
+ * so that `null` clears it.
280
+ *
281
+ * @since 0.1.0
282
+ */
283
+ export const documentRef = <const Key extends string>(key: Key) =>
284
+ Record.singleton(key, Resource.attribute(DocumentHash));