@quillmark/quiver 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/dist/assert-node.d.ts +5 -0
- package/dist/assert-node.js +12 -0
- package/dist/bundle.d.ts +13 -0
- package/dist/bundle.js +33 -0
- package/dist/engine-types.d.ts +26 -0
- package/dist/engine-types.js +20 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.js +17 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/node.d.ts +1 -0
- package/dist/node.js +5 -0
- package/dist/pack.d.ts +25 -0
- package/dist/pack.js +120 -0
- package/dist/packed-loader.d.ts +27 -0
- package/dist/packed-loader.js +232 -0
- package/dist/quiver-yaml.d.ts +22 -0
- package/dist/quiver-yaml.js +63 -0
- package/dist/quiver.d.ts +74 -0
- package/dist/quiver.js +109 -0
- package/dist/ref.d.ts +14 -0
- package/dist/ref.js +44 -0
- package/dist/registry.d.ts +39 -0
- package/dist/registry.js +115 -0
- package/dist/semver.d.ts +8 -0
- package/dist/semver.js +44 -0
- package/dist/source-loader.d.ts +42 -0
- package/dist/source-loader.js +179 -0
- package/dist/testing.d.ts +39 -0
- package/dist/testing.js +108 -0
- package/dist/transports/fs-transport.d.ts +14 -0
- package/dist/transports/fs-transport.js +33 -0
- package/dist/transports/http-transport.d.ts +12 -0
- package/dist/transports/http-transport.js +33 -0
- package/package.json +6 -2
package/dist/quiver.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quiver — primary runtime abstraction for a collection of quills.
|
|
3
|
+
*
|
|
4
|
+
* Polymorphism via composition: internally stores a pluggable loader
|
|
5
|
+
* (either source-backed or packed-backed).
|
|
6
|
+
*/
|
|
7
|
+
import type { PackOptions } from "./pack.js";
|
|
8
|
+
/** @internal Internal loader strategy: source or packed. */
|
|
9
|
+
export interface QuiverLoader {
|
|
10
|
+
loadTree(name: string, version: string): Promise<Map<string, Uint8Array>>;
|
|
11
|
+
}
|
|
12
|
+
export declare class Quiver {
|
|
13
|
+
#private;
|
|
14
|
+
readonly name: string;
|
|
15
|
+
/**
|
|
16
|
+
* Private constructor — use static factory methods.
|
|
17
|
+
* TS prevents external `new Quiver(...)` at compile time.
|
|
18
|
+
* Static methods inside can still call it.
|
|
19
|
+
*/
|
|
20
|
+
private constructor();
|
|
21
|
+
/** @internal Used by loadPackedQuiver. Not part of the public API. */
|
|
22
|
+
static _fromLoader(name: string, catalog: Map<string, string[]>, loader: QuiverLoader): Quiver;
|
|
23
|
+
/**
|
|
24
|
+
* Node-only factory. Reads a Source Quiver from a directory containing
|
|
25
|
+
* `Quiver.yaml` and `quills/<name>/<version>/Quill.yaml` entries.
|
|
26
|
+
*
|
|
27
|
+
* Uses dynamic import of `./source-loader.js` so that importing this module
|
|
28
|
+
* in a browser environment does not cause a crash at module evaluation time.
|
|
29
|
+
*
|
|
30
|
+
* Throws `quiver_invalid` on schema violations, `transport_error` on I/O failure.
|
|
31
|
+
*/
|
|
32
|
+
static fromSourceDir(path: string): Promise<Quiver>;
|
|
33
|
+
/**
|
|
34
|
+
* Node-only factory. Loads a Packed Quiver from a local directory.
|
|
35
|
+
*
|
|
36
|
+
* Uses dynamic imports so this module stays browser-safe at evaluation time.
|
|
37
|
+
*
|
|
38
|
+
* Throws `transport_error` on I/O failure, `quiver_invalid` on format errors.
|
|
39
|
+
*/
|
|
40
|
+
static fromPackedDir(path: string): Promise<Quiver>;
|
|
41
|
+
/**
|
|
42
|
+
* Browser-safe factory. Loads a Packed Quiver from an HTTP base URL.
|
|
43
|
+
*
|
|
44
|
+
* Throws `transport_error` on network/HTTP failure, `quiver_invalid` on
|
|
45
|
+
* format errors.
|
|
46
|
+
*/
|
|
47
|
+
static fromHttp(url: string): Promise<Quiver>;
|
|
48
|
+
/** Returns all known quill names, sorted lexicographically. */
|
|
49
|
+
quillNames(): string[];
|
|
50
|
+
/**
|
|
51
|
+
* Returns all canonical versions for a given quill name, sorted descending.
|
|
52
|
+
* Returns an empty array if the quill name is not in the catalog.
|
|
53
|
+
*/
|
|
54
|
+
versionsOf(name: string): string[];
|
|
55
|
+
/**
|
|
56
|
+
* Node-only tooling. Writes a Packed Quiver artifact to outDir.
|
|
57
|
+
*
|
|
58
|
+
* Uses dynamic import of `./pack.js` so that this module stays browser-safe
|
|
59
|
+
* at evaluation time.
|
|
60
|
+
*
|
|
61
|
+
* Throws `quiver_invalid` on source validation failures,
|
|
62
|
+
* `transport_error` on I/O failures.
|
|
63
|
+
*/
|
|
64
|
+
static pack(sourceDir: string, outDir: string, opts?: PackOptions): Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* Lazily loads the file tree for a specific quill version.
|
|
67
|
+
*
|
|
68
|
+
* Returns `Map<string, Uint8Array>` suitable for `engine.quill(tree)`.
|
|
69
|
+
* Does NOT cache the result — caching is the registry's concern.
|
|
70
|
+
*
|
|
71
|
+
* Throws `transport_error` if name/version not in catalog or I/O fails.
|
|
72
|
+
*/
|
|
73
|
+
loadTree(name: string, version: string): Promise<Map<string, Uint8Array>>;
|
|
74
|
+
}
|
package/dist/quiver.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quiver — primary runtime abstraction for a collection of quills.
|
|
3
|
+
*
|
|
4
|
+
* Polymorphism via composition: internally stores a pluggable loader
|
|
5
|
+
* (either source-backed or packed-backed).
|
|
6
|
+
*/
|
|
7
|
+
import { QuiverError } from "./errors.js";
|
|
8
|
+
import { assertNode } from "./assert-node.js";
|
|
9
|
+
export class Quiver {
|
|
10
|
+
name;
|
|
11
|
+
#catalog;
|
|
12
|
+
#loader;
|
|
13
|
+
/**
|
|
14
|
+
* Private constructor — use static factory methods.
|
|
15
|
+
* TS prevents external `new Quiver(...)` at compile time.
|
|
16
|
+
* Static methods inside can still call it.
|
|
17
|
+
*/
|
|
18
|
+
constructor(name, catalog, loader) {
|
|
19
|
+
this.name = name;
|
|
20
|
+
this.#catalog = new Map(catalog);
|
|
21
|
+
this.#loader = loader;
|
|
22
|
+
}
|
|
23
|
+
/** @internal Used by loadPackedQuiver. Not part of the public API. */
|
|
24
|
+
static _fromLoader(name, catalog, loader) {
|
|
25
|
+
return new Quiver(name, catalog, loader);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Node-only factory. Reads a Source Quiver from a directory containing
|
|
29
|
+
* `Quiver.yaml` and `quills/<name>/<version>/Quill.yaml` entries.
|
|
30
|
+
*
|
|
31
|
+
* Uses dynamic import of `./source-loader.js` so that importing this module
|
|
32
|
+
* in a browser environment does not cause a crash at module evaluation time.
|
|
33
|
+
*
|
|
34
|
+
* Throws `quiver_invalid` on schema violations, `transport_error` on I/O failure.
|
|
35
|
+
*/
|
|
36
|
+
static async fromSourceDir(path) {
|
|
37
|
+
assertNode("Quiver.fromSourceDir");
|
|
38
|
+
const { scanSourceQuiver, SourceLoader } = await import("./source-loader.js");
|
|
39
|
+
const { meta, catalog } = await scanSourceQuiver(path);
|
|
40
|
+
const loader = new SourceLoader(path);
|
|
41
|
+
return new Quiver(meta.name, catalog, loader);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Node-only factory. Loads a Packed Quiver from a local directory.
|
|
45
|
+
*
|
|
46
|
+
* Uses dynamic imports so this module stays browser-safe at evaluation time.
|
|
47
|
+
*
|
|
48
|
+
* Throws `transport_error` on I/O failure, `quiver_invalid` on format errors.
|
|
49
|
+
*/
|
|
50
|
+
static async fromPackedDir(path) {
|
|
51
|
+
assertNode("Quiver.fromPackedDir");
|
|
52
|
+
const { FsTransport } = await import("./transports/fs-transport.js");
|
|
53
|
+
const { loadPackedQuiver } = await import("./packed-loader.js");
|
|
54
|
+
const transport = new FsTransport(path);
|
|
55
|
+
return loadPackedQuiver(transport);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Browser-safe factory. Loads a Packed Quiver from an HTTP base URL.
|
|
59
|
+
*
|
|
60
|
+
* Throws `transport_error` on network/HTTP failure, `quiver_invalid` on
|
|
61
|
+
* format errors.
|
|
62
|
+
*/
|
|
63
|
+
static async fromHttp(url) {
|
|
64
|
+
const { HttpTransport } = await import("./transports/http-transport.js");
|
|
65
|
+
const { loadPackedQuiver } = await import("./packed-loader.js");
|
|
66
|
+
const transport = new HttpTransport(url);
|
|
67
|
+
return loadPackedQuiver(transport);
|
|
68
|
+
}
|
|
69
|
+
/** Returns all known quill names, sorted lexicographically. */
|
|
70
|
+
quillNames() {
|
|
71
|
+
return [...this.#catalog.keys()].sort();
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Returns all canonical versions for a given quill name, sorted descending.
|
|
75
|
+
* Returns an empty array if the quill name is not in the catalog.
|
|
76
|
+
*/
|
|
77
|
+
versionsOf(name) {
|
|
78
|
+
return [...(this.#catalog.get(name) ?? [])];
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Node-only tooling. Writes a Packed Quiver artifact to outDir.
|
|
82
|
+
*
|
|
83
|
+
* Uses dynamic import of `./pack.js` so that this module stays browser-safe
|
|
84
|
+
* at evaluation time.
|
|
85
|
+
*
|
|
86
|
+
* Throws `quiver_invalid` on source validation failures,
|
|
87
|
+
* `transport_error` on I/O failures.
|
|
88
|
+
*/
|
|
89
|
+
static async pack(sourceDir, outDir, opts) {
|
|
90
|
+
assertNode("Quiver.pack");
|
|
91
|
+
const { packQuiver } = await import("./pack.js");
|
|
92
|
+
return packQuiver(sourceDir, outDir, opts);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Lazily loads the file tree for a specific quill version.
|
|
96
|
+
*
|
|
97
|
+
* Returns `Map<string, Uint8Array>` suitable for `engine.quill(tree)`.
|
|
98
|
+
* Does NOT cache the result — caching is the registry's concern.
|
|
99
|
+
*
|
|
100
|
+
* Throws `transport_error` if name/version not in catalog or I/O fails.
|
|
101
|
+
*/
|
|
102
|
+
async loadTree(name, version) {
|
|
103
|
+
const versions = this.#catalog.get(name);
|
|
104
|
+
if (!versions || !versions.includes(version)) {
|
|
105
|
+
throw new QuiverError("transport_error", `Quill "${name}@${version}" not found in quiver "${this.name}"`, { quiverName: this.name, version, ref: `${name}@${version}` });
|
|
106
|
+
}
|
|
107
|
+
return this.#loader.loadTree(name, version);
|
|
108
|
+
}
|
|
109
|
+
}
|
package/dist/ref.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Internal parsed representation of a quill reference. */
|
|
2
|
+
export interface ParsedQuillRef {
|
|
3
|
+
name: string;
|
|
4
|
+
/** Undefined = "highest in first-winning quiver". */
|
|
5
|
+
selector?: string;
|
|
6
|
+
/** Selector part count: 1 = `x`, 2 = `x.y`, 3 = `x.y.z` (exact). */
|
|
7
|
+
selectorDepth?: 1 | 2 | 3;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Throws QuiverError('invalid_ref') on malformed input.
|
|
11
|
+
* Validates name charset: [A-Za-z0-9_-]+
|
|
12
|
+
* Validates selector per §5 (x, x.y, x.y.z — digits only, no ranges/operators).
|
|
13
|
+
*/
|
|
14
|
+
export declare function parseQuillRef(ref: string): ParsedQuillRef;
|
package/dist/ref.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { QuiverError } from "./errors.js";
|
|
2
|
+
const NAME_RE = /^[A-Za-z0-9_-]+$/;
|
|
3
|
+
const SELECTOR_RE = /^\d+(\.\d+){0,2}$/;
|
|
4
|
+
/**
|
|
5
|
+
* Throws QuiverError('invalid_ref') on malformed input.
|
|
6
|
+
* Validates name charset: [A-Za-z0-9_-]+
|
|
7
|
+
* Validates selector per §5 (x, x.y, x.y.z — digits only, no ranges/operators).
|
|
8
|
+
*/
|
|
9
|
+
export function parseQuillRef(ref) {
|
|
10
|
+
if (!ref) {
|
|
11
|
+
throw new QuiverError("invalid_ref", `Invalid ref: empty string`, { ref });
|
|
12
|
+
}
|
|
13
|
+
const atIndex = ref.indexOf("@");
|
|
14
|
+
if (atIndex === 0) {
|
|
15
|
+
// Starts with @, no name
|
|
16
|
+
throw new QuiverError("invalid_ref", `Invalid ref: missing name in "${ref}"`, { ref });
|
|
17
|
+
}
|
|
18
|
+
if (atIndex === -1) {
|
|
19
|
+
// No selector — just a name
|
|
20
|
+
const name = ref;
|
|
21
|
+
if (!NAME_RE.test(name)) {
|
|
22
|
+
throw new QuiverError("invalid_ref", `Invalid ref: name "${name}" contains invalid characters`, { ref });
|
|
23
|
+
}
|
|
24
|
+
return { name };
|
|
25
|
+
}
|
|
26
|
+
// Has @
|
|
27
|
+
const name = ref.slice(0, atIndex);
|
|
28
|
+
const selector = ref.slice(atIndex + 1);
|
|
29
|
+
if (!name) {
|
|
30
|
+
throw new QuiverError("invalid_ref", `Invalid ref: missing name in "${ref}"`, { ref });
|
|
31
|
+
}
|
|
32
|
+
if (!selector) {
|
|
33
|
+
throw new QuiverError("invalid_ref", `Invalid ref: missing selector after "@" in "${ref}"`, { ref });
|
|
34
|
+
}
|
|
35
|
+
if (!NAME_RE.test(name)) {
|
|
36
|
+
throw new QuiverError("invalid_ref", `Invalid ref: name "${name}" contains invalid characters`, { ref });
|
|
37
|
+
}
|
|
38
|
+
if (!SELECTOR_RE.test(selector)) {
|
|
39
|
+
throw new QuiverError("invalid_ref", `Invalid ref: selector "${selector}" is not a valid semver selector (only x, x.y, x.y.z with digits allowed)`, { ref });
|
|
40
|
+
}
|
|
41
|
+
const parts = selector.split(".");
|
|
42
|
+
const depth = parts.length;
|
|
43
|
+
return { name, selector, selectorDepth: depth };
|
|
44
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { QuillmarkLike, QuillLike } from "./engine-types.js";
|
|
2
|
+
import type { Quiver } from "./quiver.js";
|
|
3
|
+
export declare class QuiverRegistry {
|
|
4
|
+
#private;
|
|
5
|
+
constructor(args: {
|
|
6
|
+
engine: QuillmarkLike;
|
|
7
|
+
quivers: Quiver[];
|
|
8
|
+
});
|
|
9
|
+
/**
|
|
10
|
+
* Resolves a selector ref → canonical ref (e.g. "memo" → "memo@1.1.0").
|
|
11
|
+
*
|
|
12
|
+
* Applies multi-quiver precedence (§4): scan quivers in order, first quiver
|
|
13
|
+
* with any matching candidate wins, highest match within that quiver returned.
|
|
14
|
+
*
|
|
15
|
+
* Throws:
|
|
16
|
+
* - `invalid_ref` if ref fails parseQuillRef
|
|
17
|
+
* - `quill_not_found` if no quiver has a matching candidate
|
|
18
|
+
*/
|
|
19
|
+
resolve(ref: string): Promise<string>;
|
|
20
|
+
/**
|
|
21
|
+
* Returns a render-ready QuillLike instance for a canonical ref.
|
|
22
|
+
* Materializes via engine.quill(tree) on first call; caches by canonical ref.
|
|
23
|
+
*
|
|
24
|
+
* Throws:
|
|
25
|
+
* - `invalid_ref` if canonicalRef is not valid canonical x.y.z form
|
|
26
|
+
* - `quill_not_found` if canonical ref doesn't map to a loaded quiver
|
|
27
|
+
* - propagates I/O errors from loadTree unchanged
|
|
28
|
+
* - propagates engine errors from engine.quill() unchanged (not wrapped)
|
|
29
|
+
*/
|
|
30
|
+
getQuill(canonicalRef: string): Promise<QuillLike>;
|
|
31
|
+
/**
|
|
32
|
+
* Warms all quill refs across all composed quivers. Fail-fast.
|
|
33
|
+
*
|
|
34
|
+
* Calls loadTree + engine.quill(tree) for every known quill version in
|
|
35
|
+
* parallel. Already-cached refs resolve instantly (idempotent). Rejects on
|
|
36
|
+
* the first failure (Promise.all fail-fast semantics).
|
|
37
|
+
*/
|
|
38
|
+
warm(): Promise<void>;
|
|
39
|
+
}
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { QuiverError } from "./errors.js";
|
|
2
|
+
import { parseQuillRef } from "./ref.js";
|
|
3
|
+
import { matchesSemverSelector, chooseHighestVersion } from "./semver.js";
|
|
4
|
+
export class QuiverRegistry {
|
|
5
|
+
#engine;
|
|
6
|
+
#quivers;
|
|
7
|
+
/** Cache: canonical ref → pending or resolved Promise<QuillLike>. */
|
|
8
|
+
#cache = new Map();
|
|
9
|
+
constructor(args) {
|
|
10
|
+
this.#engine = args.engine;
|
|
11
|
+
// Validate no two quivers share Quiver.yaml.name → quiver_collision.
|
|
12
|
+
const seen = new Map();
|
|
13
|
+
for (const quiver of args.quivers) {
|
|
14
|
+
const existing = seen.get(quiver.name);
|
|
15
|
+
if (existing !== undefined) {
|
|
16
|
+
throw new QuiverError("quiver_collision", `Two quivers share the name "${quiver.name}": first quiver and a later quiver both declare this name. ` +
|
|
17
|
+
`Quiver names must be unique within a registry.`, { quiverName: quiver.name });
|
|
18
|
+
}
|
|
19
|
+
seen.set(quiver.name, quiver.name);
|
|
20
|
+
}
|
|
21
|
+
this.#quivers = Object.freeze([...args.quivers]);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Resolves a selector ref → canonical ref (e.g. "memo" → "memo@1.1.0").
|
|
25
|
+
*
|
|
26
|
+
* Applies multi-quiver precedence (§4): scan quivers in order, first quiver
|
|
27
|
+
* with any matching candidate wins, highest match within that quiver returned.
|
|
28
|
+
*
|
|
29
|
+
* Throws:
|
|
30
|
+
* - `invalid_ref` if ref fails parseQuillRef
|
|
31
|
+
* - `quill_not_found` if no quiver has a matching candidate
|
|
32
|
+
*/
|
|
33
|
+
async resolve(ref) {
|
|
34
|
+
// Throws invalid_ref on malformed input.
|
|
35
|
+
const parsed = parseQuillRef(ref);
|
|
36
|
+
for (const quiver of this.#quivers) {
|
|
37
|
+
const versions = quiver.versionsOf(parsed.name);
|
|
38
|
+
if (versions.length === 0)
|
|
39
|
+
continue;
|
|
40
|
+
// Filter by selector if present; otherwise all versions are candidates.
|
|
41
|
+
const candidates = parsed.selector === undefined
|
|
42
|
+
? versions
|
|
43
|
+
: versions.filter((v) => matchesSemverSelector(v, parsed.selector));
|
|
44
|
+
if (candidates.length === 0)
|
|
45
|
+
continue;
|
|
46
|
+
// First quiver with any candidate wins — pick highest within that quiver.
|
|
47
|
+
const winner = chooseHighestVersion(candidates);
|
|
48
|
+
// chooseHighestVersion returns null only for empty arrays; candidates is non-empty.
|
|
49
|
+
return `${parsed.name}@${winner}`;
|
|
50
|
+
}
|
|
51
|
+
throw new QuiverError("quill_not_found", `No quill found for ref "${ref}" in any registered quiver.`, { ref });
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Returns a render-ready QuillLike instance for a canonical ref.
|
|
55
|
+
* Materializes via engine.quill(tree) on first call; caches by canonical ref.
|
|
56
|
+
*
|
|
57
|
+
* Throws:
|
|
58
|
+
* - `invalid_ref` if canonicalRef is not valid canonical x.y.z form
|
|
59
|
+
* - `quill_not_found` if canonical ref doesn't map to a loaded quiver
|
|
60
|
+
* - propagates I/O errors from loadTree unchanged
|
|
61
|
+
* - propagates engine errors from engine.quill() unchanged (not wrapped)
|
|
62
|
+
*/
|
|
63
|
+
async getQuill(canonicalRef) {
|
|
64
|
+
let entry = this.#cache.get(canonicalRef);
|
|
65
|
+
if (entry === undefined) {
|
|
66
|
+
entry = this.#loadQuill(canonicalRef).catch((err) => {
|
|
67
|
+
this.#cache.delete(canonicalRef);
|
|
68
|
+
throw err;
|
|
69
|
+
});
|
|
70
|
+
this.#cache.set(canonicalRef, entry);
|
|
71
|
+
}
|
|
72
|
+
return entry;
|
|
73
|
+
}
|
|
74
|
+
/** Internal: does the actual loading work for getQuill. */
|
|
75
|
+
async #loadQuill(canonicalRef) {
|
|
76
|
+
// Parse and validate canonical form (must be x.y.z).
|
|
77
|
+
const parsed = parseQuillRef(canonicalRef);
|
|
78
|
+
if (parsed.selectorDepth !== 3) {
|
|
79
|
+
throw new QuiverError("invalid_ref", `getQuill requires a canonical ref (x.y.z) but received "${canonicalRef}". ` +
|
|
80
|
+
`Use resolve() first to obtain a canonical ref.`, { ref: canonicalRef });
|
|
81
|
+
}
|
|
82
|
+
const version = parsed.selector;
|
|
83
|
+
// Find the first quiver that owns this exact (name, version) pair.
|
|
84
|
+
const owningQuiver = this.#quivers.find((q) => q.versionsOf(parsed.name).includes(version));
|
|
85
|
+
if (owningQuiver === undefined) {
|
|
86
|
+
throw new QuiverError("quill_not_found", `Quill "${canonicalRef}" was not found in any registered quiver.`, { ref: canonicalRef, version });
|
|
87
|
+
}
|
|
88
|
+
// Load the file tree — I/O errors propagate as-is.
|
|
89
|
+
const tree = await owningQuiver.loadTree(parsed.name, version);
|
|
90
|
+
// Materialize the Quill via the engine.
|
|
91
|
+
// Engine errors propagate unchanged — they are not QuiverErrors and we
|
|
92
|
+
// should not mask them. The caller's error-handling stack will see the
|
|
93
|
+
// engine's own error type directly.
|
|
94
|
+
const quill = this.#engine.quill(tree);
|
|
95
|
+
return quill;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Warms all quill refs across all composed quivers. Fail-fast.
|
|
99
|
+
*
|
|
100
|
+
* Calls loadTree + engine.quill(tree) for every known quill version in
|
|
101
|
+
* parallel. Already-cached refs resolve instantly (idempotent). Rejects on
|
|
102
|
+
* the first failure (Promise.all fail-fast semantics).
|
|
103
|
+
*/
|
|
104
|
+
async warm() {
|
|
105
|
+
const promises = [];
|
|
106
|
+
for (const quiver of this.#quivers) {
|
|
107
|
+
for (const name of quiver.quillNames()) {
|
|
108
|
+
for (const version of quiver.versionsOf(name)) {
|
|
109
|
+
promises.push(this.getQuill(`${name}@${version}`));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
await Promise.all(promises);
|
|
114
|
+
}
|
|
115
|
+
}
|
package/dist/semver.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Returns true for exactly `x.y.z` with non-negative integer parts. */
|
|
2
|
+
export declare function isCanonicalSemver(version: string): boolean;
|
|
3
|
+
/** Returns true if `version` (canonical) matches `selector` (partial). */
|
|
4
|
+
export declare function matchesSemverSelector(version: string, selector: string): boolean;
|
|
5
|
+
/** Compares two canonical semver strings. Returns <0, 0, or >0. */
|
|
6
|
+
export declare function compareSemver(a: string, b: string): number;
|
|
7
|
+
/** Returns the highest version string, or null if empty. */
|
|
8
|
+
export declare function chooseHighestVersion(versions: string[]): string | null;
|
package/dist/semver.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Returns true for exactly `x.y.z` with non-negative integer parts. */
|
|
2
|
+
export function isCanonicalSemver(version) {
|
|
3
|
+
return /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version);
|
|
4
|
+
}
|
|
5
|
+
/** Returns true if `version` (canonical) matches `selector` (partial). */
|
|
6
|
+
export function matchesSemverSelector(version, selector) {
|
|
7
|
+
if (selector === version)
|
|
8
|
+
return true;
|
|
9
|
+
const selectorParts = selector.split(".");
|
|
10
|
+
const versionParts = version.split(".");
|
|
11
|
+
if (selectorParts.length === 0 ||
|
|
12
|
+
selectorParts.length > 3 ||
|
|
13
|
+
selectorParts.some((p) => p.length === 0 || Number.isNaN(Number(p)))) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
if (selectorParts.length > versionParts.length)
|
|
17
|
+
return false;
|
|
18
|
+
for (let i = 0; i < selectorParts.length; i++) {
|
|
19
|
+
if (selectorParts[i] !== versionParts[i])
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
/** Compares two canonical semver strings. Returns <0, 0, or >0. */
|
|
25
|
+
export function compareSemver(a, b) {
|
|
26
|
+
const partsA = a.split(".").map(Number);
|
|
27
|
+
const partsB = b.split(".").map(Number);
|
|
28
|
+
const len = Math.max(partsA.length, partsB.length);
|
|
29
|
+
for (let i = 0; i < len; i++) {
|
|
30
|
+
const numA = partsA[i] ?? 0;
|
|
31
|
+
const numB = partsB[i] ?? 0;
|
|
32
|
+
if (numA !== numB)
|
|
33
|
+
return numA - numB;
|
|
34
|
+
}
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
/** Returns the highest version string, or null if empty. */
|
|
38
|
+
export function chooseHighestVersion(versions) {
|
|
39
|
+
if (versions.length === 0)
|
|
40
|
+
return null;
|
|
41
|
+
const copy = [...versions];
|
|
42
|
+
copy.sort((a, b) => compareSemver(b, a));
|
|
43
|
+
return copy[0] ?? null;
|
|
44
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal filesystem scanner for Source Quiver layout.
|
|
3
|
+
*
|
|
4
|
+
* Uses Node.js `fs/promises` — this module must only be imported from
|
|
5
|
+
* Node-only contexts (fromSourceDir, etc.).
|
|
6
|
+
*/
|
|
7
|
+
import type { QuiverMeta } from "./quiver-yaml.js";
|
|
8
|
+
import type { QuiverLoader } from "./quiver.js";
|
|
9
|
+
/**
|
|
10
|
+
* Scans a Source Quiver root directory.
|
|
11
|
+
*
|
|
12
|
+
* Reads `<rootDir>/Quiver.yaml`, then walks `<rootDir>/quills/<name>/<version>/`
|
|
13
|
+
* to build a catalog of quill names → sorted versions (descending).
|
|
14
|
+
*
|
|
15
|
+
* Throws:
|
|
16
|
+
* - `quiver_invalid` if Quiver.yaml is missing/invalid, a version dir name is
|
|
17
|
+
* non-canonical, or a version dir is missing its Quill.yaml sentinel.
|
|
18
|
+
* - `transport_error` for I/O failures (permissions, etc.).
|
|
19
|
+
*
|
|
20
|
+
* Missing `quills/` directory is NOT an error — the quiver is valid but empty.
|
|
21
|
+
*/
|
|
22
|
+
export declare function scanSourceQuiver(rootDir: string): Promise<{
|
|
23
|
+
meta: QuiverMeta;
|
|
24
|
+
catalog: Map<string, string[]>;
|
|
25
|
+
}>;
|
|
26
|
+
/**
|
|
27
|
+
* Recursively reads all files under a quill version directory into a Map.
|
|
28
|
+
*
|
|
29
|
+
* Keys are relative POSIX paths (forward slashes, no leading slash).
|
|
30
|
+
* Throws `transport_error` on I/O failure.
|
|
31
|
+
*/
|
|
32
|
+
export declare function readQuillTree(quillDir: string): Promise<Map<string, Uint8Array>>;
|
|
33
|
+
/**
|
|
34
|
+
* Source-backed QuiverLoader: loads file trees from a Source Quiver on disk.
|
|
35
|
+
* The outer Quiver.loadTree already validates name/version against the catalog,
|
|
36
|
+
* so this loader trusts the gate and goes straight to reading files.
|
|
37
|
+
*/
|
|
38
|
+
export declare class SourceLoader implements QuiverLoader {
|
|
39
|
+
private readonly rootDir;
|
|
40
|
+
constructor(rootDir: string);
|
|
41
|
+
loadTree(name: string, version: string): Promise<Map<string, Uint8Array>>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal filesystem scanner for Source Quiver layout.
|
|
3
|
+
*
|
|
4
|
+
* Uses Node.js `fs/promises` — this module must only be imported from
|
|
5
|
+
* Node-only contexts (fromSourceDir, etc.).
|
|
6
|
+
*/
|
|
7
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
8
|
+
import { join, relative, sep } from "node:path";
|
|
9
|
+
import { QuiverError } from "./errors.js";
|
|
10
|
+
import { parseQuiverYaml } from "./quiver-yaml.js";
|
|
11
|
+
import { isCanonicalSemver, compareSemver } from "./semver.js";
|
|
12
|
+
/**
|
|
13
|
+
* Scans a Source Quiver root directory.
|
|
14
|
+
*
|
|
15
|
+
* Reads `<rootDir>/Quiver.yaml`, then walks `<rootDir>/quills/<name>/<version>/`
|
|
16
|
+
* to build a catalog of quill names → sorted versions (descending).
|
|
17
|
+
*
|
|
18
|
+
* Throws:
|
|
19
|
+
* - `quiver_invalid` if Quiver.yaml is missing/invalid, a version dir name is
|
|
20
|
+
* non-canonical, or a version dir is missing its Quill.yaml sentinel.
|
|
21
|
+
* - `transport_error` for I/O failures (permissions, etc.).
|
|
22
|
+
*
|
|
23
|
+
* Missing `quills/` directory is NOT an error — the quiver is valid but empty.
|
|
24
|
+
*/
|
|
25
|
+
export async function scanSourceQuiver(rootDir) {
|
|
26
|
+
// --- Read Quiver.yaml ---
|
|
27
|
+
const quiverYamlPath = join(rootDir, "Quiver.yaml");
|
|
28
|
+
let raw;
|
|
29
|
+
try {
|
|
30
|
+
raw = await readFile(quiverYamlPath);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
// ENOENT → transport_error: a missing Quiver.yaml means the path itself
|
|
34
|
+
// does not point to a quiver — this is a missing-path condition, not a
|
|
35
|
+
// structural violation of a quiver that exists.
|
|
36
|
+
// (Contrast: missing Quill.yaml inside a version dir is quiver_invalid —
|
|
37
|
+
// the version dir exists but lacks its required sentinel file.)
|
|
38
|
+
const code = err.code;
|
|
39
|
+
if (code === "ENOENT") {
|
|
40
|
+
throw new QuiverError("transport_error", `Source Quiver at "${rootDir}" is missing required "Quiver.yaml"`, { cause: err });
|
|
41
|
+
}
|
|
42
|
+
throw new QuiverError("transport_error", `Failed to read "Quiver.yaml" at "${quiverYamlPath}": ${err.message}`, { cause: err });
|
|
43
|
+
}
|
|
44
|
+
const meta = parseQuiverYaml(raw);
|
|
45
|
+
// --- Walk quills/ directory ---
|
|
46
|
+
const quillsDir = join(rootDir, "quills");
|
|
47
|
+
let quillNames;
|
|
48
|
+
try {
|
|
49
|
+
quillNames = await readdir(quillsDir);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
const code = err.code;
|
|
53
|
+
if (code === "ENOENT") {
|
|
54
|
+
// Missing quills/ is fine — empty catalog
|
|
55
|
+
return { meta, catalog: new Map() };
|
|
56
|
+
}
|
|
57
|
+
throw new QuiverError("transport_error", `Failed to read "quills/" directory at "${quillsDir}": ${err.message}`, { cause: err });
|
|
58
|
+
}
|
|
59
|
+
const catalog = new Map();
|
|
60
|
+
for (const quillName of quillNames) {
|
|
61
|
+
const quillNameDir = join(quillsDir, quillName);
|
|
62
|
+
// Ensure it's a directory
|
|
63
|
+
let st;
|
|
64
|
+
try {
|
|
65
|
+
st = await stat(quillNameDir);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
throw new QuiverError("transport_error", `Failed to stat "${quillNameDir}": ${err.message}`, { cause: err });
|
|
69
|
+
}
|
|
70
|
+
if (!st.isDirectory())
|
|
71
|
+
continue;
|
|
72
|
+
// Read version directories
|
|
73
|
+
let versionDirs;
|
|
74
|
+
try {
|
|
75
|
+
versionDirs = await readdir(quillNameDir);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
throw new QuiverError("transport_error", `Failed to read versions for quill "${quillName}": ${err.message}`, { cause: err });
|
|
79
|
+
}
|
|
80
|
+
const versions = [];
|
|
81
|
+
for (const versionDir of versionDirs) {
|
|
82
|
+
const versionPath = join(quillNameDir, versionDir);
|
|
83
|
+
// Ensure it's a directory
|
|
84
|
+
let vst;
|
|
85
|
+
try {
|
|
86
|
+
vst = await stat(versionPath);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
throw new QuiverError("transport_error", `Failed to stat "${versionPath}": ${err.message}`, { cause: err });
|
|
90
|
+
}
|
|
91
|
+
if (!vst.isDirectory())
|
|
92
|
+
continue;
|
|
93
|
+
// Non-canonical version → quiver_invalid
|
|
94
|
+
if (!isCanonicalSemver(versionDir)) {
|
|
95
|
+
throw new QuiverError("quiver_invalid", `Quill "${quillName}" has non-canonical version directory "${versionDir}" — only x.y.z format is allowed`, { quiverName: meta.name, version: versionDir });
|
|
96
|
+
}
|
|
97
|
+
// Require Quill.yaml sentinel inside the version dir
|
|
98
|
+
const quillYamlPath = join(versionPath, "Quill.yaml");
|
|
99
|
+
try {
|
|
100
|
+
await stat(quillYamlPath);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
const code = err.code;
|
|
104
|
+
if (code === "ENOENT") {
|
|
105
|
+
throw new QuiverError("quiver_invalid", `Quill "${quillName}@${versionDir}" is missing required "Quill.yaml"`, { quiverName: meta.name, version: versionDir });
|
|
106
|
+
}
|
|
107
|
+
throw new QuiverError("transport_error", `Failed to stat "Quill.yaml" at "${quillYamlPath}": ${err.message}`, { cause: err });
|
|
108
|
+
}
|
|
109
|
+
versions.push(versionDir);
|
|
110
|
+
}
|
|
111
|
+
if (versions.length > 0) {
|
|
112
|
+
// Sort descending
|
|
113
|
+
versions.sort((a, b) => compareSemver(b, a));
|
|
114
|
+
catalog.set(quillName, versions);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { meta, catalog };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Recursively reads all files under a quill version directory into a Map.
|
|
121
|
+
*
|
|
122
|
+
* Keys are relative POSIX paths (forward slashes, no leading slash).
|
|
123
|
+
* Throws `transport_error` on I/O failure.
|
|
124
|
+
*/
|
|
125
|
+
export async function readQuillTree(quillDir) {
|
|
126
|
+
const tree = new Map();
|
|
127
|
+
await walkDir(quillDir, quillDir, tree);
|
|
128
|
+
return tree;
|
|
129
|
+
}
|
|
130
|
+
async function walkDir(baseDir, currentDir, tree) {
|
|
131
|
+
let entries;
|
|
132
|
+
try {
|
|
133
|
+
entries = await readdir(currentDir);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
throw new QuiverError("transport_error", `Failed to read directory "${currentDir}": ${err.message}`, { cause: err });
|
|
137
|
+
}
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
const fullPath = join(currentDir, entry);
|
|
140
|
+
let st;
|
|
141
|
+
try {
|
|
142
|
+
st = await stat(fullPath);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
throw new QuiverError("transport_error", `Failed to stat "${fullPath}": ${err.message}`, { cause: err });
|
|
146
|
+
}
|
|
147
|
+
if (st.isDirectory()) {
|
|
148
|
+
await walkDir(baseDir, fullPath, tree);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
// Compute relative POSIX path
|
|
152
|
+
const rel = relative(baseDir, fullPath);
|
|
153
|
+
const posixRel = sep === "/" ? rel : rel.split(sep).join("/");
|
|
154
|
+
let bytes;
|
|
155
|
+
try {
|
|
156
|
+
bytes = await readFile(fullPath);
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
throw new QuiverError("transport_error", `Failed to read file "${fullPath}": ${err.message}`, { cause: err });
|
|
160
|
+
}
|
|
161
|
+
tree.set(posixRel, bytes);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Source-backed QuiverLoader: loads file trees from a Source Quiver on disk.
|
|
167
|
+
* The outer Quiver.loadTree already validates name/version against the catalog,
|
|
168
|
+
* so this loader trusts the gate and goes straight to reading files.
|
|
169
|
+
*/
|
|
170
|
+
export class SourceLoader {
|
|
171
|
+
rootDir;
|
|
172
|
+
constructor(rootDir) {
|
|
173
|
+
this.rootDir = rootDir;
|
|
174
|
+
}
|
|
175
|
+
async loadTree(name, version) {
|
|
176
|
+
const quillDir = join(this.rootDir, "quills", name, version);
|
|
177
|
+
return readQuillTree(quillDir);
|
|
178
|
+
}
|
|
179
|
+
}
|