Merge pull request 'feat: register 18 @output/* schemas, default templates, wrapEnvelope (Phase 1c)' (#78) from fix/75-output-schemas into main
This commit was merged in pull request #78.
This commit is contained in:
@@ -65,7 +65,7 @@ describe("createFsStore – init and bootstrap", () => {
|
|||||||
const h2 = await bootstrap(store);
|
const h2 = await bootstrap(store);
|
||||||
|
|
||||||
expect(h1).toEqual(h2);
|
expect(h1).toEqual(h2);
|
||||||
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(6);
|
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(24);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,40 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { bootstrap } from "./bootstrap.js";
|
import { bootstrap } from "./bootstrap.js";
|
||||||
|
import type { JSONSchema } from "./schema.js";
|
||||||
import { getSchema } from "./schema.js";
|
import { getSchema } from "./schema.js";
|
||||||
import { createMemoryStore } from "./store.js";
|
import { createMemoryStore } from "./store.js";
|
||||||
|
|
||||||
|
const OUTPUT_ALIASES = [
|
||||||
|
"@output/put",
|
||||||
|
"@output/get",
|
||||||
|
"@output/has",
|
||||||
|
"@output/hash",
|
||||||
|
"@output/verify",
|
||||||
|
"@output/refs",
|
||||||
|
"@output/walk",
|
||||||
|
"@output/list",
|
||||||
|
"@output/var-set",
|
||||||
|
"@output/var-get",
|
||||||
|
"@output/var-delete",
|
||||||
|
"@output/var-tag",
|
||||||
|
"@output/var-list",
|
||||||
|
"@output/template-set",
|
||||||
|
"@output/template-get",
|
||||||
|
"@output/template-list",
|
||||||
|
"@output/template-delete",
|
||||||
|
"@output/gc",
|
||||||
|
] as const;
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
// Built-in Schema Registration Tests
|
// Built-in Schema Registration Tests
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("bootstrap - Built-in Schemas", () => {
|
describe("bootstrap - Built-in Schemas", () => {
|
||||||
test("should return map of built-in schema aliases to hashes", async () => {
|
test("should return map of 24 built-in schema aliases to hashes", async () => {
|
||||||
const store = createMemoryStore();
|
const store = createMemoryStore();
|
||||||
const builtinSchemas = await bootstrap(store);
|
const builtinSchemas = await bootstrap(store);
|
||||||
|
|
||||||
// Should return object with 6 aliases
|
// Should return object with 6 primitive + 18 output aliases = 24
|
||||||
expect(builtinSchemas).toHaveProperty("@schema");
|
expect(builtinSchemas).toHaveProperty("@schema");
|
||||||
expect(builtinSchemas).toHaveProperty("@string");
|
expect(builtinSchemas).toHaveProperty("@string");
|
||||||
expect(builtinSchemas).toHaveProperty("@number");
|
expect(builtinSchemas).toHaveProperty("@number");
|
||||||
@@ -20,6 +42,12 @@ describe("bootstrap - Built-in Schemas", () => {
|
|||||||
expect(builtinSchemas).toHaveProperty("@array");
|
expect(builtinSchemas).toHaveProperty("@array");
|
||||||
expect(builtinSchemas).toHaveProperty("@bool");
|
expect(builtinSchemas).toHaveProperty("@bool");
|
||||||
|
|
||||||
|
for (const alias of OUTPUT_ALIASES) {
|
||||||
|
expect(builtinSchemas).toHaveProperty(alias);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(Object.keys(builtinSchemas)).toHaveLength(24);
|
||||||
|
|
||||||
// All values should be valid hashes
|
// All values should be valid hashes
|
||||||
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
|
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
|
||||||
expect(typeof hash).toBe("string");
|
expect(typeof hash).toBe("string");
|
||||||
@@ -127,3 +155,164 @@ describe("bootstrap - Built-in Schemas", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// @output/* Schema Registration Tests
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("bootstrap - @output/* Schemas", () => {
|
||||||
|
test("each @output/* schema has a title", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
|
||||||
|
for (const alias of OUTPUT_ALIASES) {
|
||||||
|
const hash = aliases[alias];
|
||||||
|
if (!hash) throw new Error(`${alias} not found`);
|
||||||
|
|
||||||
|
const schema = getSchema(store, hash) as JSONSchema;
|
||||||
|
expect(schema).not.toBeNull();
|
||||||
|
expect(typeof schema.title).toBe("string");
|
||||||
|
expect((schema.title as string).startsWith("ucas ")).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("@output/put schema describes a cas_ref string", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
const hash = aliases["@output/put"];
|
||||||
|
if (!hash) throw new Error("@output/put not found");
|
||||||
|
|
||||||
|
const schema = getSchema(store, hash);
|
||||||
|
expect(schema).toEqual({
|
||||||
|
type: "string",
|
||||||
|
format: "cas_ref",
|
||||||
|
title: "ucas put result",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("@output/get schema describes object with type, payload, timestamp", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
const hash = aliases["@output/get"];
|
||||||
|
if (!hash) throw new Error("@output/get not found");
|
||||||
|
|
||||||
|
const schema = getSchema(store, hash) as JSONSchema;
|
||||||
|
expect(schema.type).toBe("object");
|
||||||
|
expect(schema.title).toBe("ucas get result");
|
||||||
|
|
||||||
|
const props = schema.properties as Record<string, JSONSchema>;
|
||||||
|
expect(props.type).toEqual({ type: "string", format: "cas_ref" });
|
||||||
|
expect(props.payload).toEqual({});
|
||||||
|
expect(props.timestamp).toEqual({ type: "number" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("@output/has schema describes a boolean", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
const hash = aliases["@output/has"];
|
||||||
|
if (!hash) throw new Error("@output/has not found");
|
||||||
|
|
||||||
|
expect(getSchema(store, hash)).toEqual({
|
||||||
|
type: "boolean",
|
||||||
|
title: "ucas has result",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("@output/verify schema describes enum of ok|corrupted|invalid", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
const hash = aliases["@output/verify"];
|
||||||
|
if (!hash) throw new Error("@output/verify not found");
|
||||||
|
|
||||||
|
const schema = getSchema(store, hash);
|
||||||
|
expect(schema).toEqual({
|
||||||
|
type: "string",
|
||||||
|
enum: ["ok", "corrupted", "invalid"],
|
||||||
|
title: "ucas verify result",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("@output/refs schema describes array of cas_ref strings", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
const hash = aliases["@output/refs"];
|
||||||
|
if (!hash) throw new Error("@output/refs not found");
|
||||||
|
|
||||||
|
expect(getSchema(store, hash)).toEqual({
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string", format: "cas_ref" },
|
||||||
|
title: "ucas refs result",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("@output/gc schema describes object with gc stats fields", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
const hash = aliases["@output/gc"];
|
||||||
|
if (!hash) throw new Error("@output/gc not found");
|
||||||
|
|
||||||
|
const schema = getSchema(store, hash) as JSONSchema;
|
||||||
|
expect(schema.type).toBe("object");
|
||||||
|
expect(schema.title).toBe("ucas gc result");
|
||||||
|
|
||||||
|
const props = schema.properties as Record<string, JSONSchema>;
|
||||||
|
expect(props.total).toEqual({ type: "number" });
|
||||||
|
expect(props.reachable).toEqual({ type: "number" });
|
||||||
|
expect(props.collected).toEqual({ type: "number" });
|
||||||
|
expect(props.scanned).toEqual({ type: "number" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("@output/var-set schema describes a Variable object", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
const hash = aliases["@output/var-set"];
|
||||||
|
if (!hash) throw new Error("@output/var-set not found");
|
||||||
|
|
||||||
|
const schema = getSchema(store, hash) as JSONSchema;
|
||||||
|
expect(schema.type).toBe("object");
|
||||||
|
expect(schema.title).toBe("ucas var set result");
|
||||||
|
|
||||||
|
const props = schema.properties as Record<string, JSONSchema>;
|
||||||
|
expect(props.name).toEqual({ type: "string" });
|
||||||
|
expect(props.schema).toEqual({ type: "string", format: "cas_ref" });
|
||||||
|
expect(props.value).toEqual({ type: "string", format: "cas_ref" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("@output/var-list schema describes array of Variable objects", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
const hash = aliases["@output/var-list"];
|
||||||
|
if (!hash) throw new Error("@output/var-list not found");
|
||||||
|
|
||||||
|
const schema = getSchema(store, hash) as JSONSchema;
|
||||||
|
expect(schema.type).toBe("array");
|
||||||
|
expect(schema.title).toBe("ucas var list result");
|
||||||
|
|
||||||
|
const items = schema.items as JSONSchema;
|
||||||
|
expect(items.type).toBe("object");
|
||||||
|
const props = items.properties as Record<string, JSONSchema>;
|
||||||
|
expect(props.name).toEqual({ type: "string" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("@output/template-delete schema describes object with deleted boolean", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
const hash = aliases["@output/template-delete"];
|
||||||
|
if (!hash) throw new Error("@output/template-delete not found");
|
||||||
|
|
||||||
|
expect(getSchema(store, hash)).toEqual({
|
||||||
|
type: "object",
|
||||||
|
properties: { deleted: { type: "boolean" } },
|
||||||
|
title: "ucas template delete result",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all @output/* schemas are distinct hashes", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
|
||||||
|
const outputHashes = OUTPUT_ALIASES.map((alias) => aliases[alias]);
|
||||||
|
const uniqueHashes = new Set(outputHashes);
|
||||||
|
expect(uniqueHashes.size).toBe(OUTPUT_ALIASES.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -63,9 +63,168 @@ const BOOTSTRAP_PAYLOAD = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const VARIABLE_PROPERTIES = {
|
||||||
|
name: { type: "string" },
|
||||||
|
schema: { type: "string", format: "cas_ref" },
|
||||||
|
value: { type: "string", format: "cas_ref" },
|
||||||
|
created: { type: "number" },
|
||||||
|
updated: { type: "number" },
|
||||||
|
tags: { type: "object" },
|
||||||
|
labels: { type: "array", items: { type: "string" } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const OUTPUT_SCHEMAS: ReadonlyArray<
|
||||||
|
readonly [alias: string, schema: Record<string, unknown>]
|
||||||
|
> = [
|
||||||
|
[
|
||||||
|
"@output/put",
|
||||||
|
{ type: "string", format: "cas_ref", title: "ucas put result" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/get",
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
type: { type: "string", format: "cas_ref" },
|
||||||
|
payload: {},
|
||||||
|
timestamp: { type: "number" },
|
||||||
|
},
|
||||||
|
title: "ucas get result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
["@output/has", { type: "boolean", title: "ucas has result" }],
|
||||||
|
[
|
||||||
|
"@output/hash",
|
||||||
|
{ type: "string", format: "cas_ref", title: "ucas hash result" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/verify",
|
||||||
|
{
|
||||||
|
type: "string",
|
||||||
|
enum: ["ok", "corrupted", "invalid"],
|
||||||
|
title: "ucas verify result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/refs",
|
||||||
|
{
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string", format: "cas_ref" },
|
||||||
|
title: "ucas refs result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/walk",
|
||||||
|
{
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
title: "ucas walk result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/list",
|
||||||
|
{
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string", format: "cas_ref" },
|
||||||
|
title: "ucas list result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/var-set",
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { ...VARIABLE_PROPERTIES },
|
||||||
|
title: "ucas var set result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/var-get",
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { ...VARIABLE_PROPERTIES },
|
||||||
|
title: "ucas var get result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/var-delete",
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { ...VARIABLE_PROPERTIES },
|
||||||
|
title: "ucas var delete result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/var-tag",
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { ...VARIABLE_PROPERTIES },
|
||||||
|
title: "ucas var tag result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/var-list",
|
||||||
|
{
|
||||||
|
type: "array",
|
||||||
|
items: { type: "object", properties: { ...VARIABLE_PROPERTIES } },
|
||||||
|
title: "ucas var list result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/template-set",
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
schemaHash: { type: "string", format: "cas_ref" },
|
||||||
|
contentHash: { type: "string", format: "cas_ref" },
|
||||||
|
},
|
||||||
|
title: "ucas template set result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/template-get",
|
||||||
|
{ type: "string", title: "ucas template get result" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/template-list",
|
||||||
|
{
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
schemaHash: { type: "string", format: "cas_ref" },
|
||||||
|
contentHash: { type: "string", format: "cas_ref" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: "ucas template list result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/template-delete",
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { deleted: { type: "boolean" } },
|
||||||
|
title: "ucas template delete result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/gc",
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
total: { type: "number" },
|
||||||
|
reachable: { type: "number" },
|
||||||
|
collected: { type: "number" },
|
||||||
|
scanned: { type: "number" },
|
||||||
|
},
|
||||||
|
title: "ucas gc result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write the meta-schema seed node into the store and register built-in schemas.
|
* Write the meta-schema seed node into the store and register built-in schemas.
|
||||||
* The returned object contains aliases for the meta-schema and 5 primitive schemas.
|
* The returned object contains aliases for the meta-schema, 5 primitive schemas,
|
||||||
|
* and 18 @output/* schemas (24 total).
|
||||||
* Idempotent: calling bootstrap multiple times returns the same hashes.
|
* Idempotent: calling bootstrap multiple times returns the same hashes.
|
||||||
*/
|
*/
|
||||||
export async function bootstrap(store: Store): Promise<Record<string, Hash>> {
|
export async function bootstrap(store: Store): Promise<Record<string, Hash>> {
|
||||||
@@ -83,8 +242,8 @@ export async function bootstrap(store: Store): Promise<Record<string, Hash>> {
|
|||||||
const arrayHash = await store.put(metaHash, { type: "array" });
|
const arrayHash = await store.put(metaHash, { type: "array" });
|
||||||
const boolHash = await store.put(metaHash, { type: "boolean" });
|
const boolHash = await store.put(metaHash, { type: "boolean" });
|
||||||
|
|
||||||
// 3. Return map of aliases to hashes
|
// 3. Register @output/* schemas
|
||||||
return {
|
const aliases: Record<string, Hash> = {
|
||||||
"@schema": metaHash,
|
"@schema": metaHash,
|
||||||
"@string": stringHash,
|
"@string": stringHash,
|
||||||
"@number": numberHash,
|
"@number": numberHash,
|
||||||
@@ -92,4 +251,10 @@ export async function bootstrap(store: Store): Promise<Record<string, Hash>> {
|
|||||||
"@array": arrayHash,
|
"@array": arrayHash,
|
||||||
"@bool": boolHash,
|
"@bool": boolHash,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
for (const [alias, schema] of OUTPUT_SCHEMAS) {
|
||||||
|
aliases[alias] = await store.put(metaHash, schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
return aliases;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ describe("bootstrap", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns a map with 6 built-in schema aliases", async () => {
|
test("returns a map with 24 built-in schema aliases", async () => {
|
||||||
const store = createMemoryStore();
|
const store = createMemoryStore();
|
||||||
const builtinSchemas = await bootstrap(store);
|
const builtinSchemas = await bootstrap(store);
|
||||||
|
|
||||||
@@ -280,6 +280,8 @@ describe("bootstrap", () => {
|
|||||||
expect(hash).toHaveLength(13);
|
expect(hash).toHaveLength(13);
|
||||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expect(Object.keys(builtinSchemas)).toHaveLength(24);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("meta-schema node is stored and retrievable", async () => {
|
test("meta-schema node is stored and retrievable", async () => {
|
||||||
@@ -316,7 +318,7 @@ describe("bootstrap", () => {
|
|||||||
const h2 = await bootstrap(store);
|
const h2 = await bootstrap(store);
|
||||||
|
|
||||||
expect(h1).toEqual(h2);
|
expect(h1).toEqual(h2);
|
||||||
// All 6 built-in schemas should be typed by the meta-schema
|
// All 24 built-in schemas should be typed by the meta-schema
|
||||||
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(6);
|
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(24);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export { cborEncode } from "./cbor.js";
|
|||||||
export { type GcStats, gc } from "./gc.js";
|
export { type GcStats, gc } from "./gc.js";
|
||||||
export { computeHash, computeSelfHash } from "./hash.js";
|
export { computeHash, computeSelfHash } from "./hash.js";
|
||||||
export { renderWithTemplate } from "./liquid-render.js";
|
export { renderWithTemplate } from "./liquid-render.js";
|
||||||
|
export { registerOutputTemplates } from "./output-templates.js";
|
||||||
export {
|
export {
|
||||||
type RenderOptions,
|
type RenderOptions,
|
||||||
render,
|
render,
|
||||||
@@ -34,3 +35,4 @@ export {
|
|||||||
VariableStore,
|
VariableStore,
|
||||||
} from "./variable-store.js";
|
} from "./variable-store.js";
|
||||||
export { verify } from "./verify.js";
|
export { verify } from "./verify.js";
|
||||||
|
export { wrapEnvelope } from "./wrap-envelope.js";
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { bootstrap } from "./bootstrap.js";
|
||||||
|
import { registerOutputTemplates } from "./output-templates.js";
|
||||||
|
import { createMemoryStore } from "./store.js";
|
||||||
|
import type { Store } from "./types.js";
|
||||||
|
import type { VariableStore } from "./variable-store.js";
|
||||||
|
import { createVariableStore } from "./variable-store.js";
|
||||||
|
|
||||||
|
const OUTPUT_ALIASES = [
|
||||||
|
"@output/put",
|
||||||
|
"@output/get",
|
||||||
|
"@output/has",
|
||||||
|
"@output/hash",
|
||||||
|
"@output/verify",
|
||||||
|
"@output/refs",
|
||||||
|
"@output/walk",
|
||||||
|
"@output/list",
|
||||||
|
"@output/var-set",
|
||||||
|
"@output/var-get",
|
||||||
|
"@output/var-delete",
|
||||||
|
"@output/var-tag",
|
||||||
|
"@output/var-list",
|
||||||
|
"@output/template-set",
|
||||||
|
"@output/template-get",
|
||||||
|
"@output/template-list",
|
||||||
|
"@output/template-delete",
|
||||||
|
"@output/gc",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
describe("registerOutputTemplates", () => {
|
||||||
|
let store: Store;
|
||||||
|
let varStore: VariableStore;
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
varStore.close();
|
||||||
|
await rm(tempDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("registers a template for every @output/* schema", async () => {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
|
||||||
|
store = createMemoryStore();
|
||||||
|
await bootstrap(store);
|
||||||
|
varStore = createVariableStore(join(tempDir, "vars.db"), store);
|
||||||
|
|
||||||
|
const registered = await registerOutputTemplates(store, varStore);
|
||||||
|
|
||||||
|
expect(Object.keys(registered)).toHaveLength(18);
|
||||||
|
|
||||||
|
for (const alias of OUTPUT_ALIASES) {
|
||||||
|
expect(registered).toHaveProperty(alias);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("each template is retrievable via @ucas/template/text/<hash>", async () => {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
|
||||||
|
store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
varStore = createVariableStore(join(tempDir, "vars.db"), store);
|
||||||
|
|
||||||
|
await registerOutputTemplates(store, varStore);
|
||||||
|
|
||||||
|
const stringHash = aliases["@string"];
|
||||||
|
if (!stringHash) throw new Error("@string not found");
|
||||||
|
|
||||||
|
for (const alias of OUTPUT_ALIASES) {
|
||||||
|
const schemaHash = aliases[alias];
|
||||||
|
if (!schemaHash) throw new Error(`${alias} not found`);
|
||||||
|
|
||||||
|
const varName = `@ucas/template/text/${schemaHash}`;
|
||||||
|
const variable = varStore.get(varName, stringHash);
|
||||||
|
if (variable === null) throw new Error(`Variable ${varName} not found`);
|
||||||
|
|
||||||
|
const templateNode = store.get(variable.value);
|
||||||
|
if (templateNode === null)
|
||||||
|
throw new Error(`Template node ${variable.value} not found`);
|
||||||
|
expect(typeof templateNode.payload).toBe("string");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is idempotent — safe to call multiple times", async () => {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
|
||||||
|
store = createMemoryStore();
|
||||||
|
await bootstrap(store);
|
||||||
|
varStore = createVariableStore(join(tempDir, "vars.db"), store);
|
||||||
|
|
||||||
|
const first = await registerOutputTemplates(store, varStore);
|
||||||
|
const second = await registerOutputTemplates(store, varStore);
|
||||||
|
|
||||||
|
expect(first).toEqual(second);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("@output/put template contains payload reference", async () => {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
|
||||||
|
store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
varStore = createVariableStore(join(tempDir, "vars.db"), store);
|
||||||
|
|
||||||
|
await registerOutputTemplates(store, varStore);
|
||||||
|
|
||||||
|
const putHash = aliases["@output/put"];
|
||||||
|
if (!putHash) throw new Error("@output/put not found");
|
||||||
|
const stringHash = aliases["@string"];
|
||||||
|
if (!stringHash) throw new Error("@string not found");
|
||||||
|
|
||||||
|
const variable = varStore.get(`@ucas/template/text/${putHash}`, stringHash);
|
||||||
|
if (variable === null)
|
||||||
|
throw new Error("@output/put template variable not found");
|
||||||
|
|
||||||
|
const templateNode = store.get(variable.value);
|
||||||
|
if (templateNode === null) throw new Error("Template node not found");
|
||||||
|
expect(templateNode.payload).toBe("{{ payload }}");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { bootstrap } from "./bootstrap.js";
|
||||||
|
import type { Hash, Store } from "./types.js";
|
||||||
|
import type { VariableStore } from "./variable-store.js";
|
||||||
|
|
||||||
|
const DEFAULT_TEMPLATES: ReadonlyArray<
|
||||||
|
readonly [alias: string, template: string]
|
||||||
|
> = [
|
||||||
|
["@output/put", "{{ payload }}"],
|
||||||
|
[
|
||||||
|
"@output/get",
|
||||||
|
"type: {{ payload.type }}\ntimestamp: {{ payload.timestamp }}",
|
||||||
|
],
|
||||||
|
["@output/has", "{{ payload }}"],
|
||||||
|
["@output/hash", "{{ payload }}"],
|
||||||
|
["@output/verify", "{{ payload }}"],
|
||||||
|
["@output/refs", "{% for ref in payload %}{{ ref }}\n{% endfor %}"],
|
||||||
|
["@output/walk", "{% for item in payload %}{{ item }}\n{% endfor %}"],
|
||||||
|
["@output/list", "{% for item in payload %}{{ item }}\n{% endfor %}"],
|
||||||
|
[
|
||||||
|
"@output/var-set",
|
||||||
|
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/var-get",
|
||||||
|
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/var-delete",
|
||||||
|
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/var-tag",
|
||||||
|
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/var-list",
|
||||||
|
"{% for v in payload %}name: {{ v.name }}\nschema: {{ v.schema }}\nvalue: {{ v.value }}\n{% endfor %}",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/template-set",
|
||||||
|
"schemaHash: {{ payload.schemaHash }}\ncontentHash: {{ payload.contentHash }}",
|
||||||
|
],
|
||||||
|
["@output/template-get", "{{ payload }}"],
|
||||||
|
[
|
||||||
|
"@output/template-list",
|
||||||
|
"{% for t in payload %}schemaHash: {{ t.schemaHash }}\ncontentHash: {{ t.contentHash }}\n{% endfor %}",
|
||||||
|
],
|
||||||
|
["@output/template-delete", "deleted: {{ payload.deleted }}"],
|
||||||
|
[
|
||||||
|
"@output/gc",
|
||||||
|
"total: {{ payload.total }}\nreachable: {{ payload.reachable }}\ncollected: {{ payload.collected }}\nscanned: {{ payload.scanned }}",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register default LiquidJS templates for all @output/* schemas.
|
||||||
|
* Each template is stored as a @string CAS node and bound to
|
||||||
|
* the variable `@ucas/template/text/<schema-hash>`.
|
||||||
|
*
|
||||||
|
* Idempotent: safe to call multiple times.
|
||||||
|
*/
|
||||||
|
export async function registerOutputTemplates(
|
||||||
|
store: Store,
|
||||||
|
varStore: VariableStore,
|
||||||
|
): Promise<Record<string, Hash>> {
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
const stringHash = aliases["@string"];
|
||||||
|
if (stringHash === undefined) {
|
||||||
|
throw new Error("@string schema not found in bootstrap result");
|
||||||
|
}
|
||||||
|
|
||||||
|
const registered: Record<string, Hash> = {};
|
||||||
|
|
||||||
|
for (const [alias, template] of DEFAULT_TEMPLATES) {
|
||||||
|
const schemaHash = aliases[alias];
|
||||||
|
if (schemaHash === undefined) {
|
||||||
|
throw new Error(`Schema alias not found: ${alias}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentHash = await store.put(stringHash, template);
|
||||||
|
const varName = `@ucas/template/text/${schemaHash}`;
|
||||||
|
varStore.set(varName, contentHash);
|
||||||
|
registered[alias] = contentHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
return registered;
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { bootstrap } from "./bootstrap.js";
|
||||||
|
import { createMemoryStore } from "./store.js";
|
||||||
|
import { wrapEnvelope } from "./wrap-envelope.js";
|
||||||
|
|
||||||
|
describe("wrapEnvelope", () => {
|
||||||
|
test("resolves @output/put alias and returns envelope", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
|
||||||
|
const envelope = await wrapEnvelope(store, "@output/put", "AAAAAAAAAAAAA");
|
||||||
|
|
||||||
|
expect(envelope.type).toBe(aliases["@output/put"]);
|
||||||
|
expect(envelope.value).toBe("AAAAAAAAAAAAA");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves @output/has alias with boolean value", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
|
||||||
|
const envelope = await wrapEnvelope(store, "@output/has", true);
|
||||||
|
|
||||||
|
expect(envelope.type).toBe(aliases["@output/has"]);
|
||||||
|
expect(envelope.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves @output/gc alias with object value", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
|
||||||
|
const gcStats = { total: 100, reachable: 80, collected: 20, scanned: 5 };
|
||||||
|
const envelope = await wrapEnvelope(store, "@output/gc", gcStats);
|
||||||
|
|
||||||
|
expect(envelope.type).toBe(aliases["@output/gc"]);
|
||||||
|
expect(envelope.value).toEqual(gcStats);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves primitive alias @string", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
|
||||||
|
const envelope = await wrapEnvelope(store, "@string", "hello");
|
||||||
|
|
||||||
|
expect(envelope.type).toBe(aliases["@string"]);
|
||||||
|
expect(envelope.value).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws for unknown alias", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
await bootstrap(store);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
wrapEnvelope(store, "@output/nonexistent", "value"),
|
||||||
|
).rejects.toThrow("Unknown schema alias: @output/nonexistent");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is idempotent — same alias returns same type hash", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
|
||||||
|
const first = await wrapEnvelope(store, "@output/verify", "ok");
|
||||||
|
const second = await wrapEnvelope(store, "@output/verify", "corrupted");
|
||||||
|
|
||||||
|
expect(first.type).toBe(second.type);
|
||||||
|
expect(first.value).toBe("ok");
|
||||||
|
expect(second.value).toBe("corrupted");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves complex object values without mutation", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
await bootstrap(store);
|
||||||
|
|
||||||
|
const original = {
|
||||||
|
name: "test",
|
||||||
|
schema: "AAAAAAAAAAAAA",
|
||||||
|
value: "BBBBBBBBBBBBB",
|
||||||
|
created: 1000,
|
||||||
|
updated: 2000,
|
||||||
|
tags: { env: "prod" },
|
||||||
|
labels: ["stable"],
|
||||||
|
};
|
||||||
|
const envelope = await wrapEnvelope(store, "@output/var-set", original);
|
||||||
|
|
||||||
|
expect(envelope.value).toEqual(original);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { bootstrap } from "./bootstrap.js";
|
||||||
|
import type { Hash, Store } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a schema alias (e.g. "@output/put") to its hash via bootstrap,
|
||||||
|
* then return a typed envelope ready for store.put() or direct rendering.
|
||||||
|
*/
|
||||||
|
export async function wrapEnvelope(
|
||||||
|
store: Store,
|
||||||
|
schemaAlias: string,
|
||||||
|
value: unknown,
|
||||||
|
): Promise<{ type: Hash; value: unknown }> {
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
const typeHash = aliases[schemaAlias];
|
||||||
|
if (typeHash === undefined) {
|
||||||
|
throw new Error(`Unknown schema alias: ${schemaAlias}`);
|
||||||
|
}
|
||||||
|
return { type: typeHash, value };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user