@nubbin/store-fs 0.1.1 → 0.3.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/dist/testing/index.d.ts +15 -0
- package/dist/testing/index.js +181 -0
- package/package.json +14 -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 {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Artifact, ArtifactStore } from '@nubbin/core';
|
|
2
|
+
|
|
3
|
+
/** A minimal valid artifact; hash and route parameterized because the contract keys on both. */
|
|
4
|
+
declare function artifactFixture(hash: string, route: string): Artifact;
|
|
5
|
+
|
|
6
|
+
/** The reference implementation the contract suite defines equivalence against. Test-only. */
|
|
7
|
+
declare function createMemoryArtifactStore(): ArtifactStore;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One suite, every implementation. An adapter passing by eye instead of by execution is the
|
|
11
|
+
* failure this exists to prevent.
|
|
12
|
+
*/
|
|
13
|
+
declare function runArtifactStoreContract(name: string, makeStore: () => Promise<ArtifactStore>): void;
|
|
14
|
+
|
|
15
|
+
export { artifactFixture, createMemoryArtifactStore, runArtifactStoreContract };
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// src/testing/artifactFixture.ts
|
|
2
|
+
function artifactFixture(hash, route) {
|
|
3
|
+
return {
|
|
4
|
+
hash,
|
|
5
|
+
route,
|
|
6
|
+
documentId: "d1",
|
|
7
|
+
documentVersion: 1,
|
|
8
|
+
blockVersions: { Hero: 1 },
|
|
9
|
+
tree: [],
|
|
10
|
+
meta: { title: "t" },
|
|
11
|
+
compiledWith: "0.0.0"
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// src/testing/createMemoryArtifactStore.ts
|
|
16
|
+
import { NubbinIssueCode, parseMatchKind, refuse } from "@nubbin/core";
|
|
17
|
+
function createMemoryArtifactStore() {
|
|
18
|
+
const artifacts = /* @__PURE__ */ new Map();
|
|
19
|
+
const pointers = /* @__PURE__ */ new Map();
|
|
20
|
+
const moves = /* @__PURE__ */ new Map();
|
|
21
|
+
return {
|
|
22
|
+
read: async (hash) => artifacts.get(hash) ?? null,
|
|
23
|
+
write: async (artifact) => {
|
|
24
|
+
if (!artifacts.has(artifact.hash)) {
|
|
25
|
+
artifacts.set(artifact.hash, artifact);
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
pointer: async (route) => pointers.get(route) ?? null,
|
|
29
|
+
publish: async (route, hash) => {
|
|
30
|
+
const artifact = artifacts.get(hash);
|
|
31
|
+
if (artifact === void 0) {
|
|
32
|
+
refuse(
|
|
33
|
+
NubbinIssueCode.ArtifactNotStored,
|
|
34
|
+
`cannot publish ${route}: artifact ${hash} is not in the store`,
|
|
35
|
+
route
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
39
|
+
pointers.set(route, { route, matchKind: parseMatchKind(route), hash, updatedAt });
|
|
40
|
+
const trail = moves.get(route) ?? [];
|
|
41
|
+
trail.push({ hash, documentVersion: artifact.documentVersion, movedAt: updatedAt });
|
|
42
|
+
moves.set(route, trail);
|
|
43
|
+
},
|
|
44
|
+
unpublish: async (route) => {
|
|
45
|
+
pointers.delete(route);
|
|
46
|
+
},
|
|
47
|
+
manifest: async () => ({
|
|
48
|
+
routes: [...pointers.values()],
|
|
49
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
50
|
+
}),
|
|
51
|
+
// Copied on the way out, so a caller mutating the answer cannot edit the record.
|
|
52
|
+
history: async (route) => [...moves.get(route) ?? []]
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// src/testing/runArtifactStoreContract.ts
|
|
57
|
+
import { describe, expect as expect3, test as test3 } from "vitest";
|
|
58
|
+
|
|
59
|
+
// src/testing/runHistoryContract.ts
|
|
60
|
+
import { expect, test } from "vitest";
|
|
61
|
+
function runHistoryContract(makeStore) {
|
|
62
|
+
const storeWithHistory = async (ctx) => {
|
|
63
|
+
const store = await makeStore();
|
|
64
|
+
if (store.history === void 0) ctx.skip();
|
|
65
|
+
return store;
|
|
66
|
+
};
|
|
67
|
+
test("history records every publish in order, oldest first", async (ctx) => {
|
|
68
|
+
const store = await storeWithHistory(ctx);
|
|
69
|
+
await store.write(artifactFixture("a1", "/x"));
|
|
70
|
+
await store.write(artifactFixture("a2", "/x"));
|
|
71
|
+
await store.publish("/x", "a1");
|
|
72
|
+
await store.publish("/x", "a2");
|
|
73
|
+
const moves = await store.history?.("/x") ?? [];
|
|
74
|
+
expect(moves.map((move) => move.hash)).toEqual(["a1", "a2"]);
|
|
75
|
+
expect(moves.every((move) => move.documentVersion === 1)).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
test("only published states appear \u2014 a written artifact is not a move", async (ctx) => {
|
|
78
|
+
const store = await storeWithHistory(ctx);
|
|
79
|
+
await store.write(artifactFixture("a1", "/x"));
|
|
80
|
+
expect(await store.history?.("/x")).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
test("unpublish leaves the trail \u2014 a route taken down and put back keeps it", async (ctx) => {
|
|
83
|
+
const store = await storeWithHistory(ctx);
|
|
84
|
+
await store.write(artifactFixture("a1", "/x"));
|
|
85
|
+
await store.publish("/x", "a1");
|
|
86
|
+
await store.unpublish("/x");
|
|
87
|
+
expect((await store.history?.("/x") ?? []).map((move) => move.hash)).toEqual(["a1"]);
|
|
88
|
+
});
|
|
89
|
+
test("republishing the same hash is a second move \u2014 dedupe is for artifacts", async (ctx) => {
|
|
90
|
+
const store = await storeWithHistory(ctx);
|
|
91
|
+
await store.write(artifactFixture("a1", "/x"));
|
|
92
|
+
await store.publish("/x", "a1");
|
|
93
|
+
await store.publish("/x", "a1");
|
|
94
|
+
expect((await store.history?.("/x") ?? []).map((move) => move.hash)).toEqual(["a1", "a1"]);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/testing/runPointerRaceContract.ts
|
|
99
|
+
import { expect as expect2, test as test2 } from "vitest";
|
|
100
|
+
var WRITERS = ["a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8"];
|
|
101
|
+
function runPointerRaceContract(makeStore) {
|
|
102
|
+
test2("publishes racing for one route leave a whole winner, never a torn pointer", async () => {
|
|
103
|
+
const store = await makeStore();
|
|
104
|
+
await store.write(artifactFixture("a1", "/x"));
|
|
105
|
+
await store.write(artifactFixture("a2", "/x"));
|
|
106
|
+
await Promise.all([store.publish("/x", "a1"), store.publish("/x", "a2")]);
|
|
107
|
+
const pointer = await store.pointer("/x");
|
|
108
|
+
expect2(pointer).not.toBeNull();
|
|
109
|
+
expect2(["a1", "a2"]).toContain(pointer?.hash);
|
|
110
|
+
});
|
|
111
|
+
test2("many concurrent publishes to one route all settle, leaving one pointer", async () => {
|
|
112
|
+
const store = await makeStore();
|
|
113
|
+
for (const hash of WRITERS) {
|
|
114
|
+
await store.write(artifactFixture(hash, "/x"));
|
|
115
|
+
}
|
|
116
|
+
await Promise.all(WRITERS.map((hash) => store.publish("/x", hash)));
|
|
117
|
+
expect2(WRITERS).toContain((await store.pointer("/x"))?.hash);
|
|
118
|
+
expect2((await store.manifest()).routes).toHaveLength(1);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/testing/runArtifactStoreContract.ts
|
|
123
|
+
function runArtifactStoreContract(name, makeStore) {
|
|
124
|
+
describe(`ArtifactStore contract: ${name}`, () => {
|
|
125
|
+
test3("write then read round-trips; an unknown hash reads null", async () => {
|
|
126
|
+
const store = await makeStore();
|
|
127
|
+
const artifact = artifactFixture("a1", "/x");
|
|
128
|
+
await store.write(artifact);
|
|
129
|
+
expect3(await store.read("a1")).toEqual(artifact);
|
|
130
|
+
expect3(await store.read("ghost")).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
test3("writing an already-stored hash again is a no-op, not an error", async () => {
|
|
133
|
+
const store = await makeStore();
|
|
134
|
+
await store.write(artifactFixture("a1", "/x"));
|
|
135
|
+
await expect3(store.write(artifactFixture("a1", "/x"))).resolves.toBeUndefined();
|
|
136
|
+
});
|
|
137
|
+
test3("publish writes a pointer with matchKind parsed from the route", async () => {
|
|
138
|
+
const store = await makeStore();
|
|
139
|
+
await store.write(artifactFixture("a1", "/x"));
|
|
140
|
+
await store.publish("/x", "a1");
|
|
141
|
+
const pointer = await store.pointer("/x");
|
|
142
|
+
expect3(pointer?.hash).toBe("a1");
|
|
143
|
+
expect3(pointer?.matchKind).toBe("exact");
|
|
144
|
+
expect3(await store.pointer("/never")).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
test3("publish rejects a hash that was never written \u2014 no dead pointers", async () => {
|
|
147
|
+
const store = await makeStore();
|
|
148
|
+
await expect3(store.publish("/x", "ghost")).rejects.toThrow(/ghost/);
|
|
149
|
+
});
|
|
150
|
+
test3("publishing the same route and hash twice is a safe no-op", async () => {
|
|
151
|
+
const store = await makeStore();
|
|
152
|
+
await store.write(artifactFixture("a1", "/x"));
|
|
153
|
+
await store.publish("/x", "a1");
|
|
154
|
+
await expect3(store.publish("/x", "a1")).resolves.toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
test3("unpublish removes the pointer, keeps the artifact, and tolerates a missing pointer", async () => {
|
|
157
|
+
const store = await makeStore();
|
|
158
|
+
await store.write(artifactFixture("a1", "/x"));
|
|
159
|
+
await store.publish("/x", "a1");
|
|
160
|
+
await store.unpublish("/x");
|
|
161
|
+
expect3(await store.pointer("/x")).toBeNull();
|
|
162
|
+
expect3(await store.read("a1")).not.toBeNull();
|
|
163
|
+
await expect3(store.unpublish("/x")).resolves.toBeUndefined();
|
|
164
|
+
});
|
|
165
|
+
test3("publishes to different routes never contend, and manifest lists them all", async () => {
|
|
166
|
+
const store = await makeStore();
|
|
167
|
+
await store.write(artifactFixture("a1", "/x"));
|
|
168
|
+
await store.write(artifactFixture("a2", "/y"));
|
|
169
|
+
await Promise.all([store.publish("/x", "a1"), store.publish("/y", "a2")]);
|
|
170
|
+
const { routes } = await store.manifest();
|
|
171
|
+
expect3(routes.map((pointer) => pointer.route).sort()).toEqual(["/x", "/y"]);
|
|
172
|
+
});
|
|
173
|
+
runPointerRaceContract(makeStore);
|
|
174
|
+
runHistoryContract(makeStore);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
export {
|
|
178
|
+
artifactFixture,
|
|
179
|
+
createMemoryArtifactStore,
|
|
180
|
+
runArtifactStoreContract
|
|
181
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nubbin/store-fs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
".": {
|
|
24
24
|
"types": "./dist/index.d.ts",
|
|
25
25
|
"import": "./dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./testing": {
|
|
28
|
+
"types": "./dist/testing/index.d.ts",
|
|
29
|
+
"import": "./dist/testing/index.js"
|
|
26
30
|
}
|
|
27
31
|
},
|
|
28
32
|
"files": [
|
|
@@ -32,7 +36,7 @@
|
|
|
32
36
|
"access": "public"
|
|
33
37
|
},
|
|
34
38
|
"dependencies": {
|
|
35
|
-
"@nubbin/core": "0.
|
|
39
|
+
"@nubbin/core": "0.3.0"
|
|
36
40
|
},
|
|
37
41
|
"devDependencies": {
|
|
38
42
|
"@types/node": "25.9.2",
|
|
@@ -40,6 +44,14 @@
|
|
|
40
44
|
"typescript": "6.0.3",
|
|
41
45
|
"vitest": "4.1.10"
|
|
42
46
|
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"vitest": ">=4.0.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependenciesMeta": {
|
|
51
|
+
"vitest": {
|
|
52
|
+
"optional": true
|
|
53
|
+
}
|
|
54
|
+
},
|
|
43
55
|
"scripts": {
|
|
44
56
|
"build": "tsup",
|
|
45
57
|
"test": "vitest run",
|