@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,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,6 +1,148 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thomasfosterau/pulpit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "Thomas Foster <me@thomasfoster.au>",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/thomasfosterau/pulpit.git",
|
|
24
|
+
"directory": "packages/pulpit"
|
|
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
|
+
},
|
|
129
|
+
"publishConfig": {
|
|
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"
|
|
147
|
+
}
|
|
6
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
|
+
});
|