Merge pull request 'feat: support P1 leaf JSON Schema constraints (#82)' (#84) from feat/82-schema-p1-leaf-constraints into main
This commit was merged in pull request #84.
This commit is contained in:
@@ -2,36 +2,36 @@
|
||||
|
||||
exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = `
|
||||
{
|
||||
"type": "ASE7K6A0HG8W9",
|
||||
"type": "2NHHHE9NR0FVT",
|
||||
"value": {
|
||||
"payload": {
|
||||
"age": 30,
|
||||
"name": "Alice",
|
||||
},
|
||||
"type": "7XX5H51CVD9H0",
|
||||
"type": "3K9ETAPGFPE32",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `
|
||||
{
|
||||
"type": "8E2M8H30BHXS8",
|
||||
"type": "BZ3TV0PTATA52",
|
||||
"value": "ok",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `
|
||||
"{
|
||||
"type": "4N5REDA48XYJP",
|
||||
"type": "18HZGJY02WN1K",
|
||||
"value": []
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `
|
||||
"{
|
||||
"type": "7124NEATTWYYY",
|
||||
"type": "9BVX9SHACFC5J",
|
||||
"value": [
|
||||
"ERARPP19YJT05"
|
||||
"A0QKG4ERMXSFG"
|
||||
]
|
||||
}"
|
||||
`;
|
||||
@@ -40,40 +40,40 @@ exports[`Phase 2: Schema Validation 2.3 put against non-existent schema hash fai
|
||||
|
||||
exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
|
||||
{
|
||||
"type": "AYHQD2YA9G667",
|
||||
"type": "EMX6HW4CECBGK",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "7XX5H51CVD9H0",
|
||||
"schema": "3K9ETAPGFPE32",
|
||||
"tags": {},
|
||||
"value": "ERARPP19YJT05",
|
||||
"value": "A0QKG4ERMXSFG",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
|
||||
{
|
||||
"type": "BVW2SAJ8606EZ",
|
||||
"type": "F68GWJAT9H6HP",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "7XX5H51CVD9H0",
|
||||
"schema": "3K9ETAPGFPE32",
|
||||
"tags": {},
|
||||
"value": "ERARPP19YJT05",
|
||||
"value": "A0QKG4ERMXSFG",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
{
|
||||
"type": "3BY1S4RKNMR0P",
|
||||
"type": "A4TV1FVBBNG5M",
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "7XX5H51CVD9H0",
|
||||
"schema": "3K9ETAPGFPE32",
|
||||
"tags": {},
|
||||
"value": "ERARPP19YJT05",
|
||||
"value": "A0QKG4ERMXSFG",
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -81,14 +81,14 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
|
||||
exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
|
||||
{
|
||||
"type": "3BY1S4RKNMR0P",
|
||||
"type": "A4TV1FVBBNG5M",
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "7XX5H51CVD9H0",
|
||||
"schema": "3K9ETAPGFPE32",
|
||||
"tags": {},
|
||||
"value": "ERARPP19YJT05",
|
||||
"value": "A0QKG4ERMXSFG",
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -96,48 +96,48 @@ exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
|
||||
|
||||
exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1`] = `
|
||||
{
|
||||
"type": "AYHQD2YA9G667",
|
||||
"type": "EMX6HW4CECBGK",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "7XX5H51CVD9H0",
|
||||
"schema": "3K9ETAPGFPE32",
|
||||
"tags": {},
|
||||
"value": "F68P1BZ46YDXM",
|
||||
"value": "FNDM9C6P23238",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
|
||||
{
|
||||
"type": "BKMJ3DJHTS6VB",
|
||||
"type": "92JS8RXGKDKC2",
|
||||
"value": {
|
||||
"labels": [
|
||||
"important",
|
||||
],
|
||||
"name": "myapp/config",
|
||||
"schema": "7XX5H51CVD9H0",
|
||||
"schema": "3K9ETAPGFPE32",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "ERARPP19YJT05",
|
||||
"value": "A0QKG4ERMXSFG",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = `
|
||||
{
|
||||
"type": "3BY1S4RKNMR0P",
|
||||
"type": "A4TV1FVBBNG5M",
|
||||
"value": [
|
||||
{
|
||||
"labels": [
|
||||
"important",
|
||||
],
|
||||
"name": "myapp/config",
|
||||
"schema": "7XX5H51CVD9H0",
|
||||
"schema": "3K9ETAPGFPE32",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "ERARPP19YJT05",
|
||||
"value": "A0QKG4ERMXSFG",
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -145,18 +145,18 @@ exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag
|
||||
|
||||
exports[`Phase 3: Variable System 3.8 var list --tag important filters by label 1`] = `
|
||||
{
|
||||
"type": "3BY1S4RKNMR0P",
|
||||
"type": "A4TV1FVBBNG5M",
|
||||
"value": [
|
||||
{
|
||||
"labels": [
|
||||
"important",
|
||||
],
|
||||
"name": "myapp/config",
|
||||
"schema": "7XX5H51CVD9H0",
|
||||
"schema": "3K9ETAPGFPE32",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "ERARPP19YJT05",
|
||||
"value": "A0QKG4ERMXSFG",
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -164,62 +164,62 @@ exports[`Phase 3: Variable System 3.8 var list --tag important filters by label
|
||||
|
||||
exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = `
|
||||
{
|
||||
"type": "BKMJ3DJHTS6VB",
|
||||
"type": "92JS8RXGKDKC2",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "7XX5H51CVD9H0",
|
||||
"schema": "3K9ETAPGFPE32",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "ERARPP19YJT05",
|
||||
"value": "A0QKG4ERMXSFG",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
|
||||
{
|
||||
"type": "ASWN8JEGAG7AP",
|
||||
"type": "00YH4RTNVWTRB",
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "7XX5H51CVD9H0",
|
||||
"schema": "3K9ETAPGFPE32",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "ERARPP19YJT05",
|
||||
"value": "A0QKG4ERMXSFG",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=myapp/config, schema=7XX5H51CVD9H0"`;
|
||||
exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=myapp/config, schema=3K9ETAPGFPE32"`;
|
||||
|
||||
exports[`Phase 4: Template System 4.1 template set registers template 1`] = `
|
||||
{
|
||||
"type": "9YJZ09DDAYAWR",
|
||||
"type": "DTNQZ90QQHA6D",
|
||||
"value": {
|
||||
"contentHash": "FC8WACA792B6F",
|
||||
"schemaHash": "7XX5H51CVD9H0",
|
||||
"contentHash": "DBBPX5JJ74SWZ",
|
||||
"schemaHash": "3K9ETAPGFPE32",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `
|
||||
{
|
||||
"type": "FJG23DR9456WA",
|
||||
"type": "8KMGVC6TG12TS",
|
||||
"value": "Name: {{ payload.name }}, Age: {{ payload.age }}",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = `
|
||||
{
|
||||
"type": "3JB2JHXHZG2Z1",
|
||||
"type": "6RZCT3XX4J8BC",
|
||||
"value": [
|
||||
{
|
||||
"contentHash": "FC8WACA792B6F",
|
||||
"schemaHash": "7XX5H51CVD9H0",
|
||||
"contentHash": "DBBPX5JJ74SWZ",
|
||||
"schemaHash": "3K9ETAPGFPE32",
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -227,14 +227,14 @@ exports[`Phase 4: Template System 4.3 template list shows registered templates 1
|
||||
|
||||
exports[`Phase 4: Template System 4.4 template delete removes template 1`] = `
|
||||
{
|
||||
"type": "0PYGQE16XPM70",
|
||||
"type": "DBHJH385HGVTM",
|
||||
"value": {
|
||||
"deleted": true,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 7XX5H51CVD9H0"`;
|
||||
exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 3K9ETAPGFPE32"`;
|
||||
|
||||
exports[`Phase 5: Render 5.1 render fills payload variables 1`] = `"Hello Alice!"`;
|
||||
|
||||
|
||||
@@ -60,6 +60,17 @@ const BOOTSTRAP_PAYLOAD = {
|
||||
enum: { type: "array" },
|
||||
const: {},
|
||||
description: { type: "string" },
|
||||
// P1 leaf constraints
|
||||
minimum: { type: "number" },
|
||||
maximum: { type: "number" },
|
||||
exclusiveMinimum: { type: "number" },
|
||||
exclusiveMaximum: { type: "number" },
|
||||
minLength: { type: "number" },
|
||||
maxLength: { type: "number" },
|
||||
pattern: { type: "string" },
|
||||
minItems: { type: "number" },
|
||||
maxItems: { type: "number" },
|
||||
uniqueItems: { type: "boolean" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -388,6 +388,107 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
expect(dataNode.type).not.toBe(metaHash);
|
||||
});
|
||||
|
||||
// ── P1 leaf constraints ──────────────────────────────────────────────────
|
||||
|
||||
test("accepts schema with numeric constraints (minimum/maximum)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "number",
|
||||
minimum: 0,
|
||||
maximum: 100,
|
||||
exclusiveMinimum: -1,
|
||||
exclusiveMaximum: 101,
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
// validate a conforming payload
|
||||
const nodeHash = await store.put(hash, 42);
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
expect(validate(store, node)).toBe(true);
|
||||
|
||||
// validate a non-conforming payload
|
||||
const badHash = await store.put(hash, 200);
|
||||
const badNode = store.get(badHash) as CasNode;
|
||||
expect(validate(store, badNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with string constraints (minLength/maxLength/pattern)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
maxLength: 10,
|
||||
pattern: "^[a-z]+$",
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const goodHash = await store.put(hash, "hello");
|
||||
expect(validate(store, store.get(goodHash) as CasNode)).toBe(true);
|
||||
|
||||
const badHash = await store.put(hash, "HELLO");
|
||||
expect(validate(store, store.get(badHash) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with array constraints (minItems/maxItems/uniqueItems)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
minItems: 1,
|
||||
maxItems: 3,
|
||||
uniqueItems: true,
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const goodHash = await store.put(hash, [1, 2, 3]);
|
||||
expect(validate(store, store.get(goodHash) as CasNode)).toBe(true);
|
||||
|
||||
const tooMany = await store.put(hash, [1, 2, 3, 4]);
|
||||
expect(validate(store, store.get(tooMany) as CasNode)).toBe(false);
|
||||
|
||||
const dupes = await store.put(hash, [1, 1]);
|
||||
expect(validate(store, store.get(dupes) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects schema with wrong constraint types", async () => {
|
||||
const store = createMemoryStore();
|
||||
await expect(
|
||||
putSchema(store, { type: "number", minimum: "zero" } as never),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
putSchema(store, { type: "string", maxLength: true } as never),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
putSchema(store, { type: "array", uniqueItems: 1 } as never),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("accepts schema with nested property constraints", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", minLength: 1, maxLength: 50 },
|
||||
age: { type: "number", minimum: 0, maximum: 150 },
|
||||
scores: {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
minItems: 1,
|
||||
uniqueItems: true,
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, {
|
||||
name: "Alice",
|
||||
age: 30,
|
||||
scores: [95, 87],
|
||||
});
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("bootstrap is idempotent across putSchema calls", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
@@ -35,6 +35,17 @@ const ALLOWED_SCHEMA_KEYS = new Set([
|
||||
"enum",
|
||||
"const",
|
||||
"description",
|
||||
// P1 leaf constraints (no collectRefs impact)
|
||||
"minimum",
|
||||
"maximum",
|
||||
"exclusiveMinimum",
|
||||
"exclusiveMaximum",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"pattern",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"uniqueItems",
|
||||
]);
|
||||
|
||||
const JSON_SCHEMA_TYPES = new Set([
|
||||
@@ -126,6 +137,29 @@ function isValidSchema(value: unknown): boolean {
|
||||
if (!Array.isArray(schema.enum) || schema.enum.length === 0) return false;
|
||||
}
|
||||
|
||||
// P1 leaf constraints — type checks only
|
||||
if ("minimum" in schema && typeof schema.minimum !== "number") return false;
|
||||
if ("maximum" in schema && typeof schema.maximum !== "number") return false;
|
||||
if (
|
||||
"exclusiveMinimum" in schema &&
|
||||
typeof schema.exclusiveMinimum !== "number"
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
"exclusiveMaximum" in schema &&
|
||||
typeof schema.exclusiveMaximum !== "number"
|
||||
)
|
||||
return false;
|
||||
if ("minLength" in schema && typeof schema.minLength !== "number")
|
||||
return false;
|
||||
if ("maxLength" in schema && typeof schema.maxLength !== "number")
|
||||
return false;
|
||||
if ("pattern" in schema && typeof schema.pattern !== "string") return false;
|
||||
if ("minItems" in schema && typeof schema.minItems !== "number") return false;
|
||||
if ("maxItems" in schema && typeof schema.maxItems !== "number") return false;
|
||||
if ("uniqueItems" in schema && typeof schema.uniqueItems !== "boolean")
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user