wire.ts100 lines · main
| 1 | import { z } from 'zod'; |
| 2 | |
| 3 | import { isIdentifier } from './table.js'; |
| 4 | |
| 5 | const RESERVED_PREFIX = '_briven_'; |
| 6 | |
| 7 | const SAFE_SQL_TYPE_RE = |
| 8 | /^(text|integer|bigint|boolean|timestamptz|jsonb|uuid|varchar\(\d+\)|vector\(\d+\))$/; |
| 9 | |
| 10 | const 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 | |
| 22 | const identifier = z |
| 23 | .string() |
| 24 | .min(1) |
| 25 | .max(63) |
| 26 | .refine((s) => isIdentifier(s), { message: 'invalid identifier' }); |
| 27 | |
| 28 | const userTableName = identifier.refine((s) => !s.startsWith(RESERVED_PREFIX), { |
| 29 | message: `name must not start with reserved prefix '${RESERVED_PREFIX}'`, |
| 30 | }); |
| 31 | |
| 32 | const 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 | |
| 40 | const 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 | |
| 47 | const onDelete = z.enum(['cascade', 'set null', 'restrict']); |
| 48 | |
| 49 | const 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 | |
| 65 | const indexDef = z |
| 66 | .object({ |
| 67 | columns: z.array(identifier).min(1), |
| 68 | unique: z.boolean(), |
| 69 | }); |
| 70 | |
| 71 | const accessRules = z |
| 72 | .object({ |
| 73 | read: z.string().max(2048).optional(), |
| 74 | write: z.string().max(2048).optional(), |
| 75 | }); |
| 76 | |
| 77 | const 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). |
| 91 | export const schemaSnapshotSchema = z.object({ |
| 92 | version: z.literal(1), |
| 93 | tables: z.record(userTableName, tableDef), |
| 94 | }); |
| 95 | |
| 96 | export type SchemaSnapshotWire = z.infer<typeof schemaSnapshotSchema>; |
| 97 | |
| 98 | export function validateSchemaSnapshot(input: unknown): SchemaSnapshotWire { |
| 99 | return schemaSnapshotSchema.parse(input); |
| 100 | } |