@@ -33,15 +33,74 @@ import type {
3333import type { Project } from '@cfp/shared/schemas' ;
3434
3535/**
36- * Cast a Zod v4 schema to gitsheets' StandardSchemaV1.
36+ * Recursively drop `null` / `undefined`-valued keys from a record.
37+ *
38+ * gitsheets 2.x (Rust core) refuses to marshal `null` or `undefined` field
39+ * values — `serializeRecords`/`upsert` throw `cannot marshal JS value of type
40+ * Null/Undefined to a TOML value`. gitsheets 1.4.1 (`@iarna/toml`) silently
41+ * dropped such keys instead, so they were never written to disk: an absent
42+ * optional field is simply an absent TOML key (verified against the on-disk
43+ * `published` snapshot — no record carries a `null`-valued key).
44+ *
45+ * Our Zod schemas mark optional fields `.nullable().optional()` and the write
46+ * services normalize "cleared" fields to `?? null`. To keep the on-disk form
47+ * byte-identical to 1.4.1 (and to keep those `?? null` write paths working),
48+ * we strip null/undefined keys here, at the single write boundary, before the
49+ * record reaches the core marshaller. Nested tables (objects) are cleaned
50+ * recursively; arrays are passed through untouched (TOML has no null in
51+ * arrays, and our schemas never emit sparse arrays).
52+ *
53+ * `null` as an explicit "delete this field" signal only exists for
54+ * `Sheet.patch` (RFC 7396 merge-patch); we don't use `patch`, so stripping on
55+ * the full-record `upsert` path is unambiguous.
56+ */
57+ function stripNullish ( value : unknown ) : unknown {
58+ if ( value === null || value === undefined ) return undefined ;
59+ if ( Array . isArray ( value ) ) return value ;
60+ if ( typeof value === 'object' && ! ( value instanceof Date ) ) {
61+ const out : Record < string , unknown > = { } ;
62+ for ( const [ k , v ] of Object . entries ( value as Record < string , unknown > ) ) {
63+ const cleaned = stripNullish ( v ) ;
64+ if ( cleaned !== undefined ) out [ k ] = cleaned ;
65+ }
66+ return out ;
67+ }
68+ return value ;
69+ }
70+
71+ /**
72+ * Cast a Zod v4 schema to gitsheets' StandardSchemaV1, wrapping its validator
73+ * so the validated record has null/undefined-valued keys stripped before it
74+ * reaches gitsheets' core marshaller.
75+ *
76+ * gitsheets runs the Standard Schema validator host-side and marshals the
77+ * validator's *output* — so stripping here (rather than at every upsert call
78+ * site) is the single, authoritative write boundary. See `stripNullish`.
3779 *
3880 * Zod v4 implements the Standard Schema interface at runtime, but TypeScript
3981 * cannot prove that Zod's Result type is assignable to gitsheets' narrow
4082 * StandardSchemaResult because of a structural mismatch in the FailureResult
4183 * shape. Both are correct at runtime; the cast is safe.
4284 */
4385function asValidator < T extends Record < string , unknown > > ( schema : unknown ) : StandardSchemaV1 < unknown , T > {
44- return schema as StandardSchemaV1 < unknown , T > ;
86+ const inner = schema as StandardSchemaV1 < unknown , T > ;
87+ const innerValidate = inner [ '~standard' ] . validate ;
88+ return {
89+ ...inner ,
90+ '~standard' : {
91+ ...inner [ '~standard' ] ,
92+ validate : ( value : unknown ) => {
93+ const result = innerValidate ( value ) ;
94+ const strip = (
95+ r : Awaited < ReturnType < typeof innerValidate > > ,
96+ ) : Awaited < ReturnType < typeof innerValidate > > =>
97+ r . issues === undefined
98+ ? { value : stripNullish ( r . value ) as T }
99+ : r ;
100+ return result instanceof Promise ? result . then ( strip ) : strip ( result ) ;
101+ } ,
102+ } ,
103+ } ;
45104}
46105
47106/** Typed validator map for openStore. */
0 commit comments