@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,95 @@
1
+ /**
2
+ * **The working copy** — an editor's autosave buffer, kept outside the
3
+ * version chain and overwritten freely, which an explicit save appends as
4
+ * one ordinary `update` version.
5
+ *
6
+ * ## The model (owner decisions on pulpit#46)
7
+ *
8
+ * - **Nothing in a working copy is a version**: saving it writes only the
9
+ * {@link WorkingCopyStore}, so `VERSION-CHAIN-001` is untouched and a
10
+ * hundred autosaves leave no trace in history.
11
+ * - **One copy per (node, editor)**, so two editors never overwrite each
12
+ * other's unsaved work.
13
+ * - **`commit` appends the copy** through the leaf's own `update`, expecting
14
+ * the head the copy was first based on: an edit landing in between fails
15
+ * `StaleVersion` (and keeps the copy), so a commit never silently
16
+ * overwrites work its editor didn't see. A committed copy is cleared.
17
+ * - **Every operation is gated like a write** (the leaf's `authorize`):
18
+ * reading a copy shows unpublished state, so it is gated like the head.
19
+ *
20
+ * @copyright Thomas Foster
21
+ * @module pulpit/working-copy
22
+ * @since 0.1.0
23
+ */
24
+ import * as Clock from "effect/Clock";
25
+ import * as DateTime from "effect/DateTime";
26
+ import * as Effect from "effect/Effect";
27
+ import * as Option from "effect/Option";
28
+ import * as Record from "effect/Record";
29
+ import * as Schema from "effect/Schema";
30
+ import { NodeNotFound, } from "./version-chain.js";
31
+ /**
32
+ * No working copy to commit: the wire's 404.
33
+ *
34
+ * @since 0.1.0
35
+ */
36
+ export class WorkingCopyNotFound extends Schema.TaggedError()("WorkingCopyNotFound", { type: Schema.String, id: Schema.String }) {
37
+ }
38
+ const nowDate = Effect.map(Clock.currentTimeMillis, (millis) => DateTime.toDate(DateTime.makeUnsafe(millis)));
39
+ /**
40
+ * Bind a leaf's working copies: `leaf` gates and commits, `chain` reads
41
+ * the head a new copy is based on, `store` keeps the copies.
42
+ *
43
+ * @since 0.1.0
44
+ */
45
+ export const defineWorkingCopy = (definition) => {
46
+ const { leaf, chain, store } = definition;
47
+ const type = leaf.type;
48
+ const keyFor = (id) => Effect.map(leaf.authorize(id), (account) => ({ id, userId: account.id }));
49
+ const save = Effect.fn(`${type}.workingCopy.save`)(function* (id, patch) {
50
+ const key = yield* keyFor(id);
51
+ const head = yield* chain.head(id);
52
+ if (Option.isNone(head) || head.value.action === "delete") {
53
+ return yield* new NodeNotFound({ type, id });
54
+ }
55
+ const earlier = yield* store.get(key);
56
+ const copy = {
57
+ patch: Option.match(earlier, {
58
+ onNone: () => patch,
59
+ // An absent (or `undefined`) key in the new patch keeps the copy's own.
60
+ onSome: (existing) => ({
61
+ ...existing.patch,
62
+ ...Record.filter(patch, (value) => value !== undefined),
63
+ }),
64
+ }),
65
+ baseVersion: Option.match(earlier, {
66
+ onNone: () => head.value.version,
67
+ onSome: (existing) => existing.baseVersion,
68
+ }),
69
+ savedAt: yield* nowDate,
70
+ };
71
+ yield* store.put(key, copy);
72
+ return copy;
73
+ });
74
+ const get = Effect.fn(`${type}.workingCopy.get`)(function* (id) {
75
+ return yield* store.get(yield* keyFor(id));
76
+ });
77
+ const discard = Effect.fn(`${type}.workingCopy.discard`)(function* (id) {
78
+ yield* store.discard(yield* keyFor(id));
79
+ });
80
+ const commit = Effect.fn(`${type}.workingCopy.commit`)(function* (id) {
81
+ const key = yield* keyFor(id);
82
+ const copy = yield* store.get(key);
83
+ if (Option.isNone(copy))
84
+ return yield* new WorkingCopyNotFound({ type, id });
85
+ const written = yield* leaf.update({
86
+ id,
87
+ patch: copy.value.patch,
88
+ expectedVersion: copy.value.baseVersion,
89
+ });
90
+ yield* store.discard(key);
91
+ return written;
92
+ });
93
+ return { save, get, discard, commit };
94
+ };
95
+ //# sourceMappingURL=working-copy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"working-copy.js","sourceRoot":"","sources":["../src/working-copy.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,14 +1,148 @@
1
1
  {
2
2
  "name": "@thomasfosterau/pulpit",
3
- "version": "0.0.0",
4
- "description": "Placeholder reserved for npm trusted publishing; the first real release of @thomasfosterau/pulpit is 0.1.0.",
3
+ "version": "0.1.0",
4
+ "description": "The versioned-content kernel for an Effect headless CMS: Content model fields and registry flags on effect-jsonapi, the append-only node/version chain operations, the subtype operation factory with owned/reference access, and the publication / moderation / cache-serveability predicates.",
5
+ "keywords": [
6
+ "cms",
7
+ "effect",
8
+ "effect-schema",
9
+ "headless-cms",
10
+ "json-api",
11
+ "jsonapi",
12
+ "typescript",
13
+ "versioning"
14
+ ],
15
+ "homepage": "https://github.com/thomasfosterau/pulpit#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/thomasfosterau/pulpit/issues"
18
+ },
5
19
  "license": "MIT",
20
+ "author": "Thomas Foster <me@thomasfoster.au>",
6
21
  "repository": {
7
22
  "type": "git",
8
- "url": "git+https://github.com/thomasfosterau/pulpit.git",
23
+ "url": "https://github.com/thomasfosterau/pulpit.git",
9
24
  "directory": "packages/pulpit"
10
25
  },
26
+ "files": [
27
+ "dist",
28
+ "src",
29
+ "!src/**/*.test.ts",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "type": "module",
34
+ "sideEffects": false,
35
+ "exports": {
36
+ ".": {
37
+ "types": "./dist/index.d.ts",
38
+ "default": "./dist/index.js"
39
+ },
40
+ "./content": {
41
+ "types": "./dist/content.d.ts",
42
+ "default": "./dist/content.js"
43
+ },
44
+ "./registry": {
45
+ "types": "./dist/registry.d.ts",
46
+ "default": "./dist/registry.js"
47
+ },
48
+ "./version-chain": {
49
+ "types": "./dist/version-chain.d.ts",
50
+ "default": "./dist/version-chain.js"
51
+ },
52
+ "./filter": {
53
+ "types": "./dist/filter.d.ts",
54
+ "default": "./dist/filter.js"
55
+ },
56
+ "./subtype": {
57
+ "types": "./dist/subtype.d.ts",
58
+ "default": "./dist/subtype.js"
59
+ },
60
+ "./publication": {
61
+ "types": "./dist/publication.d.ts",
62
+ "default": "./dist/publication.js"
63
+ },
64
+ "./cache": {
65
+ "types": "./dist/cache.d.ts",
66
+ "default": "./dist/cache.js"
67
+ },
68
+ "./documents": {
69
+ "types": "./dist/documents.d.ts",
70
+ "default": "./dist/documents.js"
71
+ },
72
+ "./edges": {
73
+ "types": "./dist/edges.d.ts",
74
+ "default": "./dist/edges.js"
75
+ },
76
+ "./proposals": {
77
+ "types": "./dist/proposals.d.ts",
78
+ "default": "./dist/proposals.js"
79
+ },
80
+ "./blobs": {
81
+ "types": "./dist/blobs.d.ts",
82
+ "default": "./dist/blobs.js"
83
+ },
84
+ "./paths": {
85
+ "types": "./dist/paths.d.ts",
86
+ "default": "./dist/paths.js"
87
+ },
88
+ "./collections": {
89
+ "types": "./dist/collections.d.ts",
90
+ "default": "./dist/collections.js"
91
+ },
92
+ "./moderation": {
93
+ "types": "./dist/moderation.d.ts",
94
+ "default": "./dist/moderation.js"
95
+ },
96
+ "./working-copy": {
97
+ "types": "./dist/working-copy.d.ts",
98
+ "default": "./dist/working-copy.js"
99
+ },
100
+ "./comments": {
101
+ "types": "./dist/comments.d.ts",
102
+ "default": "./dist/comments.js"
103
+ },
104
+ "./purge": {
105
+ "types": "./dist/purge.d.ts",
106
+ "default": "./dist/purge.js"
107
+ },
108
+ "./search": {
109
+ "types": "./dist/search.d.ts",
110
+ "default": "./dist/search.js"
111
+ },
112
+ "./budgets": {
113
+ "types": "./dist/budgets.d.ts",
114
+ "default": "./dist/budgets.js"
115
+ },
116
+ "./reactions": {
117
+ "types": "./dist/reactions.d.ts",
118
+ "default": "./dist/reactions.js"
119
+ },
120
+ "./social": {
121
+ "types": "./dist/social.d.ts",
122
+ "default": "./dist/social.js"
123
+ },
124
+ "./testing": {
125
+ "types": "./dist/testing.d.ts",
126
+ "default": "./dist/testing.js"
127
+ }
128
+ },
11
129
  "publishConfig": {
12
130
  "access": "public"
131
+ },
132
+ "devDependencies": {
133
+ "@effect/docgen": "^4.1.2",
134
+ "@effect/language-service": "^0.87.5",
135
+ "@thomasfosterau/effect-jsonapi": "^0.20.0",
136
+ "@types/node": "^26.6.6",
137
+ "@vitest/coverage-istanbul": "5.0.5",
138
+ "effect": "4.1.2",
139
+ "tsx": "^4.23.13"
140
+ },
141
+ "peerDependencies": {
142
+ "@thomasfosterau/effect-jsonapi": ">=0.17.0",
143
+ "effect": "^4.0.0"
144
+ },
145
+ "engines": {
146
+ "node": ">=22"
13
147
  }
14
- }
148
+ }
package/src/blobs.ts ADDED
@@ -0,0 +1,384 @@
1
+ /**
2
+ * **File and media leaves** — the blob-store seam a file leaf's bytes live
3
+ * behind, the field spread a file version carries, the commit-ordered byte
4
+ * write, and the usage index as an edge family.
5
+ *
6
+ * The behavioural spec is gardeneria's `apps/api/src/operations/media.ts`
7
+ * (bytes written only after the transaction commits) and `apps/api/src/files.ts`
8
+ * (the content hash), and website's `apps/api/src/operations/file-chain.ts`.
9
+ *
10
+ * ## The model (owner decisions on pulpit#55)
11
+ *
12
+ * - **Bytes are keyed by their content hash** ({@link blobKey}), so a
13
+ * version's bytes are immutable: new bytes are a new hash and a new
14
+ * version, and `restoreVersion` restores the reference to the old bytes,
15
+ * which are still there. Equal bytes are stored once.
16
+ * - **Bytes are written after the chain commits.** {@link defineFileWrites}
17
+ * appends the version first, marked `bytesPending: true` unless the store
18
+ * already holds the bytes, puts the bytes once the transaction has
19
+ * returned, and then appends `bytesPending: false`. A byte write that
20
+ * fails leaves the node visibly incomplete: `bytesPending` stays `true`
21
+ * and {@link fileUrl} gives no URL, so no reader is ever pointed at
22
+ * missing bytes. `confirmBytes` finishes it later, once the bytes are
23
+ * there.
24
+ * - **Nothing garbage-collects bytes.** They stay for as long as any
25
+ * version references them; purge (pulpit#58) is the only removal path,
26
+ * which is what {@link BlobStore.delete} is for.
27
+ * - **"Where is this file used" is an edge family** ({@link fileUsageEnds}),
28
+ * so the endpoint-visibility gate (`EDGE-001`) applies: a "used by" list
29
+ * never reveals content the viewer can't see. Maintaining those edges is
30
+ * the referencing leaf's job (or an after hook's).
31
+ *
32
+ * The adapters (R2, S3), image transforms, EXIF extraction and video
33
+ * streaming are the application's.
34
+ *
35
+ * @copyright Thomas Foster
36
+ * @module pulpit/blobs
37
+ * @since 0.1.0
38
+ */
39
+
40
+ import { Resource } from "@thomasfosterau/effect-jsonapi";
41
+ import * as Arr from "effect/Array";
42
+ import * as Effect from "effect/Effect";
43
+ import * as Exit from "effect/Exit";
44
+ import * as Option from "effect/Option";
45
+ import * as Schema from "effect/Schema";
46
+ import type { EdgeEnd, EdgeEnds } from "./edges.ts";
47
+ import type { Forbidden, SubtypeOps, Unauthorized, WriteRejected } from "./subtype.ts";
48
+ import type {
49
+ ChainState,
50
+ ChainWrite,
51
+ NodeAlreadyExists,
52
+ NodeNotFound,
53
+ Precondition,
54
+ StaleVersion,
55
+ VersionConflict,
56
+ } from "./version-chain.ts";
57
+
58
+ // ── Vocabulary ────────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * A file's content hash: the SHA-256 digest of its bytes as 64 lowercase
62
+ * hexadecimal characters ({@link sha256Hex}).
63
+ *
64
+ * @since 0.1.0
65
+ */
66
+ export const ContentHash = Schema.String.check(
67
+ Schema.isPattern(/^[0-9a-f]{64}$/, {
68
+ description: "a SHA-256 content hash as 64 lowercase hexadecimal characters",
69
+ }),
70
+ );
71
+ /**
72
+ * The {@link ContentHash} string.
73
+ *
74
+ * @since 0.1.0
75
+ */
76
+ export type ContentHash = typeof ContentHash.Type;
77
+
78
+ /**
79
+ * The SHA-256 content hash of `bytes`, through the platform's Web Crypto
80
+ * (`globalThis.crypto.subtle`, in browsers, Node 22+ and Workers alike).
81
+ *
82
+ * @example
83
+ * ```ts
84
+ * import { strictEqual } from "node:assert";
85
+ * import * as Effect from "effect/Effect";
86
+ * import { sha256Hex } from "@thomasfosterau/pulpit/blobs";
87
+ *
88
+ * strictEqual(
89
+ * await Effect.runPromise(sha256Hex(new TextEncoder().encode("abc"))),
90
+ * "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
91
+ * );
92
+ * ```
93
+ *
94
+ * @since 0.1.0
95
+ */
96
+ export const sha256Hex = (bytes: Uint8Array): Effect.Effect<ContentHash> =>
97
+ Effect.tryPromise(() => globalThis.crypto.subtle.digest("SHA-256", Uint8Array.from(bytes))).pipe(
98
+ // Web Crypto's SHA-256 over bytes has no failure a caller could act on.
99
+ Effect.orDie,
100
+ Effect.map((digest) =>
101
+ Arr.map(Arr.fromIterable(new Uint8Array(digest)), (byte) =>
102
+ byte.toString(16).padStart(2, "0"),
103
+ ).join(""),
104
+ ),
105
+ );
106
+
107
+ /**
108
+ * The blob-store key for a content hash: `sha256/<hash>`. The key is
109
+ * derived from the bytes alone, never from a node id, so a node's history
110
+ * can point at every set of bytes it has ever had.
111
+ *
112
+ * @since 0.1.0
113
+ */
114
+ export const blobKey = (hash: ContentHash): string => `sha256/${hash}`;
115
+
116
+ /**
117
+ * What the blob store knows of one stored object.
118
+ *
119
+ * @since 0.1.0
120
+ */
121
+ export interface BlobHead {
122
+ readonly byteSize: number;
123
+ readonly mediaType: string;
124
+ }
125
+
126
+ // ── The seam ──────────────────────────────────────────────────────────
127
+
128
+ /**
129
+ * The byte store an application supplies (R2, S3, a local directory). It
130
+ * applies no policy and fails only with its `E`. Keys come from
131
+ * {@link blobKey}; see the package README → Seams for the contract.
132
+ *
133
+ * @since 0.1.0
134
+ */
135
+ export interface BlobStore<E, R> {
136
+ /** Store `bytes` at `key`. Idempotent: the same key always holds the same bytes. */
137
+ readonly put: (key: string, bytes: Uint8Array, head: BlobHead) => Effect.Effect<void, E, R>;
138
+ /** The bytes at `key`, or none. */
139
+ readonly get: (key: string) => Effect.Effect<Option.Option<Uint8Array>, E, R>;
140
+ /** The object's size and media type, or none when nothing is stored at `key`. */
141
+ readonly head: (key: string) => Effect.Effect<Option.Option<BlobHead>, E, R>;
142
+ /** Remove the object at `key`. Purge's alone (pulpit#58): nothing else deletes bytes. */
143
+ readonly delete: (key: string) => Effect.Effect<void, E, R>;
144
+ /** The URL a reader fetches `key` from, when the store serves one. */
145
+ readonly publicUrl: (key: string) => Option.Option<string>;
146
+ /** A signed URL a client may upload `key`'s bytes to directly, when the store offers one. */
147
+ readonly signedUploadUrl: Option.Option<
148
+ (key: string, head: BlobHead) => Effect.Effect<string, E, R>
149
+ >;
150
+ }
151
+
152
+ // ── The field spread ──────────────────────────────────────────────────
153
+
154
+ /**
155
+ * The attributes every file version carries, all server-computed from the
156
+ * upload and present on the resource only (`readOnlyAttribute`): the
157
+ * `contentHash` its bytes are stored under, their `byteSize` and
158
+ * `mediaType`, the original `filename`, and `bytesPending`, `true` while the
159
+ * bytes are not yet confirmed in the blob store. Spread into a file leaf's
160
+ * `Resource.make({ attributes })`.
161
+ *
162
+ * @since 0.1.0
163
+ */
164
+ export const fileResourceFields = {
165
+ contentHash: Resource.readOnlyAttribute(ContentHash),
166
+ byteSize: Resource.readOnlyAttribute(Schema.Finite),
167
+ mediaType: Resource.readOnlyAttribute(Schema.String),
168
+ filename: Resource.readOnlyAttribute(Schema.String),
169
+ bytesPending: Resource.readOnlyAttribute(Schema.Boolean),
170
+ } as const;
171
+
172
+ /**
173
+ * A file version's columns, as {@link defineFileWrites} writes them.
174
+ *
175
+ * @since 0.1.0
176
+ */
177
+ export interface FileColumns {
178
+ readonly contentHash: ContentHash;
179
+ readonly byteSize: number;
180
+ readonly mediaType: string;
181
+ readonly filename: string;
182
+ readonly bytesPending: boolean;
183
+ }
184
+
185
+ /**
186
+ * The URL a file version's bytes are read from: none while `bytesPending`
187
+ * (anything but an explicit `false`), and none when the store serves no
188
+ * URL, so no reader is pointed at bytes that may not be there.
189
+ *
190
+ * @since 0.1.0
191
+ */
192
+ export const fileUrl = <E, R>(
193
+ file: { readonly contentHash: ContentHash; readonly bytesPending: boolean },
194
+ blobs: Pick<BlobStore<E, R>, "publicUrl">,
195
+ ): Option.Option<string> =>
196
+ !file.bytesPending ? blobs.publicUrl(blobKey(file.contentHash)) : Option.none();
197
+
198
+ // ── The commit-ordered write ──────────────────────────────────────────
199
+
200
+ /**
201
+ * An upload: the bytes and what the client said they are.
202
+ *
203
+ * @since 0.1.0
204
+ */
205
+ export interface Upload {
206
+ readonly bytes: Uint8Array;
207
+ readonly mediaType: string;
208
+ readonly filename: string;
209
+ }
210
+
211
+ /**
212
+ * What a file write reports: the version it landed at, its content hash,
213
+ * and whether its bytes are still pending (the post-commit byte write
214
+ * failed, or its confirmation did).
215
+ *
216
+ * @since 0.1.0
217
+ */
218
+ export interface FileWrite extends ChainWrite {
219
+ readonly contentHash: ContentHash;
220
+ readonly bytesPending: boolean;
221
+ }
222
+
223
+ type WriteFailure = Unauthorized | Forbidden | WriteRejected | VersionConflict;
224
+
225
+ /**
226
+ * The operations {@link defineFileWrites} produces.
227
+ *
228
+ * @since 0.1.0
229
+ */
230
+ export interface FileWriteOps<E, R> {
231
+ /**
232
+ * Create a file node from an upload, with the leaf's other columns in
233
+ * `state`. Fails as the leaf's `create` does; a failure to store the
234
+ * bytes after the commit does not fail it (the node is left
235
+ * `bytesPending`).
236
+ */
237
+ readonly create: (input: {
238
+ readonly id: Option.Option<string>;
239
+ readonly state: ChainState;
240
+ readonly upload: Upload;
241
+ }) => Effect.Effect<FileWrite, WriteFailure | NodeAlreadyExists | E, R>;
242
+ /**
243
+ * Replace a file node's bytes with a new version. Fails as the leaf's
244
+ * `update` does (`StaleVersion` against `expectedVersion`, `NodeNotFound`
245
+ * for a missing or tombstoned node).
246
+ */
247
+ readonly replace: (
248
+ input: { readonly id: string; readonly upload: Upload } & Precondition,
249
+ ) => Effect.Effect<FileWrite, WriteFailure | NodeNotFound | StaleVersion | E, R>;
250
+ /**
251
+ * Finish a pending version (`version`, whose bytes `contentHash` names,
252
+ * as a {@link FileWrite} reports them): when the store now holds those
253
+ * bytes, append `bytesPending: false` over exactly that version and
254
+ * report `false`; otherwise write nothing and report `true`. Gated as the
255
+ * leaf's `update` is; `StaleVersion` when the node has moved past
256
+ * `version` (its head names other bytes).
257
+ */
258
+ readonly confirmBytes: (input: {
259
+ readonly id: string;
260
+ readonly version: bigint;
261
+ readonly contentHash: ContentHash;
262
+ }) => Effect.Effect<boolean, WriteFailure | NodeNotFound | StaleVersion | E, R>;
263
+ }
264
+
265
+ /**
266
+ * Bind a file leaf's commit-ordered writes: the version is appended (in
267
+ * `transact`, the caller's write boundary) before any byte is written, and
268
+ * the bytes go to the blob store only once it has returned. Each version
269
+ * write goes through the leaf's own operations, so their gates,
270
+ * preconditions and before hooks apply; the cache write-through and after
271
+ * hooks are `subtypeHandlers`', which an application that caches the leaf
272
+ * runs itself.
273
+ *
274
+ * @since 0.1.0
275
+ */
276
+ export const defineFileWrites = <Resource, E, R>(definition: {
277
+ readonly leaf: SubtypeOps<Resource, E, R>;
278
+ readonly blobs: BlobStore<E, R>;
279
+ readonly transact: <A, E2>(effect: Effect.Effect<A, E2, R>) => Effect.Effect<A, E2, R>;
280
+ }): FileWriteOps<E, R> => {
281
+ const { leaf, blobs, transact } = definition;
282
+
283
+ /** The upload's columns, pending unless the store already holds its bytes. */
284
+ const columnsOf = (upload: Upload) =>
285
+ Effect.gen(function* () {
286
+ const contentHash = yield* sha256Hex(upload.bytes);
287
+ const stored = yield* blobs.head(blobKey(contentHash));
288
+ const columns: FileColumns = {
289
+ contentHash,
290
+ byteSize: upload.bytes.byteLength,
291
+ mediaType: upload.mediaType,
292
+ filename: upload.filename,
293
+ bytesPending: Option.isNone(stored),
294
+ };
295
+ return columns;
296
+ });
297
+
298
+ /**
299
+ * `bytesPending: false`, appended in its own transaction over exactly the
300
+ * pending version: if another write has landed since, it names other
301
+ * bytes, so the confirmation fails `StaleVersion` and confirms nothing.
302
+ */
303
+ const confirm = (id: string, pending: bigint) =>
304
+ transact(leaf.update({ id, patch: { bytesPending: false }, expectedVersion: pending }));
305
+
306
+ /**
307
+ * After the commit: put the bytes and confirm them. A failure of either
308
+ * leaves the node pending, never failing the write that committed.
309
+ */
310
+ const afterCommit = (written: ChainWrite, columns: FileColumns, upload: Upload) =>
311
+ Effect.gen(function* () {
312
+ if (!columns.bytesPending) {
313
+ return { ...written, contentHash: columns.contentHash, bytesPending: false };
314
+ }
315
+ const confirmed = yield* Effect.exit(
316
+ Effect.andThen(
317
+ blobs.put(blobKey(columns.contentHash), upload.bytes, {
318
+ byteSize: columns.byteSize,
319
+ mediaType: columns.mediaType,
320
+ }),
321
+ confirm(written.id, written.version),
322
+ ),
323
+ );
324
+ if (Exit.isFailure(confirmed)) {
325
+ yield* Effect.logWarning(`${leaf.type} ${written.id}: bytes left pending`, confirmed.cause);
326
+ return { ...written, contentHash: columns.contentHash, bytesPending: true };
327
+ }
328
+ return { ...confirmed.value, contentHash: columns.contentHash, bytesPending: false };
329
+ });
330
+
331
+ const create = Effect.fn(`${leaf.type}.files.create`)(function* (input: {
332
+ readonly id: Option.Option<string>;
333
+ readonly state: ChainState;
334
+ readonly upload: Upload;
335
+ }) {
336
+ const columns = yield* columnsOf(input.upload);
337
+ const written = yield* transact(
338
+ leaf.create({ id: input.id, state: { ...input.state, ...columns } }),
339
+ );
340
+ return yield* afterCommit(written, columns, input.upload);
341
+ });
342
+
343
+ const replace = Effect.fn(`${leaf.type}.files.replace`)(function* (
344
+ input: { readonly id: string; readonly upload: Upload } & Precondition,
345
+ ) {
346
+ const columns = yield* columnsOf(input.upload);
347
+ const written = yield* transact(
348
+ leaf.update({ id: input.id, patch: { ...columns }, expectedVersion: input.expectedVersion }),
349
+ );
350
+ return yield* afterCommit(written, columns, input.upload);
351
+ });
352
+
353
+ const confirmBytes = Effect.fn(`${leaf.type}.files.confirmBytes`)(function* (input: {
354
+ readonly id: string;
355
+ readonly version: bigint;
356
+ readonly contentHash: ContentHash;
357
+ }) {
358
+ if (Option.isNone(yield* blobs.head(blobKey(input.contentHash)))) return true;
359
+ yield* confirm(input.id, input.version);
360
+ return false;
361
+ });
362
+
363
+ return { create, replace, confirmBytes };
364
+ };
365
+
366
+ // ── The usage index ───────────────────────────────────────────────────
367
+
368
+ /**
369
+ * The ends of a file-usage edge family: `user` (the content node that uses
370
+ * the file) and `file` (the file node), each declared with its own leaf's
371
+ * access and view, for `./edges`' `defineEdgeOps`. The family's edge table
372
+ * keys `(user_id, file_id)` uniquely, so a re-asserted use is a no-op under
373
+ * `onConflict: "ignore"`. A "used by" list is then an edge list at the file
374
+ * end, and `EDGE-001` hides every use whose content the viewer can't read.
375
+ *
376
+ * @since 0.1.0
377
+ */
378
+ export const fileUsageEnds = <E, R>(ends: {
379
+ readonly user: Omit<EdgeEnd<E, R>, "column">;
380
+ readonly file: Omit<EdgeEnd<E, R>, "column">;
381
+ }): EdgeEnds<E, R> => ({
382
+ user: { ...ends.user, column: "userId" },
383
+ file: { ...ends.file, column: "fileId" },
384
+ });