@nubbin/store-fs 0.1.1 → 0.2.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/README.md +9 -3
- package/dist/index.d.ts +79 -2
- package/dist/index.js +58 -19
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -20,15 +20,21 @@ await store.publish("/promotions/summer", artifact.hash);
|
|
|
20
20
|
.nubbin/
|
|
21
21
|
artifacts/<hash>.json
|
|
22
22
|
routes/%2Fpromotions%2Fsummer.json
|
|
23
|
+
history/%2Fpromotions%2Fsummer.jsonl
|
|
23
24
|
```
|
|
24
25
|
|
|
25
26
|
Writes are temp-then-rename, so a concurrent reader sees the old pointer or the new one and
|
|
26
27
|
never half of either. `manifest()` reads the pointer directory rather than a stored file, so
|
|
27
28
|
there is nothing to keep in step.
|
|
28
29
|
|
|
30
|
+
Each publish also appends one line to the route's history log — after the pointer has moved,
|
|
31
|
+
so a failed publish is never recorded as live. Appending writes only the new line, never
|
|
32
|
+
reading the log back to rewrite it, so concurrent publishes of one route cannot lose each
|
|
33
|
+
other's entry either. `history(route)` reads it back oldest first, and `unpublish` leaves it
|
|
34
|
+
where it is.
|
|
35
|
+
|
|
29
36
|
It passes a shared `ArtifactStore` contract suite, which is how a replacement adapter proves
|
|
30
37
|
itself equivalent — by execution rather than by inspection.
|
|
31
38
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
<https://effekt.github.io/nubbin/>. MIT.
|
|
39
|
+
Read the [Nubbin documentation](https://nubbin.io) for the complete artifact store reference.
|
|
40
|
+
MIT.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,85 @@
|
|
|
1
1
|
import { ArtifactStore } from '@nubbin/core';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Builds the reference `ArtifactStore` over a directory — the store the studio, the example app
|
|
5
|
+
* and the documented `@nubbin/cli` setup all publish through, and the one a replacement adapter is
|
|
6
|
+
* measured against.
|
|
7
|
+
*
|
|
8
|
+
* Three kinds of file live under `root`, and nothing else does:
|
|
9
|
+
*
|
|
10
|
+
* ```text
|
|
11
|
+
* <root>/
|
|
12
|
+
* artifacts/<hash>.json one per written artifact, never rewritten
|
|
13
|
+
* routes/%2Fpromotions%2Fsummer.json the pointer — the only file that moves
|
|
14
|
+
* history/%2Fpromotions%2Fsummer.jsonl one line per publish, oldest first
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* A route key is percent-encoded, so a route with slashes is one flat filename rather than a
|
|
18
|
+
* directory tree, and `manifest()` can list `routes/` to find every published route. The logs sit
|
|
19
|
+
* in their own directory for that reason: a `.jsonl` filed among the pointers would be read as one
|
|
20
|
+
* and break the listing.
|
|
21
|
+
*
|
|
22
|
+
* Nothing is created until something is written — `root` need not exist, and each write makes the
|
|
23
|
+
* directory it needs. Reading before then is not a failure: an unknown hash and an unpublished
|
|
24
|
+
* route both read as `null`, and a store with no `routes/` yet manifests as no routes.
|
|
25
|
+
*
|
|
26
|
+
* Repeating a call is safe, which is what a publish retried after a timeout needs, but the two
|
|
27
|
+
* repeats differ. Writing a hash the store already holds leaves the file alone — a content address
|
|
28
|
+
* that already resolves holds the same bytes by construction. Publishing the same route and hash
|
|
29
|
+
* again does not: the pointer is rewritten with a fresh `updatedAt` and a second move is appended
|
|
30
|
+
* to the log. Content addressing dedupes artifacts, not moves.
|
|
31
|
+
*
|
|
32
|
+
* Every `ArtifactStore` method is implemented, `history` included, so a caller reading a route's
|
|
33
|
+
* moves needs no `?? []` fallback for this store. Within one publish the pointer moves first and
|
|
34
|
+
* the log is appended after: a crash between the two leaves the log one entry short, which
|
|
35
|
+
* under-reports. The opposite order would let the log claim a publish that never went live.
|
|
36
|
+
*
|
|
37
|
+
* @param root - The directory the store owns, absolute or resolved against `process.cwd()`. It
|
|
38
|
+
* owns everything beneath it, so give it a directory nothing else writes into. Two stores over
|
|
39
|
+
* one root are the same store, and so is a second process pointed at it — the state is the files.
|
|
40
|
+
* @returns An `ArtifactStore` bound to `root`. It holds no cache and no open handle: every call
|
|
41
|
+
* reads the filesystem, so one built at module scope stays correct as other processes publish
|
|
42
|
+
* underneath it.
|
|
43
|
+
*
|
|
44
|
+
* @throws {NubbinError} From `publish`, coded `artifact-not-stored` when nothing has been written
|
|
45
|
+
* at that hash. A pointer at an unwritten hash would be a live 404, so the existence check comes
|
|
46
|
+
* before the pointer is touched.
|
|
47
|
+
* @throws {NubbinError} From `publish`, coded `invalid-route` when the route addresses no page.
|
|
48
|
+
* Core's `parseMatchKind` judges it, so an adapter that never called `compile` still cannot
|
|
49
|
+
* publish an unaddressable route.
|
|
50
|
+
* @throws A Node filesystem error, unchanged, when the operation itself fails — no permission on
|
|
51
|
+
* `root`, a full disk, or a path taken by something that is not the expected kind of file.
|
|
52
|
+
* `ENOENT` alone is not one of these: it is how absence is read.
|
|
53
|
+
* @throws A `SyntaxError` from `read`, `pointer` or `manifest` when a file under `root` is not the
|
|
54
|
+
* JSON this store wrote. Hand-editing the directory, or pointing two tools at one root, is what
|
|
55
|
+
* produces that.
|
|
56
|
+
*
|
|
57
|
+
* @example Compile, store, publish
|
|
58
|
+
* ```ts
|
|
59
|
+
* import { compile } from "@nubbin/core";
|
|
60
|
+
* import { createFsArtifactStore } from "@nubbin/store-fs";
|
|
61
|
+
*
|
|
62
|
+
* const store = createFsArtifactStore("./.nubbin");
|
|
63
|
+
*
|
|
64
|
+
* const { artifact } = compile(version, catalog, registry, "/promotions/summer");
|
|
65
|
+
* await store.write(artifact);
|
|
66
|
+
* await store.publish(artifact.route, artifact.hash);
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* @example Serve a request from the store
|
|
70
|
+
* ```ts
|
|
71
|
+
* const pointer = await store.pointer("/promotions/summer");
|
|
72
|
+
* const artifact = pointer === null ? null : await store.read(pointer.hash);
|
|
73
|
+
* ```
|
|
74
|
+
*
|
|
75
|
+
* @example Roll a route back to what it pointed at before
|
|
76
|
+
* ```ts
|
|
77
|
+
* const moves = await store.history?.("/promotions/summer") ?? [];
|
|
78
|
+
* const previous = moves.at(-2);
|
|
79
|
+
* if (previous !== undefined) {
|
|
80
|
+
* await store.publish("/promotions/summer", previous.hash);
|
|
81
|
+
* }
|
|
82
|
+
* ```
|
|
6
83
|
*/
|
|
7
84
|
declare function createFsArtifactStore(root: string): ArtifactStore;
|
|
8
85
|
|
package/dist/index.js
CHANGED
|
@@ -4,15 +4,25 @@ function artifactPath(root, hash) {
|
|
|
4
4
|
return join(root, "artifacts", `${hash}.json`);
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
// src/
|
|
8
|
-
import { readdir } from "fs/promises";
|
|
7
|
+
// src/historyPath.ts
|
|
9
8
|
import { join as join2 } from "path";
|
|
10
9
|
|
|
11
|
-
// src/
|
|
10
|
+
// src/encodeRouteKey.ts
|
|
11
|
+
function encodeRouteKey(route) {
|
|
12
|
+
return encodeURIComponent(route);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// src/historyPath.ts
|
|
16
|
+
function historyPath(root, route) {
|
|
17
|
+
const key = encodeRouteKey(route);
|
|
18
|
+
return join2(root, "history", `${key}.jsonl`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// src/readFileOrNull.ts
|
|
12
22
|
import { readFile } from "fs/promises";
|
|
13
|
-
async function
|
|
23
|
+
async function readFileOrNull(filePath) {
|
|
14
24
|
try {
|
|
15
|
-
return
|
|
25
|
+
return await readFile(filePath, "utf8");
|
|
16
26
|
} catch (error) {
|
|
17
27
|
if (error.code === "ENOENT") {
|
|
18
28
|
return null;
|
|
@@ -21,9 +31,28 @@ async function readJsonOrNull(filePath) {
|
|
|
21
31
|
}
|
|
22
32
|
}
|
|
23
33
|
|
|
34
|
+
// src/fsHistory.ts
|
|
35
|
+
async function fsHistory(root, route) {
|
|
36
|
+
const log = await readFileOrNull(historyPath(root, route));
|
|
37
|
+
if (log === null) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
return log.split("\n").filter((line) => line !== "").map((line) => JSON.parse(line));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/fsManifest.ts
|
|
44
|
+
import { readdir } from "fs/promises";
|
|
45
|
+
import { join as join3 } from "path";
|
|
46
|
+
|
|
47
|
+
// src/readJsonOrNull.ts
|
|
48
|
+
async function readJsonOrNull(filePath) {
|
|
49
|
+
const text = await readFileOrNull(filePath);
|
|
50
|
+
return text === null ? null : JSON.parse(text);
|
|
51
|
+
}
|
|
52
|
+
|
|
24
53
|
// src/fsManifest.ts
|
|
25
54
|
async function fsManifest(root) {
|
|
26
|
-
const directory =
|
|
55
|
+
const directory = join3(root, "routes");
|
|
27
56
|
let entries;
|
|
28
57
|
try {
|
|
29
58
|
entries = await readdir(directory);
|
|
@@ -33,7 +62,7 @@ async function fsManifest(root) {
|
|
|
33
62
|
}
|
|
34
63
|
entries = [];
|
|
35
64
|
}
|
|
36
|
-
const read = entries.map((entry) => readJsonOrNull(
|
|
65
|
+
const read = entries.map((entry) => readJsonOrNull(join3(directory, entry)));
|
|
37
66
|
const routes = (await Promise.all(read)).filter((pointer) => pointer !== null);
|
|
38
67
|
return { routes, generatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
39
68
|
}
|
|
@@ -41,25 +70,28 @@ async function fsManifest(root) {
|
|
|
41
70
|
// src/fsPublish.ts
|
|
42
71
|
import { NubbinIssueCode, parseMatchKind, refuse } from "@nubbin/core";
|
|
43
72
|
|
|
44
|
-
// src/
|
|
45
|
-
import {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
73
|
+
// src/appendJsonLine.ts
|
|
74
|
+
import { appendFile, mkdir } from "fs/promises";
|
|
75
|
+
import { dirname } from "path";
|
|
76
|
+
async function appendJsonLine(filePath, value) {
|
|
77
|
+
const line = `${JSON.stringify(value)}
|
|
78
|
+
`;
|
|
79
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
80
|
+
await appendFile(filePath, line);
|
|
50
81
|
}
|
|
51
82
|
|
|
52
83
|
// src/pointerPath.ts
|
|
84
|
+
import { join as join4 } from "path";
|
|
53
85
|
function pointerPath(root, route) {
|
|
54
|
-
return
|
|
86
|
+
return join4(root, "routes", `${encodeRouteKey(route)}.json`);
|
|
55
87
|
}
|
|
56
88
|
|
|
57
89
|
// src/writeJsonAtomic.ts
|
|
58
|
-
import { mkdir, rename, writeFile } from "fs/promises";
|
|
59
|
-
import { dirname } from "path";
|
|
90
|
+
import { mkdir as mkdir2, rename, writeFile } from "fs/promises";
|
|
91
|
+
import { dirname as dirname2 } from "path";
|
|
60
92
|
var writes = 0;
|
|
61
93
|
async function writeJsonAtomic(filePath, value) {
|
|
62
|
-
await
|
|
94
|
+
await mkdir2(dirname2(filePath), { recursive: true });
|
|
63
95
|
writes += 1;
|
|
64
96
|
const temp = `${filePath}.${process.pid}.${writes}.tmp`;
|
|
65
97
|
await writeFile(temp, JSON.stringify(value, null, 2));
|
|
@@ -68,7 +100,8 @@ async function writeJsonAtomic(filePath, value) {
|
|
|
68
100
|
|
|
69
101
|
// src/fsPublish.ts
|
|
70
102
|
async function fsPublish(root, route, hash) {
|
|
71
|
-
|
|
103
|
+
const artifact = await readJsonOrNull(artifactPath(root, hash));
|
|
104
|
+
if (artifact === null) {
|
|
72
105
|
refuse(
|
|
73
106
|
NubbinIssueCode.ArtifactNotStored,
|
|
74
107
|
`cannot publish ${route}: artifact ${hash} is not in the store`,
|
|
@@ -82,6 +115,11 @@ async function fsPublish(root, route, hash) {
|
|
|
82
115
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
83
116
|
};
|
|
84
117
|
await writeJsonAtomic(pointerPath(root, route), pointer);
|
|
118
|
+
await appendJsonLine(historyPath(root, route), {
|
|
119
|
+
hash,
|
|
120
|
+
documentVersion: artifact.documentVersion,
|
|
121
|
+
movedAt: pointer.updatedAt
|
|
122
|
+
});
|
|
85
123
|
}
|
|
86
124
|
|
|
87
125
|
// src/fsUnpublish.ts
|
|
@@ -107,7 +145,8 @@ function createFsArtifactStore(root) {
|
|
|
107
145
|
pointer: (route) => readJsonOrNull(pointerPath(root, route)),
|
|
108
146
|
manifest: () => fsManifest(root),
|
|
109
147
|
publish: (route, hash) => fsPublish(root, route, hash),
|
|
110
|
-
unpublish: (route) => fsUnpublish(root, route)
|
|
148
|
+
unpublish: (route) => fsUnpublish(root, route),
|
|
149
|
+
history: (route) => fsHistory(root, route)
|
|
111
150
|
};
|
|
112
151
|
}
|
|
113
152
|
export {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nubbin/store-fs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The reference artifact store for Nubbin: one file per artifact, one pointer file per route, and no aggregate to lose a write.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nubbin",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"access": "public"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@nubbin/core": "0.
|
|
35
|
+
"@nubbin/core": "0.2.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/node": "25.9.2",
|