@nubbin/store-fs 0.1.0 → 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 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
- **Release candidate.**
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
- * One pointer file per route, one file per artifact, and nothing else. No aggregate document
5
- * exists, so two publishes to different routes cannot lose each other's write.
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/fsManifest.ts
8
- import { readdir } from "fs/promises";
7
+ // src/historyPath.ts
9
8
  import { join as join2 } from "path";
10
9
 
11
- // src/readJsonOrNull.ts
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 readJsonOrNull(filePath) {
23
+ async function readFileOrNull(filePath) {
14
24
  try {
15
- return JSON.parse(await readFile(filePath, "utf8"));
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 = join2(root, "routes");
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(join2(directory, entry)));
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/pointerPath.ts
45
- import { join as join3 } from "path";
46
-
47
- // src/encodeRouteKey.ts
48
- function encodeRouteKey(route) {
49
- return encodeURIComponent(route);
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 join3(root, "routes", `${encodeRouteKey(route)}.json`);
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 mkdir(dirname(filePath), { recursive: true });
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
- if (!await readJsonOrNull(artifactPath(root, hash))) {
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.1.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.1.0"
35
+ "@nubbin/core": "0.2.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "25.9.2",