wire.ts100 lines · main
1import { z } from 'zod';
2
3import { isIdentifier } from './table.js';
4
5const RESERVED_PREFIX = '_briven_';
6
7const SAFE_SQL_TYPE_RE =
8 /^(text|integer|bigint|boolean|timestamptz|jsonb|uuid|varchar\(\d+\)|vector\(\d+\))$/;
9
10const SAFE_DEFAULT_RE = new RegExp(
11 '^(' +
12 'null' +
13 '|true|false' +
14 "|'[^']*'" +
15 '|-?\\d+(\\.\\d+)?' +
16 '|[a-zA-Z_][a-zA-Z0-9_]{0,62}\\(\\)' +
17 '|current_(?:timestamp|date|time|user)' +
18 ')$',
19 'i',
20);
21
22const identifier = z
23 .string()
24 .min(1)
25 .max(63)
26 .refine((s) => isIdentifier(s), { message: 'invalid identifier' });
27
28const userTableName = identifier.refine((s) => !s.startsWith(RESERVED_PREFIX), {
29 message: `name must not start with reserved prefix '${RESERVED_PREFIX}'`,
30});
31
32const safeSqlType = z
33 .string()
34 .max(64)
35 .refine((s) => SAFE_SQL_TYPE_RE.test(s), {
36 message:
37 'sqlType must be one of text|integer|bigint|boolean|timestamptz|jsonb|uuid|varchar(N)|vector(N)',
38 });
39
40const safeDefault = z
41 .string()
42 .max(256)
43 .refine((s) => SAFE_DEFAULT_RE.test(s), {
44 message: 'default must be a literal, identifier()-call, or recognised SQL constant',
45 });
46
47const onDelete = z.enum(['cascade', 'set null', 'restrict']);
48
49const columnDef = z
50 .object({
51 sqlType: safeSqlType,
52 nullable: z.boolean(),
53 primaryKey: z.boolean(),
54 unique: z.boolean(),
55 default: safeDefault.optional(),
56 references: z
57 .object({
58 table: identifier,
59 column: identifier,
60 onDelete: onDelete.optional(),
61 })
62 .optional(),
63 });
64
65const indexDef = z
66 .object({
67 columns: z.array(identifier).min(1),
68 unique: z.boolean(),
69 });
70
71const accessRules = z
72 .object({
73 read: z.string().max(2048).optional(),
74 write: z.string().max(2048).optional(),
75 });
76
77const tableDef = z
78 .object({
79 columns: z.record(identifier, columnDef),
80 indexes: z.array(indexDef),
81 access: accessRules.optional(),
82 });
83
84// Unknown keys are STRIPPED (zod's default), not rejected — this makes the wire
85// format forward-compatible with newer @briven/cli versions that add fields to
86// the snapshot. A `.strict()` reject was the cause of the 0.2 ↔ 0.3 CLI schema
87// mismatch. Safety is unaffected: the value-level guards (identifier /
88// safeSqlType / safeDefault) still block anything unsafe, and schema-apply only
89// reads fields it knows. `version` stays a hard literal — bump only on a
90// genuinely breaking change (that's the deliberate compatibility gate).
91export const schemaSnapshotSchema = z.object({
92 version: z.literal(1),
93 tables: z.record(userTableName, tableDef),
94});
95
96export type SchemaSnapshotWire = z.infer<typeof schemaSnapshotSchema>;
97
98export function validateSchemaSnapshot(input: unknown): SchemaSnapshotWire {
99 return schemaSnapshotSchema.parse(input);
100}