progressive-zod 1.0.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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/dist/__tests__/infer-schema.test.d.ts +1 -0
  3. package/dist/__tests__/infer-schema.test.js +121 -0
  4. package/dist/__tests__/progressive.test.d.ts +1 -0
  5. package/dist/__tests__/progressive.test.js +67 -0
  6. package/dist/cli/commands/infer.d.ts +1 -0
  7. package/dist/cli/commands/infer.js +26 -0
  8. package/dist/cli/commands/list.d.ts +1 -0
  9. package/dist/cli/commands/list.js +16 -0
  10. package/dist/cli/commands/stats.d.ts +1 -0
  11. package/dist/cli/commands/stats.js +19 -0
  12. package/dist/cli/commands/violations.d.ts +3 -0
  13. package/dist/cli/commands/violations.js +23 -0
  14. package/dist/cli/index.d.ts +2 -0
  15. package/dist/cli/index.js +29 -0
  16. package/dist/index.d.ts +3 -0
  17. package/dist/index.js +3 -0
  18. package/dist/infer-schema.d.ts +5 -0
  19. package/dist/infer-schema.js +176 -0
  20. package/dist/progressive.d.ts +3 -0
  21. package/dist/progressive.js +48 -0
  22. package/dist/redis-client.d.ts +6 -0
  23. package/dist/redis-client.js +48 -0
  24. package/dist/schema-to-code.d.ts +5 -0
  25. package/dist/schema-to-code.js +62 -0
  26. package/dist/types.d.ts +15 -0
  27. package/dist/types.js +1 -0
  28. package/package.json +28 -0
  29. package/src/__tests__/infer-schema.test.ts +152 -0
  30. package/src/__tests__/progressive.test.ts +89 -0
  31. package/src/cli/commands/infer.ts +32 -0
  32. package/src/cli/commands/list.ts +18 -0
  33. package/src/cli/commands/stats.ts +24 -0
  34. package/src/cli/commands/violations.ts +32 -0
  35. package/src/cli/index.ts +36 -0
  36. package/src/index.ts +3 -0
  37. package/src/infer-schema.ts +208 -0
  38. package/src/progressive.ts +56 -0
  39. package/src/redis-client.ts +56 -0
  40. package/src/schema-to-code.ts +66 -0
  41. package/src/types.ts +19 -0
  42. package/tsconfig.json +15 -0
  43. package/vitest.config.ts +8 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ferruccio Balestreri
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,121 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { inferSchema } from "../index";
3
+ describe("inferSchema", () => {
4
+ describe("1. Single shape", () => {
5
+ it("produces a plain z.object when all samples share the same keys and types", () => {
6
+ const samples = [
7
+ { name: "Alice", age: 30 },
8
+ { name: "Bob", age: 25 },
9
+ ];
10
+ const schema = inferSchema(samples);
11
+ // Should parse valid samples
12
+ for (const s of samples) {
13
+ expect(schema.parse(s)).toEqual(s);
14
+ }
15
+ // Should reject structurally incorrect data
16
+ expect(() => schema.parse({ name: "X" })).toThrow();
17
+ expect(() => schema.parse({ name: 1, age: 2 })).toThrow();
18
+ // Should be a ZodObject, not a union
19
+ expect(schema._zod.def.type).toBe("object");
20
+ });
21
+ });
22
+ describe("2. Discriminated union (string literal discriminator)", () => {
23
+ it("produces z.discriminatedUnion when samples share a key with different string literals per variant", () => {
24
+ const samples = [
25
+ { type: "user", name: "Alice", age: 30 },
26
+ { type: "org", name: "Acme", members: 5 },
27
+ ];
28
+ const schema = inferSchema(samples);
29
+ // Should parse valid samples
30
+ for (const s of samples) {
31
+ expect(schema.parse(s)).toEqual(s);
32
+ }
33
+ // Should reject data that doesn't match any variant
34
+ expect(() => schema.parse({ type: "user", name: "X", members: 3 })).toThrow();
35
+ expect(() => schema.parse({ type: "org", name: "X", age: 10 })).toThrow();
36
+ expect(() => schema.parse({ type: "unknown", name: "X" })).toThrow();
37
+ // Should be a ZodDiscriminatedUnion (zod v4 uses def.type "union" with a discriminator field)
38
+ expect(schema._zod.def.type).toBe("union");
39
+ expect(schema._zod.def.discriminator).toBe("type");
40
+ });
41
+ });
42
+ describe("3. Simple union (no discriminator)", () => {
43
+ it("produces z.union when samples have structurally different shapes with no common literal key", () => {
44
+ const samples = [
45
+ { x: 1, y: 2 },
46
+ { a: "hello", b: "world" },
47
+ ];
48
+ const schema = inferSchema(samples);
49
+ for (const s of samples) {
50
+ expect(schema.parse(s)).toEqual(s);
51
+ }
52
+ expect(() => schema.parse({ x: "wrong", y: 2 })).toThrow();
53
+ expect(() => schema.parse({ a: 1, b: 2 })).toThrow();
54
+ // Should be a ZodUnion, not a ZodObject
55
+ expect(schema._zod.def.type).toBe("union");
56
+ });
57
+ });
58
+ describe("4. Nested objects", () => {
59
+ it("infers nested object shapes correctly per variant", () => {
60
+ const samples = [
61
+ { type: "point2d", coords: { x: 1, y: 2 } },
62
+ { type: "point3d", coords: { x: 1, y: 2, z: 3 } },
63
+ ];
64
+ const schema = inferSchema(samples);
65
+ for (const s of samples) {
66
+ expect(schema.parse(s)).toEqual(s);
67
+ }
68
+ // Should reject wrong nested shape for a variant
69
+ expect(() => schema.parse({ type: "point2d", coords: { x: 1, y: 2, z: 3 } })).toThrow();
70
+ expect(() => schema.parse({ type: "point3d", coords: { x: 1, y: 2 } })).toThrow();
71
+ });
72
+ });
73
+ describe("5. Arrays", () => {
74
+ it("infers array element types", () => {
75
+ const samples = [
76
+ { tags: ["a", "b"], count: 2 },
77
+ { tags: ["c"], count: 1 },
78
+ ];
79
+ const schema = inferSchema(samples);
80
+ for (const s of samples) {
81
+ expect(schema.parse(s)).toEqual(s);
82
+ }
83
+ // Should reject wrong element types
84
+ expect(() => schema.parse({ tags: [1, 2], count: 1 })).toThrow();
85
+ });
86
+ });
87
+ describe("6. Mixed primitive types", () => {
88
+ it("produces z.union of primitives when a field has different types across samples of the same shape", () => {
89
+ const samples = [
90
+ { id: 1, value: "hello" },
91
+ { id: 2, value: 42 },
92
+ ];
93
+ const schema = inferSchema(samples);
94
+ for (const s of samples) {
95
+ expect(schema.parse(s)).toEqual(s);
96
+ }
97
+ // Both string and number should be accepted for value
98
+ expect(schema.parse({ id: 3, value: "world" })).toEqual({ id: 3, value: "world" });
99
+ expect(schema.parse({ id: 4, value: 99 })).toEqual({ id: 4, value: 99 });
100
+ // But boolean should not
101
+ expect(() => schema.parse({ id: 5, value: true })).toThrow();
102
+ });
103
+ });
104
+ describe("7. Minimality — no optional fields", () => {
105
+ it("produces a union of exact objects rather than one object with optional fields", () => {
106
+ const samples = [
107
+ { a: 1, b: 2 },
108
+ { a: 1, c: 3 },
109
+ ];
110
+ const schema = inferSchema(samples);
111
+ for (const s of samples) {
112
+ expect(schema.parse(s)).toEqual(s);
113
+ }
114
+ // Should reject data with wrong structure
115
+ expect(() => schema.parse({ a: 1 })).toThrow();
116
+ expect(() => schema.parse({ a: 1, b: 2, c: 3 })).toThrow();
117
+ // Should be a union, not a single object with optionals
118
+ expect(schema._zod.def.type).not.toBe("object");
119
+ });
120
+ });
121
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { z } from "zod/v4";
3
+ // Mock ioredis before importing progressive
4
+ const mockPipeline = {
5
+ lpush: vi.fn().mockReturnThis(),
6
+ ltrim: vi.fn().mockReturnThis(),
7
+ exec: vi.fn().mockResolvedValue([]),
8
+ };
9
+ const mockRedis = {
10
+ sadd: vi.fn().mockResolvedValue(1),
11
+ incr: vi.fn().mockResolvedValue(1),
12
+ pipeline: vi.fn().mockReturnValue(mockPipeline),
13
+ on: vi.fn().mockReturnThis(),
14
+ connect: vi.fn().mockResolvedValue(undefined),
15
+ disconnect: vi.fn(),
16
+ quit: vi.fn().mockResolvedValue("OK"),
17
+ };
18
+ vi.mock("ioredis", () => {
19
+ return {
20
+ default: function Redis() {
21
+ return mockRedis;
22
+ },
23
+ };
24
+ });
25
+ import { progressive } from "../progressive";
26
+ beforeEach(() => {
27
+ vi.clearAllMocks();
28
+ });
29
+ describe("progressive", () => {
30
+ it("always returns the input unchanged", () => {
31
+ const schema = progressive("Test");
32
+ const input = { foo: "bar", n: 42 };
33
+ expect(schema.parse(input)).toBe(input);
34
+ });
35
+ it("never throws, even with invalid data", () => {
36
+ const schema = progressive("Test", z.object({ id: z.string() }));
37
+ expect(() => schema.parse(123)).not.toThrow();
38
+ expect(schema.parse(123)).toBe(123);
39
+ });
40
+ it("registers the name in Redis", () => {
41
+ const schema = progressive("UserPayload");
42
+ schema.parse({ x: 1 });
43
+ expect(mockRedis.sadd).toHaveBeenCalledWith("pzod:names", "UserPayload");
44
+ });
45
+ it("records samples via pipeline", () => {
46
+ const schema = progressive("Test");
47
+ schema.parse({ hello: "world" });
48
+ expect(mockPipeline.lpush).toHaveBeenCalledWith("pzod:Test:samples", JSON.stringify({ hello: "world" }));
49
+ expect(mockPipeline.ltrim).toHaveBeenCalledWith("pzod:Test:samples", 0, 999);
50
+ });
51
+ it("increments conform count when schema matches", () => {
52
+ const schema = progressive("Order", z.object({ id: z.string() }));
53
+ schema.parse({ id: "abc" });
54
+ expect(mockRedis.incr).toHaveBeenCalledWith("pzod:Order:conform");
55
+ });
56
+ it("increments violate count and records violation when schema fails", () => {
57
+ const schema = progressive("Order", z.object({ id: z.string() }));
58
+ schema.parse({ id: 123 });
59
+ expect(mockRedis.incr).toHaveBeenCalledWith("pzod:Order:violate");
60
+ expect(mockPipeline.lpush).toHaveBeenCalledWith("pzod:Order:violations", JSON.stringify({ id: 123 }));
61
+ });
62
+ it("treats no-schema as violate", () => {
63
+ const schema = progressive("Unknown");
64
+ schema.parse({ a: 1 });
65
+ expect(mockRedis.incr).toHaveBeenCalledWith("pzod:Unknown:violate");
66
+ });
67
+ });
@@ -0,0 +1 @@
1
+ export declare function inferCommand(name: string): Promise<void>;
@@ -0,0 +1,26 @@
1
+ import { getRedis, getConfig, disconnectRedis } from "../../redis-client";
2
+ import { inferSchema } from "../../infer-schema";
3
+ import { schemaToCode } from "../../schema-to-code";
4
+ export async function inferCommand(name) {
5
+ const redis = getRedis();
6
+ const { keyPrefix } = getConfig();
7
+ const items = await redis.lrange(`${keyPrefix}${name}:samples`, 0, -1);
8
+ if (items.length === 0) {
9
+ console.log(`No samples recorded for "${name}".`);
10
+ await disconnectRedis();
11
+ return;
12
+ }
13
+ const samples = items.map((item) => {
14
+ try {
15
+ return JSON.parse(item);
16
+ }
17
+ catch {
18
+ return item;
19
+ }
20
+ });
21
+ const schema = inferSchema(samples);
22
+ const code = schemaToCode(schema);
23
+ console.log(`Inferred schema for "${name}" (from ${samples.length} samples):\n`);
24
+ console.log(`const ${name} = ${code};`);
25
+ await disconnectRedis();
26
+ }
@@ -0,0 +1 @@
1
+ export declare function listCommand(): Promise<void>;
@@ -0,0 +1,16 @@
1
+ import { getRedis, getConfig, disconnectRedis } from "../../redis-client";
2
+ export async function listCommand() {
3
+ const redis = getRedis();
4
+ const { keyPrefix } = getConfig();
5
+ const names = await redis.smembers(`${keyPrefix}names`);
6
+ if (names.length === 0) {
7
+ console.log("No monitored types found.");
8
+ }
9
+ else {
10
+ console.log("Monitored types:\n");
11
+ for (const name of names.sort()) {
12
+ console.log(` - ${name}`);
13
+ }
14
+ }
15
+ await disconnectRedis();
16
+ }
@@ -0,0 +1 @@
1
+ export declare function statsCommand(name: string): Promise<void>;
@@ -0,0 +1,19 @@
1
+ import { getRedis, getConfig, disconnectRedis } from "../../redis-client";
2
+ export async function statsCommand(name) {
3
+ const redis = getRedis();
4
+ const { keyPrefix } = getConfig();
5
+ const prefix = `${keyPrefix}${name}`;
6
+ const [conform, violate] = await Promise.all([
7
+ redis.get(`${prefix}:conform`),
8
+ redis.get(`${prefix}:violate`),
9
+ ]);
10
+ console.log(`Stats for "${name}":\n`);
11
+ console.log(` Conform: ${conform ?? 0}`);
12
+ console.log(` Violate: ${violate ?? 0}`);
13
+ const total = Number(conform ?? 0) + Number(violate ?? 0);
14
+ if (total > 0) {
15
+ const pct = ((Number(conform ?? 0) / total) * 100).toFixed(1);
16
+ console.log(` Rate: ${pct}% conforming`);
17
+ }
18
+ await disconnectRedis();
19
+ }
@@ -0,0 +1,3 @@
1
+ export declare function violationsCommand(name: string, options: {
2
+ limit?: string;
3
+ }): Promise<void>;
@@ -0,0 +1,23 @@
1
+ import { getRedis, getConfig, disconnectRedis } from "../../redis-client";
2
+ export async function violationsCommand(name, options) {
3
+ const redis = getRedis();
4
+ const { keyPrefix } = getConfig();
5
+ const limit = Number(options.limit) || 10;
6
+ const items = await redis.lrange(`${keyPrefix}${name}:violations`, 0, limit - 1);
7
+ if (items.length === 0) {
8
+ console.log(`No violations recorded for "${name}".`);
9
+ }
10
+ else {
11
+ console.log(`Recent violations for "${name}" (${items.length}):\n`);
12
+ for (const item of items) {
13
+ try {
14
+ console.log(JSON.stringify(JSON.parse(item), null, 2));
15
+ }
16
+ catch {
17
+ console.log(item);
18
+ }
19
+ console.log();
20
+ }
21
+ }
22
+ await disconnectRedis();
23
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { listCommand } from "./commands/list";
4
+ import { statsCommand } from "./commands/stats";
5
+ import { violationsCommand } from "./commands/violations";
6
+ import { inferCommand } from "./commands/infer";
7
+ const program = new Command();
8
+ program
9
+ .name("progressive-zod")
10
+ .description("Runtime type observability for TypeScript")
11
+ .version("1.0.0");
12
+ program
13
+ .command("list")
14
+ .description("List all monitored types")
15
+ .action(listCommand);
16
+ program
17
+ .command("stats <name>")
18
+ .description("Show conform/violate counts for a type")
19
+ .action(statsCommand);
20
+ program
21
+ .command("violations <name>")
22
+ .description("Show non-conforming payloads for a type")
23
+ .option("-l, --limit <n>", "Number of violations to show", "10")
24
+ .action(violationsCommand);
25
+ program
26
+ .command("infer <name>")
27
+ .description("Suggest a zod schema from observed data")
28
+ .action(inferCommand);
29
+ program.parse();
@@ -0,0 +1,3 @@
1
+ export { inferSchema } from "./infer-schema";
2
+ export { progressive } from "./progressive";
3
+ export { configure } from "./redis-client";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { inferSchema } from "./infer-schema";
2
+ export { progressive } from "./progressive";
3
+ export { configure } from "./redis-client";
@@ -0,0 +1,5 @@
1
+ import { z } from "zod/v4";
2
+ /**
3
+ * Infer a zod schema from observed sample objects.
4
+ */
5
+ export declare function inferSchema(samples: unknown[]): z.ZodTypeAny;
@@ -0,0 +1,176 @@
1
+ import { z } from "zod/v4";
2
+ /**
3
+ * Compute a deep shape signature that includes nested object structures.
4
+ */
5
+ function shapeSignature(obj) {
6
+ const keys = Object.keys(obj).sort();
7
+ return keys
8
+ .map((k) => {
9
+ const v = obj[k];
10
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
11
+ return `${k}:{${shapeSignature(v)}}`;
12
+ }
13
+ return k;
14
+ })
15
+ .join(",");
16
+ }
17
+ /**
18
+ * Infer a zod type for a single field from observed values.
19
+ */
20
+ function inferFieldType(values) {
21
+ const types = new Set();
22
+ const nestedObjects = [];
23
+ const arrayElements = [];
24
+ for (const v of values) {
25
+ if (v === null) {
26
+ types.add("null");
27
+ }
28
+ else if (Array.isArray(v)) {
29
+ types.add("array");
30
+ arrayElements.push(...v);
31
+ }
32
+ else if (typeof v === "object") {
33
+ types.add("object");
34
+ nestedObjects.push(v);
35
+ }
36
+ else {
37
+ types.add(typeof v);
38
+ }
39
+ }
40
+ const schemas = [];
41
+ if (types.has("string"))
42
+ schemas.push(z.string());
43
+ if (types.has("number"))
44
+ schemas.push(z.number());
45
+ if (types.has("boolean"))
46
+ schemas.push(z.boolean());
47
+ if (types.has("null"))
48
+ schemas.push(z.null());
49
+ if (types.has("object"))
50
+ schemas.push(inferSchema(nestedObjects));
51
+ if (types.has("array")) {
52
+ if (arrayElements.length > 0) {
53
+ schemas.push(z.array(inferFieldType(arrayElements)));
54
+ }
55
+ else {
56
+ schemas.push(z.array(z.unknown()));
57
+ }
58
+ }
59
+ if (schemas.length === 1)
60
+ return schemas[0];
61
+ return z.union(schemas);
62
+ }
63
+ /**
64
+ * Build a strict z.object from a group of samples that share the same shape.
65
+ */
66
+ function buildObjectSchema(samples) {
67
+ if (samples.length === 0)
68
+ return z.strictObject({});
69
+ const keys = Object.keys(samples[0]).sort();
70
+ const shape = {};
71
+ for (const key of keys) {
72
+ const values = samples.map((s) => s[key]);
73
+ shape[key] = inferFieldType(values);
74
+ }
75
+ return z.strictObject(shape);
76
+ }
77
+ /**
78
+ * Find a discriminator key across all samples: a key present in every sample
79
+ * where each unique string value maps to a distinct variant.
80
+ */
81
+ function findDiscriminatorAcrossSamples(objects) {
82
+ if (objects.length < 2)
83
+ return null;
84
+ const candidateKeys = Object.keys(objects[0]);
85
+ for (const key of candidateKeys) {
86
+ // All samples must have this key with a string value
87
+ let valid = true;
88
+ const valueToSamples = new Map();
89
+ for (const obj of objects) {
90
+ const val = obj[key];
91
+ if (typeof val !== "string") {
92
+ valid = false;
93
+ break;
94
+ }
95
+ const group = valueToSamples.get(val);
96
+ if (group) {
97
+ group.push(obj);
98
+ }
99
+ else {
100
+ valueToSamples.set(val, [obj]);
101
+ }
102
+ }
103
+ if (!valid)
104
+ continue;
105
+ // Need at least 2 distinct values to be a discriminator
106
+ if (valueToSamples.size < 2)
107
+ continue;
108
+ // Each value must be constant within its group (it is by construction)
109
+ // and values must be distinct across groups (they are by Map keys)
110
+ return key;
111
+ }
112
+ return null;
113
+ }
114
+ /**
115
+ * Build a discriminated union from samples grouped by discriminator value.
116
+ */
117
+ function buildDiscriminatedUnion(discriminator, objects) {
118
+ const valueToSamples = new Map();
119
+ for (const obj of objects) {
120
+ const val = obj[discriminator];
121
+ const group = valueToSamples.get(val);
122
+ if (group) {
123
+ group.push(obj);
124
+ }
125
+ else {
126
+ valueToSamples.set(val, [obj]);
127
+ }
128
+ }
129
+ const options = [...valueToSamples.entries()].map(([discValue, groupSamples]) => {
130
+ const keys = Object.keys(groupSamples[0]).sort();
131
+ const shape = {};
132
+ for (const key of keys) {
133
+ if (key === discriminator) {
134
+ shape[key] = z.literal(discValue);
135
+ }
136
+ else {
137
+ const values = groupSamples.map((s) => s[key]);
138
+ shape[key] = inferFieldType(values);
139
+ }
140
+ }
141
+ return z.strictObject(shape);
142
+ });
143
+ return z.discriminatedUnion(discriminator, options);
144
+ }
145
+ /**
146
+ * Infer a zod schema from observed sample objects.
147
+ */
148
+ export function inferSchema(samples) {
149
+ const objects = samples.filter((s) => s !== null && typeof s === "object" && !Array.isArray(s));
150
+ if (objects.length === 0)
151
+ return z.unknown();
152
+ // Group by shape signature (deep, including nested object structures)
153
+ const groups = new Map();
154
+ for (const obj of objects) {
155
+ const sig = shapeSignature(obj);
156
+ const group = groups.get(sig);
157
+ if (group) {
158
+ group.push(obj);
159
+ }
160
+ else {
161
+ groups.set(sig, [obj]);
162
+ }
163
+ }
164
+ // Single group → single object schema
165
+ if (groups.size === 1) {
166
+ return buildObjectSchema([...groups.values()][0]);
167
+ }
168
+ // Multiple groups — check for discriminator across all samples
169
+ const discriminator = findDiscriminatorAcrossSamples(objects);
170
+ if (discriminator) {
171
+ return buildDiscriminatedUnion(discriminator, objects);
172
+ }
173
+ // Multiple groups, no discriminator → plain union
174
+ const options = [...groups.values()].map((groupSamples) => buildObjectSchema(groupSamples));
175
+ return z.union(options);
176
+ }
@@ -0,0 +1,3 @@
1
+ import { z } from "zod/v4";
2
+ import type { ProgressiveSchema } from "./types";
3
+ export declare function progressive(name: string, schema?: z.ZodTypeAny): ProgressiveSchema;
@@ -0,0 +1,48 @@
1
+ import { getRedis, getConfig } from "./redis-client";
2
+ export function progressive(name, schema) {
3
+ return {
4
+ name,
5
+ parse(input) {
6
+ const redis = getRedis();
7
+ const { keyPrefix, maxViolations, maxSamples } = getConfig();
8
+ const prefix = `${keyPrefix}${name}`;
9
+ // Fire-and-forget: register name
10
+ redis.sadd(`${keyPrefix}names`, name).catch(() => { });
11
+ // Fire-and-forget: record sample
12
+ const serialized = JSON.stringify(input);
13
+ redis
14
+ .pipeline()
15
+ .lpush(`${prefix}:samples`, serialized)
16
+ .ltrim(`${prefix}:samples`, 0, maxSamples - 1)
17
+ .exec()
18
+ .catch(() => { });
19
+ if (schema) {
20
+ const result = schema.safeParse(input);
21
+ if (result.success) {
22
+ redis.incr(`${prefix}:conform`).catch(() => { });
23
+ }
24
+ else {
25
+ redis.incr(`${prefix}:violate`).catch(() => { });
26
+ redis
27
+ .pipeline()
28
+ .lpush(`${prefix}:violations`, serialized)
29
+ .ltrim(`${prefix}:violations`, 0, maxViolations - 1)
30
+ .exec()
31
+ .catch(() => { });
32
+ }
33
+ }
34
+ else {
35
+ // No schema — everything is a violation
36
+ redis.incr(`${prefix}:violate`).catch(() => { });
37
+ redis
38
+ .pipeline()
39
+ .lpush(`${prefix}:violations`, serialized)
40
+ .ltrim(`${prefix}:violations`, 0, maxViolations - 1)
41
+ .exec()
42
+ .catch(() => { });
43
+ }
44
+ // Always return input unchanged, never throw
45
+ return input;
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,6 @@
1
+ import Redis from "ioredis";
2
+ import type { ProgressiveConfig } from "./types";
3
+ export declare function configure(opts: ProgressiveConfig): void;
4
+ export declare function getConfig(): Required<ProgressiveConfig>;
5
+ export declare function getRedis(): Redis;
6
+ export declare function disconnectRedis(): Promise<void>;
@@ -0,0 +1,48 @@
1
+ import Redis from "ioredis";
2
+ let client = null;
3
+ let config = {};
4
+ export function configure(opts) {
5
+ config = { ...config, ...opts };
6
+ // Reset client so next access picks up new config
7
+ if (client) {
8
+ client.disconnect();
9
+ client = null;
10
+ }
11
+ }
12
+ export function getConfig() {
13
+ return {
14
+ redisUrl: config.redisUrl ??
15
+ process.env.PROGRESSIVE_ZOD_REDIS_URL ??
16
+ "redis://localhost:6379",
17
+ keyPrefix: config.keyPrefix ??
18
+ process.env.PROGRESSIVE_ZOD_KEY_PREFIX ??
19
+ "pzod:",
20
+ maxViolations: config.maxViolations ??
21
+ (Number(process.env.PROGRESSIVE_ZOD_MAX_VIOLATIONS) || 1000),
22
+ maxSamples: config.maxSamples ??
23
+ (Number(process.env.PROGRESSIVE_ZOD_MAX_SAMPLES) || 1000),
24
+ };
25
+ }
26
+ export function getRedis() {
27
+ if (!client) {
28
+ const { redisUrl } = getConfig();
29
+ client = new Redis(redisUrl, { lazyConnect: true, maxRetriesPerRequest: 1 });
30
+ client.on("error", (err) => {
31
+ if (process.env.PROGRESSIVE_ZOD_DEBUG) {
32
+ console.error("[progressive-zod] Redis error:", err.message);
33
+ }
34
+ });
35
+ client.connect().catch(() => {
36
+ // swallow — fire-and-forget
37
+ });
38
+ }
39
+ return client;
40
+ }
41
+ export function disconnectRedis() {
42
+ if (client) {
43
+ const c = client;
44
+ client = null;
45
+ return c.quit().then(() => { });
46
+ }
47
+ return Promise.resolve();
48
+ }
@@ -0,0 +1,5 @@
1
+ import { z } from "zod/v4";
2
+ /**
3
+ * Serialize a zod schema to a TypeScript code string.
4
+ */
5
+ export declare function schemaToCode(schema: z.ZodTypeAny, indent?: number): string;