@nubbin/store-fs 0.2.0 → 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/dist/testing/index.d.ts +15 -0
- package/dist/testing/index.js +181 -0
- package/package.json +14 -2
|
@@ -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",
|