From caef2a9f814a1ec245eeaadebf57260c39bb8951 Mon Sep 17 00:00:00 2001 From: Erny Sans Date: Sat, 22 Aug 2026 20:21:09 -0500 Subject: [PATCH] fix(schema): accept null for fields Firestore stores as null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zod's `.optional()` accepts `undefined` and rejects `null`. Firestore stores an absent optional field as an explicit `null` under common write patterns, so a document written normally failed a schema that declared that field `.optional()`. The schemas rejected the documents they exist to validate. Measured before the change, read-only against stored data: 25 of 25 sampled account documents and 12 of 12 sampled price documents were rejected. On the price documents `limit` — seat capacity — was null in 11 of 12. Every optional field on a stored-document schema is now `.nullish()`, and the matching interface property is annotated `| null`. Those are one change: a schema that accepts null while the interface promises it cannot occur is a runtime-versus-declaration mismatch that no type checking can see. 193 schema fields and 164 interface properties, across every model and the base, place and queue mixins. `null` is preserved in the parse output rather than folded into `undefined`. Folding would delete the stored field on a read-modify-write, and `x === undefined` and `'key' in obj` answer differently for the two. The loosening is bounded to null and nothing else. Required fields still reject null, because a required field carrying null is the load-bearing absence the requirement exists to stop. The nine instant-valued fields validated by `auditTimestamp()` or `timestampLike()` also still reject it and stay `.optional()`: an explicitly null timestamp is not a time, and read as one it becomes epoch zero, which sorts first and — for a TTL — expires the document immediately. No stored null was observed in any of those nine. No schema in this package validates an inbound payload; every parse boundary here reads a stored document. That was checked rather than assumed, with a positive control on the search. An inbound schema would keep `.optional()`, because a JSON body omits a key rather than nulling it, and must be declared separately rather than reusing one of these. The policy inventory in test/interface/schema.test.ts is now a reject-list rather than an accept-list, so it shrinks toward empty and a later blanket loosening turns it red; an accept-list would have grown silently instead. Stored-layout regression fixtures for Account, Price and EventData carry null in the same fields the stored documents do, because a fixture using `undefined` there parses identically under both spellings and asserts nothing. Twelve mutations were run against them — reverting a fixed field, loosening a kept timestamp, loosening a required field — and all twelve turned red. Separately, the claim that Account's SchemaOutput pinned `linksSchema` to the upstream links definition was false, and was false before this change: a `z.looseObject`'s inferred index signature does not supply named members to satisfy an optional target property, so a member added upstream checked clean. Measured by adding one and watching the build stay green. `LinksKeysCovered` now compares keys and fails on an added or renamed member; both cases were confirmed red, with the unchanged upstream confirmed green. Stored accounts also carry geohash, latitude, longitude and placeId, which the account interface does not declare. They pass through as unknown keys and never caused a rejection; whether to model them is left to the owner of that shape. --- .../serialized-models.instructions.md | 38 +- lib/interface/base_db.d.ts | 26 +- lib/interface/base_db.js | 18 +- lib/interface/place.d.ts | 130 +++--- lib/interface/place.js | 68 ++-- lib/interface/queue.d.ts | 24 +- lib/interface/queue.js | 8 +- lib/interface/schema.d.ts | 11 + lib/interface/schema.js | 11 + lib/model/Account.d.ts | 374 +++++++++++------- lib/model/Account.js | 146 ++++--- lib/model/Block.d.ts | 8 +- lib/model/Block.js | 4 +- lib/model/Capacity.d.ts | 8 +- lib/model/Entitlement.d.ts | 16 +- lib/model/Entitlement.js | 6 +- lib/model/EventData.d.ts | 108 ++--- lib/model/EventData.js | 42 +- lib/model/Idempotency.d.ts | 22 +- lib/model/Idempotency.js | 8 +- lib/model/Ledger.d.ts | 12 +- lib/model/Ledger.js | 6 +- lib/model/MessageUsage.d.ts | 14 +- lib/model/MessageUsage.js | 4 +- lib/model/MessagingEvent.d.ts | 74 ++-- lib/model/MessagingEvent.js | 38 +- lib/model/Post.d.ts | 104 ++--- lib/model/Post.js | 50 +-- lib/model/Price.d.ts | 76 ++-- lib/model/Price.js | 43 +- src/interface/base_db.ts | 22 +- src/interface/place.ts | 110 +++--- src/interface/queue.ts | 16 +- src/interface/schema.ts | 11 + src/model/Account.ts | 295 ++++++++------ src/model/Block.ts | 8 +- src/model/Entitlement.ts | 12 +- src/model/EventData.ts | 82 ++-- src/model/Idempotency.ts | 16 +- src/model/Ledger.ts | 10 +- src/model/MessageUsage.ts | 8 +- src/model/MessagingEvent.ts | 72 ++-- src/model/Post.ts | 100 ++--- src/model/Price.ts | 78 ++-- test/interface/schema.test.ts | 117 ++++-- test/model/Account.test.ts | 127 ++++++ test/model/EventData.test.ts | 93 +++++ test/model/Price.test.ts | 110 ++++++ 48 files changed, 1726 insertions(+), 1058 deletions(-) diff --git a/.github/instructions/serialized-models.instructions.md b/.github/instructions/serialized-models.instructions.md index 4116df2..c07b7be 100644 --- a/.github/instructions/serialized-models.instructions.md +++ b/.github/instructions/serialized-models.instructions.md @@ -33,10 +33,40 @@ it is a wrong shape replicated into every consumer. (`Account.Interface`). Enums sit beside it in the same namespace. - **Fields are optional (`?`) by default** for stored entities. Firestore documents are sparse and partially populated; a required field in the type is a promise the datastore does not keep. -- **`?` and `| null` mean different things.** Use `?` for "may not be present" and `| null` for - "explicitly absent and must survive a JSON round-trip" — `undefined` keys are dropped by - `JSON.stringify`, `null` keys are not. `place.ts` uses `| null` deliberately; match the - surrounding convention rather than mixing. +- **A stored optional field must be declared `?: T | null` and validated with `.nullish()`.** + This is not a style preference, it is what the datastore does. Firestore stores an absent + optional field as an **explicit `null`** under common write patterns, so `?` alone describes a + shape that stored documents do not have. A schema built from `.optional()` rejects `null`, which + means it rejects the very documents it exists to validate — measured at 25/25 stored `account` + documents and 12/12 stored `price` documents before this was fixed. + - Declare `.nullish()` on the schema field **and** `| null` on the interface property, together. + They are one change. A schema that accepts `null` while the interface promises it cannot occur + is the runtime-versus-declaration mismatch that no amount of type checking can see. + - The inventory in `test/interface/schema.test.ts` (`nullRejecting`) enforces this. It is + deliberately a **reject-list**, so it shrinks toward empty and a blanket loosening turns it + red — an accept-list would silently grow instead. +- **`?` and `| null` mean different things, and a stored field is usually both.** `?` is "the key + may not be present"; `| null` is "the key is present and explicitly empty, and that must survive + a JSON round-trip" — `undefined` keys are dropped by `JSON.stringify`, `null` keys are not. Both + occur in stored data, which is why `.nullish()` rather than either alone is the default there. + Keep `null` in the parse output rather than folding it to `undefined`: a read-modify-write + through a folding schema deletes the stored field, and `x === undefined` and `'key' in obj` give + different answers for the two. +- **Two exemptions, and only these two.** Both are inventoried in the test above: + - A **required** field never accepts `null`. A required field carrying `null` is exactly the + load-bearing absence the requirement exists to stop. + - An **instant-valued** field — anything validated by `auditTimestamp()` or `timestampLike()` — + stays `.optional()` and keeps rejecting `null`. An explicitly null timestamp is not a time, and + reading one as epoch zero sorts it first and expires it immediately. No stored null was + observed in any of these fields, so this exemption costs nothing today; if one is ever + observed, the fix is a documented decision about what a null instant means, **not** a blanket + loosening. +- **Validating an inbound payload is a different job from reading a stored document.** No schema in + this package currently validates an HTTP body, a callable `data` argument or a webhook payload — + every `parse`/`safeParse` here is a stored-document boundary. If one is ever added, `.optional()` + is correct for it, because a JSON body genuinely omits a key rather than nulling it, and + `.nullish()` there would weaken untrusted-input validation. **Do not reuse a stored-document + schema for an inbound payload; declare a separate one.** - **Constrain string enumerations with an enum or a union.** Where a raw stored value must be tolerated, use `T | string` — **never `T | any`**, which collapses to `any` and silently stops discriminating while still reading as though it constrains something. diff --git a/lib/interface/base_db.d.ts b/lib/interface/base_db.d.ts index 7265717..59d9441 100644 --- a/lib/interface/base_db.d.ts +++ b/lib/interface/base_db.d.ts @@ -18,13 +18,13 @@ export interface BaseFirestore { /** * Firestore document identifier, typically the auto-generated document ID. */ - id?: string; + id?: string | null; /** * Backup status flag for long-term historical storage. * When `true`, the document has been successfully backed up to the * historical long-term database. When `false`, the backup is still pending. */ - backup?: boolean; + backup?: boolean | null; /** * Server-side timestamp recorded when the document was first created. */ @@ -61,16 +61,30 @@ export interface BaseFirestore { * at the boundary instead, where it can actually be checked. See * {@link auditTimestamp} for the shapes accepted, what is rejected, and how to * widen a schema for a write payload. + * + * {@link BaseFirestore.id} and {@link BaseFirestore.backup} are `.nullish()`, + * because a stored document writes an unset optional field as an explicit + * `null` rather than omitting it, and a field that accepted only `undefined` + * would reject documents that are otherwise entirely well formed. + * + * The three timestamps are deliberately **not** `.nullish()` and keep rejecting + * `null`. An explicitly null timestamp is not a time: read as one it becomes + * epoch zero, which sorts first and — for {@link BaseFirestore.expiry}, a TTL — + * expires the document immediately. No stored null was observed in any of the + * three, so the exemption costs nothing today. If one is ever observed, the fix + * is a decision about what a null instant means, recorded here, rather than a + * blanket loosening. The `nullRejecting` inventory in + * `test/interface/schema.test.ts` is what holds that line. */ export declare const baseFirestoreShape: { /** * See {@link BaseFirestore.id}. */ - id: z.ZodOptional; + id: z.ZodOptional>; /** * See {@link BaseFirestore.backup}. */ - backup: z.ZodOptional; + backup: z.ZodOptional>; /** * See {@link BaseFirestore.created}. */ @@ -100,8 +114,8 @@ export declare const baseFirestoreShape: { * schema instead. */ export declare const BaseFirestoreSchema: z.ZodObject<{ - id: z.ZodOptional; - backup: z.ZodOptional; + id: z.ZodOptional>; + backup: z.ZodOptional>; created: z.ZodOptional>>; updated: z.ZodOptional>>; expiry: z.ZodOptional>>; diff --git a/lib/interface/base_db.js b/lib/interface/base_db.js index df42f3f..5c1a587 100644 --- a/lib/interface/base_db.js +++ b/lib/interface/base_db.js @@ -21,16 +21,30 @@ import { auditTimestamp, documentId } from './schema.js'; * at the boundary instead, where it can actually be checked. See * {@link auditTimestamp} for the shapes accepted, what is rejected, and how to * widen a schema for a write payload. + * + * {@link BaseFirestore.id} and {@link BaseFirestore.backup} are `.nullish()`, + * because a stored document writes an unset optional field as an explicit + * `null` rather than omitting it, and a field that accepted only `undefined` + * would reject documents that are otherwise entirely well formed. + * + * The three timestamps are deliberately **not** `.nullish()` and keep rejecting + * `null`. An explicitly null timestamp is not a time: read as one it becomes + * epoch zero, which sorts first and — for {@link BaseFirestore.expiry}, a TTL — + * expires the document immediately. No stored null was observed in any of the + * three, so the exemption costs nothing today. If one is ever observed, the fix + * is a decision about what a null instant means, recorded here, rather than a + * blanket loosening. The `nullRejecting` inventory in + * `test/interface/schema.test.ts` is what holds that line. */ export const baseFirestoreShape = { /** * See {@link BaseFirestore.id}. */ - id: documentId().optional(), + id: documentId().nullish(), /** * See {@link BaseFirestore.backup}. */ - backup: z.boolean().optional(), + backup: z.boolean().nullish(), /** * See {@link BaseFirestore.created}. */ diff --git a/lib/interface/place.d.ts b/lib/interface/place.d.ts index 1f7a475..c998cd9 100644 --- a/lib/interface/place.d.ts +++ b/lib/interface/place.d.ts @@ -22,41 +22,41 @@ export interface BasePlaceData { /** * GeoJSON-style `[longitude, latitude]` coordinate pair for the place. */ - location?: number[]; + location?: number[] | null; /** * Google Places API place identifier for the location. */ - placeId?: string; + placeId?: string | null; /** * Decimal degrees latitude of the place. */ - latitude?: number; + latitude?: number | null; /** * Decimal degrees longitude of the place. */ - longitude?: number; + longitude?: number | null; /** * Human-readable display name for the place. */ - placeName?: string; + placeName?: string | null; /** * UTC offset in minutes for the place's local timezone. */ - utcOffset?: number; + utcOffset?: number | null; /** * ISO 3166-1 alpha-2 country code (e.g., `"US"`, `"CA"`). */ - country?: string; + country?: string | null; /** * Geohash string encoding the place's latitude/longitude for efficient * proximity queries in Firestore. */ - geohash?: string; + geohash?: string | null; /** * Administrative region/state/province name, also known as the geographic * area designation. */ - area?: string; + area?: string | null; } /** * Field schemas for {@link BasePlaceData}, exported as a raw shape so documents @@ -69,42 +69,42 @@ export declare const basePlaceDataShape: { * unconstrained: the published type is `number[]`, and rejecting a stored * array of another length would narrow it. */ - location: z.ZodOptional>; + location: z.ZodOptional>>; /** * See {@link BasePlaceData.placeId}. */ - placeId: z.ZodOptional; + placeId: z.ZodOptional>; /** * See {@link BasePlaceData.latitude}. */ - latitude: z.ZodOptional; + latitude: z.ZodOptional>; /** * See {@link BasePlaceData.longitude}. */ - longitude: z.ZodOptional; + longitude: z.ZodOptional>; /** * See {@link BasePlaceData.placeName}. */ - placeName: z.ZodOptional; + placeName: z.ZodOptional>; /** * See {@link BasePlaceData.utcOffset}. */ - utcOffset: z.ZodOptional; + utcOffset: z.ZodOptional>; /** * See {@link BasePlaceData.country}. Accepted as any non-empty string rather * than a two-letter code: the field is documented as ISO 3166-1 alpha-2, but * narrowing a published field to a fixed length would reject any stored * document that predates that convention. */ - country: z.ZodOptional; + country: z.ZodOptional>; /** * See {@link BasePlaceData.geohash}. */ - geohash: z.ZodOptional; + geohash: z.ZodOptional>; /** * See {@link BasePlaceData.area}. */ - area: z.ZodOptional; + area: z.ZodOptional>; }; /** * Runtime schema producing {@link BasePlaceData}. @@ -118,15 +118,15 @@ export declare const basePlaceDataShape: { * unconditionally. Compose it into a concrete document schema instead. */ export declare const BasePlaceDataSchema: z.ZodObject<{ - location: z.ZodOptional>; - placeId: z.ZodOptional; - latitude: z.ZodOptional; - longitude: z.ZodOptional; - placeName: z.ZodOptional; - utcOffset: z.ZodOptional; - country: z.ZodOptional; - geohash: z.ZodOptional; - area: z.ZodOptional; + location: z.ZodOptional>>; + placeId: z.ZodOptional>; + latitude: z.ZodOptional>; + longitude: z.ZodOptional>; + placeName: z.ZodOptional>; + utcOffset: z.ZodOptional>; + country: z.ZodOptional>; + geohash: z.ZodOptional>; + area: z.ZodOptional>; }, z.core.$loose>; /** * Compile-time proof that {@link BasePlaceDataSchema} produces @@ -146,11 +146,11 @@ export interface PlaceData { * ISO 8601 timestamp string recorded when this place document was first * created; required for auditing. */ - created?: string; + created?: string | null; /** * Unique Firestore document identifier for this place; required for lookups. */ - id?: string; + id?: string | null; /** * Short administrative area (region/state) name, or `null` if unavailable. */ @@ -162,7 +162,7 @@ export interface PlaceData { /** * Numeric identifiers for parent area documents used in hierarchical queries. */ - areas?: number[]; + areas?: number[] | null; /** * Short city name, or `null` if unavailable. */ @@ -182,16 +182,16 @@ export interface PlaceData { /** * Decimal degrees latitude; required for geospatial queries. */ - latitude?: number; + latitude?: number | null; /** * Decimal degrees longitude; required for geospatial queries. */ - longitude?: number; + longitude?: number | null; /** * When `true`, indicates this place is local/domestic relative to the * primary operating region; required for filtering. */ - local?: boolean; + local?: boolean | null; /** * Full display name of the place, or `null` if unavailable. */ @@ -215,27 +215,27 @@ export interface PlaceData { /** * UTC offset in minutes for the place's timezone; required for scheduling. */ - timeOffset?: number; + timeOffset?: number | null; /** * IANA timezone identifier (e.g., `"America/New_York"`); required for * accurate local-time calculations. */ - timeZoneId?: string; + timeZoneId?: string | null; /** * Human-readable timezone name (e.g., `"Eastern Standard Time"`); required * for display purposes. */ - timeZoneName?: string; + timeZoneName?: string | null; /** * Administrative level of this place as categorised by {@link PlaceType}; * required for hierarchical filtering. */ - type?: PlaceType; + type?: PlaceType | null; /** * ISO 8601 timestamp string recorded the last time this document was * modified; required for cache invalidation. */ - updated?: string; + updated?: string | null; /** * Public URL for this place on an external directory or maps service, or * `null` if unavailable. @@ -259,7 +259,7 @@ export interface PlaceData { latitude: number; longitude: number; }; - }; + } | null; } /** * Field schemas for {@link PlaceData}, exported as a raw shape for composition @@ -269,11 +269,11 @@ export declare const placeDataShape: { /** * See {@link PlaceData.created}. */ - created: z.ZodOptional; + created: z.ZodOptional>; /** * See {@link PlaceData.id}. */ - id: z.ZodOptional; + id: z.ZodOptional>; /** * See {@link PlaceData.area}. */ @@ -285,7 +285,7 @@ export declare const placeDataShape: { /** * See {@link PlaceData.areas}. */ - areas: z.ZodOptional>; + areas: z.ZodOptional>>; /** * See {@link PlaceData.city}. */ @@ -305,15 +305,15 @@ export declare const placeDataShape: { /** * See {@link PlaceData.latitude}. */ - latitude: z.ZodOptional; + latitude: z.ZodOptional>; /** * See {@link PlaceData.longitude}. */ - longitude: z.ZodOptional; + longitude: z.ZodOptional>; /** * See {@link PlaceData.local}. */ - local: z.ZodOptional; + local: z.ZodOptional>; /** * See {@link PlaceData.longName}. */ @@ -340,25 +340,25 @@ export declare const placeDataShape: { /** * See {@link PlaceData.timeOffset}. */ - timeOffset: z.ZodOptional; + timeOffset: z.ZodOptional>; /** * See {@link PlaceData.timeZoneId}. */ - timeZoneId: z.ZodOptional; + timeZoneId: z.ZodOptional>; /** * See {@link PlaceData.timeZoneName}. */ - timeZoneName: z.ZodOptional; + timeZoneName: z.ZodOptional>; /** * See {@link PlaceData.type}. Constrained to {@link PlaceType}, so an * unrecognised administrative level is rejected instead of being asserted into * the enum by a cast. */ - type: z.ZodOptional>; + type: z.ZodOptional>>; /** * See {@link PlaceData.updated}. */ - updated: z.ZodOptional; + updated: z.ZodOptional>; /** * See {@link PlaceData.url}. */ @@ -370,7 +370,7 @@ export declare const placeDataShape: { /** * See {@link PlaceData.viewport}. */ - viewport: z.ZodOptional; - }, z.core.$loose>>; + }, z.core.$loose>>>; }; /** * Runtime schema producing {@link PlaceData}. @@ -389,31 +389,31 @@ export declare const placeDataShape: { * round-trip without losing the fields this version does not know about. */ export declare const PlaceDataSchema: z.ZodObject<{ - created: z.ZodOptional; - id: z.ZodOptional; + created: z.ZodOptional>; + id: z.ZodOptional>; area: z.ZodOptional>; areaLong: z.ZodOptional>; - areas: z.ZodOptional>; + areas: z.ZodOptional>>; city: z.ZodOptional>; cityLong: z.ZodOptional>; country: z.ZodOptional>; countryLong: z.ZodOptional>; - latitude: z.ZodOptional; - longitude: z.ZodOptional; - local: z.ZodOptional; + latitude: z.ZodOptional>; + longitude: z.ZodOptional>; + local: z.ZodOptional>; longName: z.ZodOptional>; name: z.ZodOptional>; postalCode: z.ZodOptional>; state: z.ZodOptional>; stateLong: z.ZodOptional>; - timeOffset: z.ZodOptional; - timeZoneId: z.ZodOptional; - timeZoneName: z.ZodOptional; - type: z.ZodOptional>; - updated: z.ZodOptional; + timeOffset: z.ZodOptional>; + timeZoneId: z.ZodOptional>; + timeZoneName: z.ZodOptional>; + type: z.ZodOptional>>; + updated: z.ZodOptional>; url: z.ZodOptional>; vicinity: z.ZodOptional>; - viewport: z.ZodOptional; - }, z.core.$loose>>; + }, z.core.$loose>>>; }, z.core.$loose>; /** * Compile-time proof that {@link PlaceDataSchema} produces {@link PlaceData}. diff --git a/lib/interface/place.js b/lib/interface/place.js index 13721f6..d6c4834 100644 --- a/lib/interface/place.js +++ b/lib/interface/place.js @@ -54,42 +54,42 @@ export const basePlaceDataShape = { * unconstrained: the published type is `number[]`, and rejecting a stored * array of another length would narrow it. */ - location: z.array(z.number()).optional(), + location: z.array(z.number()).nullish(), /** * See {@link BasePlaceData.placeId}. */ - placeId: nonEmptyString().optional(), + placeId: nonEmptyString().nullish(), /** * See {@link BasePlaceData.latitude}. */ - latitude: latitudeDegrees().optional(), + latitude: latitudeDegrees().nullish(), /** * See {@link BasePlaceData.longitude}. */ - longitude: longitudeDegrees().optional(), + longitude: longitudeDegrees().nullish(), /** * See {@link BasePlaceData.placeName}. */ - placeName: nonEmptyString().optional(), + placeName: nonEmptyString().nullish(), /** * See {@link BasePlaceData.utcOffset}. */ - utcOffset: utcOffsetMinutes().optional(), + utcOffset: utcOffsetMinutes().nullish(), /** * See {@link BasePlaceData.country}. Accepted as any non-empty string rather * than a two-letter code: the field is documented as ISO 3166-1 alpha-2, but * narrowing a published field to a fixed length would reject any stored * document that predates that convention. */ - country: nonEmptyString().optional(), + country: nonEmptyString().nullish(), /** * See {@link BasePlaceData.geohash}. */ - geohash: nonEmptyString().optional(), + geohash: nonEmptyString().nullish(), /** * See {@link BasePlaceData.area}. */ - area: nonEmptyString().optional(), + area: nonEmptyString().nullish(), }; /** * Runtime schema producing {@link BasePlaceData}. @@ -128,108 +128,108 @@ export const placeDataShape = { /** * See {@link PlaceData.created}. */ - created: nonEmptyString().optional(), + created: nonEmptyString().nullish(), /** * See {@link PlaceData.id}. */ - id: nonEmptyString().optional(), + id: nonEmptyString().nullish(), /** * See {@link PlaceData.area}. */ - area: z.string().nullable().optional(), + area: z.string().nullish(), /** * See {@link PlaceData.areaLong}. */ - areaLong: z.string().nullable().optional(), + areaLong: z.string().nullish(), /** * See {@link PlaceData.areas}. */ - areas: z.array(z.number()).optional(), + areas: z.array(z.number()).nullish(), /** * See {@link PlaceData.city}. */ - city: z.string().nullable().optional(), + city: z.string().nullish(), /** * See {@link PlaceData.cityLong}. */ - cityLong: z.string().nullable().optional(), + cityLong: z.string().nullish(), /** * See {@link PlaceData.country}. */ - country: z.string().nullable().optional(), + country: z.string().nullish(), /** * See {@link PlaceData.countryLong}. */ - countryLong: z.string().nullable().optional(), + countryLong: z.string().nullish(), /** * See {@link PlaceData.latitude}. */ - latitude: latitudeDegrees().optional(), + latitude: latitudeDegrees().nullish(), /** * See {@link PlaceData.longitude}. */ - longitude: longitudeDegrees().optional(), + longitude: longitudeDegrees().nullish(), /** * See {@link PlaceData.local}. */ - local: z.boolean().optional(), + local: z.boolean().nullish(), /** * See {@link PlaceData.longName}. */ - longName: z.string().nullable().optional(), + longName: z.string().nullish(), /** * See {@link PlaceData.name}. */ - name: z.string().nullable().optional(), + name: z.string().nullish(), /** * See {@link PlaceData.postalCode}. Numeric rather than string by declaration; * a postal code supplied as text is rejected here rather than coerced, because * `Number('SW1A')` is `NaN` and a `NaN` postal code matches nothing while * looking like a value. */ - postalCode: z.number().nullable().optional(), + postalCode: z.number().nullish(), /** * See {@link PlaceData.state}. */ - state: z.string().nullable().optional(), + state: z.string().nullish(), /** * See {@link PlaceData.stateLong}. */ - stateLong: z.string().nullable().optional(), + stateLong: z.string().nullish(), /** * See {@link PlaceData.timeOffset}. */ - timeOffset: utcOffsetMinutes().optional(), + timeOffset: utcOffsetMinutes().nullish(), /** * See {@link PlaceData.timeZoneId}. */ - timeZoneId: nonEmptyString().optional(), + timeZoneId: nonEmptyString().nullish(), /** * See {@link PlaceData.timeZoneName}. */ - timeZoneName: nonEmptyString().optional(), + timeZoneName: nonEmptyString().nullish(), /** * See {@link PlaceData.type}. Constrained to {@link PlaceType}, so an * unrecognised administrative level is rejected instead of being asserted into * the enum by a cast. */ - type: z.enum(PlaceType).optional(), + type: z.enum(PlaceType).nullish(), /** * See {@link PlaceData.updated}. */ - updated: nonEmptyString().optional(), + updated: nonEmptyString().nullish(), /** * See {@link PlaceData.url}. */ - url: z.string().nullable().optional(), + url: z.string().nullish(), /** * See {@link PlaceData.vicinity}. */ - vicinity: z.string().nullable().optional(), + vicinity: z.string().nullish(), /** * See {@link PlaceData.viewport}. */ - viewport: viewportCornerBoxSchema().optional(), + viewport: viewportCornerBoxSchema().nullish(), }; /** * Builds the bounding-box schema for {@link PlaceData.viewport}. diff --git a/lib/interface/queue.d.ts b/lib/interface/queue.d.ts index cd0904a..eac2e8b 100644 --- a/lib/interface/queue.d.ts +++ b/lib/interface/queue.d.ts @@ -17,21 +17,21 @@ export interface MessageQueue { * Number of messages that have been created but not yet validated or approved * for sending. */ - pending?: number; + pending?: number | null; /** * Number of messages that have passed validation and are ready to be picked * up by the sender worker. */ - ready?: number; + ready?: number | null; /** * Number of messages currently assigned to a sender worker for processing. */ - sender?: number; + sender?: number | null; /** * Number of messages actively being transmitted to the downstream messaging * provider (e.g., Twilio). */ - sending?: number; + sending?: number | null; /** * Arbitrary snapshot or metadata captured at the time the queue was last * counted; used for auditing and diagnostics. @@ -53,19 +53,19 @@ export declare const messageQueueShape: { /** * See {@link MessageQueue.pending}. */ - pending: z.ZodOptional; + pending: z.ZodOptional>; /** * See {@link MessageQueue.ready}. */ - ready: z.ZodOptional; + ready: z.ZodOptional>; /** * See {@link MessageQueue.sender}. */ - sender: z.ZodOptional; + sender: z.ZodOptional>; /** * See {@link MessageQueue.sending}. */ - sending: z.ZodOptional; + sending: z.ZodOptional>; /** * See {@link MessageQueue.counted}. Deliberately open: the field is declared * as an arbitrary diagnostic snapshot and constraining it here would narrow a @@ -81,10 +81,10 @@ export declare const messageQueueShape: { * the fields the schema does not name. */ export declare const MessageQueueSchema: z.ZodObject<{ - pending: z.ZodOptional; - ready: z.ZodOptional; - sender: z.ZodOptional; - sending: z.ZodOptional; + pending: z.ZodOptional>; + ready: z.ZodOptional>; + sender: z.ZodOptional>; + sending: z.ZodOptional>; counted: z.ZodOptional; }, z.core.$loose>; /** diff --git a/lib/interface/queue.js b/lib/interface/queue.js index 76fa59a..3f88803 100644 --- a/lib/interface/queue.js +++ b/lib/interface/queue.js @@ -19,19 +19,19 @@ export const messageQueueShape = { /** * See {@link MessageQueue.pending}. */ - pending: counter().optional(), + pending: counter().nullish(), /** * See {@link MessageQueue.ready}. */ - ready: counter().optional(), + ready: counter().nullish(), /** * See {@link MessageQueue.sender}. */ - sender: counter().optional(), + sender: counter().nullish(), /** * See {@link MessageQueue.sending}. */ - sending: counter().optional(), + sending: counter().nullish(), /** * See {@link MessageQueue.counted}. Deliberately open: the field is declared * as an arbitrary diagnostic snapshot and constraining it here would narrow a diff --git a/lib/interface/schema.d.ts b/lib/interface/schema.d.ts index c17e526..9af4be0 100644 --- a/lib/interface/schema.d.ts +++ b/lib/interface/schema.d.ts @@ -561,6 +561,17 @@ export declare const timestampLike: () => z.ZodType} Schema accepting any read shape of a stored timestamp. */ export declare const auditTimestamp: () => z.ZodType; diff --git a/lib/interface/schema.js b/lib/interface/schema.js index df6fc7a..9f37800 100644 --- a/lib/interface/schema.js +++ b/lib/interface/schema.js @@ -326,6 +326,17 @@ export const timestampLike = () => z.custom(isTimestampLike, { error: 'Expected * time, and treating it as one produces an epoch-zero date that sorts first and * expires immediately. * + * That rejection is why every field validated by this helper stays `.optional()` + * while the rest of the package's stored-document fields are `.nullish()`. The + * general rule there is that a stored optional field arrives as an explicit + * `null`, so a schema must accept one; the exception here is that for an instant + * specifically, accepting `null` would hand a caller a value that reads as a + * date and denotes 1970. The exemption is inventoried in `nullRejecting` in + * `test/interface/schema.test.ts`, so it cannot be widened silently — and it is + * an exemption rather than a preference: if a stored document is ever observed + * carrying `null` in one of these fields, the correct response is to decide what + * a null instant means and record it, not to reach for `.nullish()`. + * * @return {z.ZodType} Schema accepting any read shape of a stored timestamp. */ export const auditTimestamp = () => z.custom((candidate) => { diff --git a/lib/model/Account.d.ts b/lib/model/Account.d.ts index a6cea1d..cceac6c 100644 --- a/lib/model/Account.d.ts +++ b/lib/model/Account.d.ts @@ -26,7 +26,7 @@ export declare namespace Account { * status to `paused` is the required mechanism for stopping the queue * without permanently deactivating the account. */ - enum Status { + export enum Status { active = "active", inactive = "inactive", suspended = "suspended", @@ -38,7 +38,7 @@ export declare namespace Account { * High-level classification of the account organisation type, used for * Twilio brand registration and compliance routing. */ - enum Type { + export enum Type { business = "business", government = "government", nonProfit = "non_profit" @@ -47,7 +47,7 @@ export declare namespace Account { * Access-control role assigned to a user within an account, used by * Firestore security rules and Cloud Function permission checks. */ - enum Roles { + export enum Roles { agent = "agent", admin = "admin", owner = "owner", @@ -57,7 +57,7 @@ export declare namespace Account { * Accepted job-position values for the authorised representative during * Twilio brand registration. */ - enum AuthorizedRepresentativeJobPosition { + export enum AuthorizedRepresentativeJobPosition { director = "Director", gm = "GM", vp = "VP", @@ -73,32 +73,32 @@ export declare namespace Account { * Twilio requires at least one authorised representative; a second is * optional for additional verification. */ - interface AuthorizedRepresentative { + export interface AuthorizedRepresentative { /** * Representative's legal first name. */ - firstName?: string; + firstName?: string | null; /** * Representative's legal last name. */ - lastName?: string; + lastName?: string | null; /** * Representative's business email address. */ - email?: string; + email?: string | null; /** * Representative's direct phone number in E.164 format. */ - phoneNumber?: string; + phoneNumber?: string | null; /** * Representative's business title as it appears on company documents. */ - businessTitle?: string; + businessTitle?: string | null; /** * Representative's seniority or functional role; see * {@link AuthorizedRepresentativeJobPosition} for accepted values. */ - jobPosition?: AuthorizedRepresentativeJobPosition; + jobPosition?: AuthorizedRepresentativeJobPosition | null; } /** * Twilio brand type required for A2P 10DLC registration; determined @@ -107,7 +107,7 @@ export declare namespace Account { * Accepted values for the Twilio API are `'SOLE_PROPRIETOR'`, * `'LOW_VOLUME_STANDARD'`, and `'STANDARD'`. */ - enum BrandType { + export enum BrandType { soleProprietor = "SOLE_PROPRIETOR", lowVolumeStandard = "LOW_VOLUME_STANDARD", standard = "STANDARD" @@ -119,7 +119,7 @@ export declare namespace Account { * Accepted values are `'public'`, `'private'`, `'non-profit'`, and * `'government'`. */ - enum CompanyType { + export enum CompanyType { public = "public", private = "private", nonProfit = "non-profit", @@ -133,7 +133,7 @@ export declare namespace Account { * `'Limited Liability Corporation'`, `'Co-operative'`, * `'Non-profit Corporation'`, and `'Corporation'`. */ - enum BusinessType { + export enum BusinessType { soleProprietorship = "Sole Proprietorship", partnership = "Partnership", limitedLiabilityCorporation = "Limited Liability Corporation", @@ -148,7 +148,7 @@ export declare namespace Account { * The full list of accepted values is documented in the inline comment above * this enum. */ - enum BusinessIndustry { + export enum BusinessIndustry { automotive = "AUTOMOTIVE", agriculture = "AGRICULTURE", banking = "BANKING", @@ -190,7 +190,7 @@ export declare namespace Account { * Accepted values are `'AFRICA'`, `'ASIA'`, `'EUROPE'`, `'LATIN_AMERICA'`, * `'USA_AND_CANADA'`, and `'AUSTRALIA'`. */ - enum BusinessRegionsOfOperations { + export enum BusinessRegionsOfOperations { africa = "AFRICA", asia = "ASIA", europe = "EUROPE", @@ -208,7 +208,7 @@ export declare namespace Account { * Note: to register for A2P 10DLC, select `CBN` — `CCN` is no longer * accepted by Twilio for Canadian registrations. */ - enum BusinessRegistrationIdentifier { + export enum BusinessRegistrationIdentifier { ein = "EIN", duns = "DUNS", ccn = "CCN", @@ -229,7 +229,7 @@ export declare namespace Account { * accepted exchange codes is documented in the inline comment above this * enum. */ - enum StockExchange { + export enum StockExchange { none = "NONE", nasdaq = "NASDAQ", nyse = "NYSE", @@ -267,7 +267,7 @@ export declare namespace Account { * The full list of accepted values is documented in the inline comment above * this enum. */ - enum AppToPersonUseCase { + export enum AppToPersonUseCase { twoFactorAuthentication = "2FA", accountNotification = "ACCOUNT_NOTIFICATION", agentsFranchises = "AGENTS_FRANCHISES", @@ -288,6 +288,25 @@ export declare namespace Account { social = "SOCIAL", sweepstake = "SWEEPSTAKE" } + /** + * Social and web links, derived from the shared-helpers `User.InterfaceLinks` + * definition with every member additionally permitted to be `null`. + * + * Declared as a mapped type over `User.InterfaceLinks` rather than as a + * hand-written copy, so a member added or renamed upstream appears here + * automatically and this type cannot drift from the definition it is derived + * from. The runtime schema is a separate hand-written copy and *can* drift; + * {@link LinksKeysCovered} is what catches that. + * + * The `| null` is what the stored documents actually require. A link that has + * never been filled in is written as an explicit `null` rather than omitted, + * and `User.InterfaceLinks` alone cannot describe that — which is also why + * this type exists rather than the field being declared `User.InterfaceLinks` + * directly. + */ + export type Links = { + [TMember in keyof User.InterfaceLinks]?: User.InterfaceLinks[TMember] | null; + }; /** * Firestore document shape for a Furcata account. * @@ -297,28 +316,28 @@ export declare namespace Account { * and campaign registration, enforce messaging queue limits, manage billing * via Stripe Connected Accounts, and control domain-based authentication. */ - interface Interface extends BaseFirestore, MessageQueue { + export interface Interface extends BaseFirestore, MessageQueue { /** * Preferred language. */ - language?: string; + language?: string | null; /** * Image path. */ - image?: string; + image?: string | null; /** * Full image URL for quick use. */ - imageURL?: string; + imageURL?: string | null; /** * Account name. * It should be the legal name or in case of sending on behalf an eleted official, use that name */ - name?: string; + name?: string | null; /** * Legal business name. */ - businessName?: string; + businessName?: string | null; /** * The name to use. * Examples: @@ -326,149 +345,151 @@ export declare namespace Account { * If they don't match: businessName (name) * This works for example in case of registering a government organization that sends on behalf of elected official */ - useName?: string; + useName?: string | null; /** * Public site description. */ - description?: string; + description?: string | null; /** * Current lifecycle status of the account; see {@link Status} for accepted * values. */ - status?: Status; + status?: Status | null; /** * Organisation classification; see {@link Type} for accepted values. */ - type?: Type; + type?: Type | null; /** * Firebase Auth UID of the account owner. */ - uid?: string; + uid?: string | null; /** * Social and web links associated with the account, sourced from the - * shared-helpers `User.InterfaceLinks` definition. + * shared-helpers `User.InterfaceLinks` definition; see {@link Links} for why + * the field is declared through a mapped type rather than as + * `User.InterfaceLinks` directly. */ - links?: User.InterfaceLinks; + links?: Links | null; /** * Legal company structure; see {@link CompanyType} for accepted values. * Submitted to Twilio during brand registration. */ - companyType?: CompanyType; + companyType?: CompanyType | null; /** * Stock exchange on which the company is listed; see {@link StockExchange} * for accepted values. Use `NONE` for private companies. */ - stockExchange?: StockExchange; + stockExchange?: StockExchange | null; /** * Ticker symbol of the company on the `stockExchange`, if publicly traded. */ - stockTicker?: string; + stockTicker?: string | null; /** * Legal form of the business entity; see {@link BusinessType} for accepted * values. */ - businessType?: BusinessType; + businessType?: BusinessType | null; /** * Regions where the account operates; see {@link BusinessRegionsOfOperations} * for accepted values. */ - businessRegionsOfOperations?: BusinessRegionsOfOperations; + businessRegionsOfOperations?: BusinessRegionsOfOperations | null; /** * Type of government-issued registration number provided; see * {@link BusinessRegistrationIdentifier} for accepted values. Must be * sent to Twilio in uppercase. */ - businessRegistrationIdentifier?: BusinessRegistrationIdentifier; + businessRegistrationIdentifier?: BusinessRegistrationIdentifier | null; /** * Primary industry of the account; see {@link BusinessIndustry} for * accepted values. */ - businessIndustry?: BusinessIndustry; + businessIndustry?: BusinessIndustry | null; /** * Government-issued business registration number corresponding to the * `businessRegistrationIdentifier` type (e.g., EIN, DUNS). */ - businessRegistrationNumber?: string; + businessRegistrationNumber?: string | null; /** * Primary authorised representative for Twilio brand registration. */ - authorizedRepresentative1?: AuthorizedRepresentative; + authorizedRepresentative1?: AuthorizedRepresentative | null; /** * Secondary authorised representative for Twilio brand registration * (optional). */ - authorizedRepresentative2?: AuthorizedRepresentative; + authorizedRepresentative2?: AuthorizedRepresentative | null; /** * Estimated monthly message volume used to auto-select the appropriate * `brandType` for Twilio A2P 10DLC registration. */ - estimatedVolume?: number; + estimatedVolume?: number | null; /** * Twilio brand tier derived from `estimatedVolume`; see {@link BrandType} * for accepted values. */ - brandType?: BrandType; + brandType?: BrandType | null; /** * A2P 10DLC campaign use case; see {@link AppToPersonUseCase} for accepted * values. Submitted to Twilio during campaign registration. */ - appToPersonUseCase?: AppToPersonUseCase; + appToPersonUseCase?: AppToPersonUseCase | null; /** * Declared use-case description for toll-free number campaign registration. */ - tollFreeUseCase?: string; + tollFreeUseCase?: string | null; /** * Detailed description of the use case to use on the 10DLC registration and for Twilio to understand the use case and be able to approve it. */ - useCaseDescription?: string; + useCaseDescription?: string | null; /** * Shorter and to the point to use on the opt-in consent. */ - useCaseDescriptionCTA?: string; + useCaseDescriptionCTA?: string | null; /** * This is used to turn on/off the automatic header that is added to the top of the message for compliance reasons. * This is a custom feature for bulk and test messages in case the customer wants to use their own header or put the identification on the footer. * This should not be used for transactional messages. */ - automaticHeader?: boolean; + automaticHeader?: boolean | null; /** * Postal or ZIP code of the account's registered business address. */ - postalCode?: string; + postalCode?: string | null; /** * Administrative region (state/province) of the business address. */ - area?: string; + area?: string | null; /** * City of the business address. */ - city?: string; + city?: string | null; /** * First line of the street address. */ - street1?: string; + street1?: string | null; /** * Second line of the street address (suite, floor, etc.). */ - street2?: string; + street2?: string | null; /** * ISO 3166-1 alpha-2 country code for the business address (e.g., `"US"`). */ - country?: string; + country?: string | null; /** * UTC offset in minutes for the place's local timezone. */ - utcOffset?: number; + utcOffset?: number | null; /** * Custom domain associated with this account (e.g., `"example.com"`), used * for domain-based authentication and white-labelling. */ - domain?: string; + domain?: string | null; /** * When `true`, the `domain` value has been verified and is active for * routing. */ - domainOk?: boolean; + domainOk?: boolean | null; /** * Timestamp recording when the domain was last verified or checked. */ @@ -477,34 +498,75 @@ export declare namespace Account { * Short alphanumeric alias for this account used in public-facing URLs * and API routes. */ - alias?: string; + alias?: string | null; /** * First sample message submitted to Twilio during A2P 10DLC campaign * registration to demonstrate the type of content that will be sent. */ - sampleMessage1?: string; + sampleMessage1?: string | null; /** * Second sample message for Twilio campaign registration. */ - sampleMessage2?: string; + sampleMessage2?: string | null; /** * Third sample message for Twilio campaign registration. */ - sampleMessage3?: string; + sampleMessage3?: string | null; /** * Fourth sample message for Twilio campaign registration. */ - sampleMessage4?: string; + sampleMessage4?: string | null; /** * Fifth sample message for Twilio campaign registration. */ - sampleMessage5?: string; + sampleMessage5?: string | null; /** * Stripe Connected Account ID used for billing and payment processing on * behalf of this account. */ - bca?: string; + bca?: string | null; } + /** + * Schema for the social and web links block sourced from the shared-helpers + * `User.InterfaceLinks` definition. + * + * Declared here rather than imported because that package ships types only. + * {@link LinksKeysCovered} below is what keeps this copy honest — **not** the + * compile-time proof on {@link Schema}, which cannot see a member added + * upstream. `z.looseObject` infers a `[x: string]: unknown` index signature, + * and an index signature on the source of an assignment does not supply named + * members to satisfy an optional property on the target, so a new upstream + * `mastodon?: string` is simply read as absent-and-optional and checks clean. + * That was measured, by adding a member upstream and observing the build stay + * green. + */ + const linksSchema: z.ZodObject<{ + behance: z.ZodOptional>; + dribbble: z.ZodOptional>; + facebook: z.ZodOptional>; + instagram: z.ZodOptional>; + linkedin: z.ZodOptional>; + tiktok: z.ZodOptional>; + x: z.ZodOptional>; + youtube: z.ZodOptional>; + website: z.ZodOptional>; + }, z.core.$loose>; + /** + * Compile-time proof that {@link linksSchema} declares a field for **every** + * member of the shared-helpers `User.InterfaceLinks` definition. + * + * This is the drift detector for the hand-maintained copy above, and it is + * separate from {@link SchemaOutput} because the two catch opposite failures. + * `SchemaOutput` compares inferred *values* and so catches this copy declaring + * a member with the wrong type; it is structurally blind to a member that is + * missing here, because an absent optional property is a legal shape. This + * alias compares *keys*, so a member added or renamed upstream and not + * followed here is a build failure naming the member. + * + * Verified by adding a member to the upstream definition and observing this + * alias turn red while everything else stayed green. + */ + export type LinksKeysCovered = AssertSchemaOutput; /** * Runtime schema producing {@link Interface}. * @@ -523,84 +585,99 @@ export declare namespace Account { * * Unknown keys are preserved, matching the `[x: string]: any` index signature * inherited from {@link BaseFirestore}. + * + * Every optional field is `.nullish()` rather than `.optional()`. A stored + * account is filled in progressively and its unfilled registration fields are + * written as an explicit `null` rather than omitted, so a schema accepting + * only `undefined` rejected substantially every stored account rather than an + * unusual one. The loosening is bounded to `null` alone: an unrecognised enum + * member, a wrong type and an out-of-range offset are all still rejected, as + * are `null` on {@link Interface.domainTimestamp} and on the audit timestamps. + * + * Stored accounts also carry place fields — `geohash`, `latitude`, + * `longitude`, `placeId` — that {@link Interface} does not declare. They pass + * through as unknown keys and are preserved rather than rejected, so they are + * unaffected by any of the above. Whether they should be modelled here, most + * likely by spreading `basePlaceDataShape`, is a separate question for the + * owner of this shape and is deliberately not answered by this schema. */ - const Schema: z.ZodObject<{ - language: z.ZodOptional; - image: z.ZodOptional; - imageURL: z.ZodOptional; - name: z.ZodOptional; - businessName: z.ZodOptional; - useName: z.ZodOptional; - description: z.ZodOptional; - status: z.ZodOptional>; - type: z.ZodOptional>; - uid: z.ZodOptional; - links: z.ZodOptional; - dribbble: z.ZodOptional; - facebook: z.ZodOptional; - instagram: z.ZodOptional; - linkedin: z.ZodOptional; - tiktok: z.ZodOptional; - x: z.ZodOptional; - youtube: z.ZodOptional; - website: z.ZodOptional; - }, z.core.$loose>>; - companyType: z.ZodOptional>; - stockExchange: z.ZodOptional>; - stockTicker: z.ZodOptional; - businessType: z.ZodOptional>; - businessRegionsOfOperations: z.ZodOptional>; - businessRegistrationIdentifier: z.ZodOptional>; - businessIndustry: z.ZodOptional>; - businessRegistrationNumber: z.ZodOptional; - authorizedRepresentative1: z.ZodOptional; - lastName: z.ZodOptional; - email: z.ZodOptional; - phoneNumber: z.ZodOptional; - businessTitle: z.ZodOptional; - jobPosition: z.ZodOptional>; - }, z.core.$loose>>; - authorizedRepresentative2: z.ZodOptional; - lastName: z.ZodOptional; - email: z.ZodOptional; - phoneNumber: z.ZodOptional; - businessTitle: z.ZodOptional; - jobPosition: z.ZodOptional>; - }, z.core.$loose>>; - estimatedVolume: z.ZodOptional; - brandType: z.ZodOptional>; - appToPersonUseCase: z.ZodOptional>; - tollFreeUseCase: z.ZodOptional; - useCaseDescription: z.ZodOptional; - useCaseDescriptionCTA: z.ZodOptional; - automaticHeader: z.ZodOptional; - postalCode: z.ZodOptional; - area: z.ZodOptional; - city: z.ZodOptional; - street1: z.ZodOptional; - street2: z.ZodOptional; - country: z.ZodOptional; - utcOffset: z.ZodOptional; - domain: z.ZodOptional; - domainOk: z.ZodOptional; + export const Schema: z.ZodObject<{ + language: z.ZodOptional>; + image: z.ZodOptional>; + imageURL: z.ZodOptional>; + name: z.ZodOptional>; + businessName: z.ZodOptional>; + useName: z.ZodOptional>; + description: z.ZodOptional>; + status: z.ZodOptional>>; + type: z.ZodOptional>>; + uid: z.ZodOptional>; + links: z.ZodOptional>; + dribbble: z.ZodOptional>; + facebook: z.ZodOptional>; + instagram: z.ZodOptional>; + linkedin: z.ZodOptional>; + tiktok: z.ZodOptional>; + x: z.ZodOptional>; + youtube: z.ZodOptional>; + website: z.ZodOptional>; + }, z.core.$loose>>>; + companyType: z.ZodOptional>>; + stockExchange: z.ZodOptional>>; + stockTicker: z.ZodOptional>; + businessType: z.ZodOptional>>; + businessRegionsOfOperations: z.ZodOptional>>; + businessRegistrationIdentifier: z.ZodOptional>>; + businessIndustry: z.ZodOptional>>; + businessRegistrationNumber: z.ZodOptional>; + authorizedRepresentative1: z.ZodOptional>; + lastName: z.ZodOptional>; + email: z.ZodOptional>; + phoneNumber: z.ZodOptional>; + businessTitle: z.ZodOptional>; + jobPosition: z.ZodOptional>>; + }, z.core.$loose>>>; + authorizedRepresentative2: z.ZodOptional>; + lastName: z.ZodOptional>; + email: z.ZodOptional>; + phoneNumber: z.ZodOptional>; + businessTitle: z.ZodOptional>; + jobPosition: z.ZodOptional>>; + }, z.core.$loose>>>; + estimatedVolume: z.ZodOptional>; + brandType: z.ZodOptional>>; + appToPersonUseCase: z.ZodOptional>>; + tollFreeUseCase: z.ZodOptional>; + useCaseDescription: z.ZodOptional>; + useCaseDescriptionCTA: z.ZodOptional>; + automaticHeader: z.ZodOptional>; + postalCode: z.ZodOptional>; + area: z.ZodOptional>; + city: z.ZodOptional>; + street1: z.ZodOptional>; + street2: z.ZodOptional>; + country: z.ZodOptional>; + utcOffset: z.ZodOptional>; + domain: z.ZodOptional>; + domainOk: z.ZodOptional>; domainTimestamp: z.ZodOptional>>; - alias: z.ZodOptional; - sampleMessage1: z.ZodOptional; - sampleMessage2: z.ZodOptional; - sampleMessage3: z.ZodOptional; - sampleMessage4: z.ZodOptional; - sampleMessage5: z.ZodOptional; - bca: z.ZodOptional; - pending: z.ZodOptional; - ready: z.ZodOptional; - sender: z.ZodOptional; - sending: z.ZodOptional; + alias: z.ZodOptional>; + sampleMessage1: z.ZodOptional>; + sampleMessage2: z.ZodOptional>; + sampleMessage3: z.ZodOptional>; + sampleMessage4: z.ZodOptional>; + sampleMessage5: z.ZodOptional>; + bca: z.ZodOptional>; + pending: z.ZodOptional>; + ready: z.ZodOptional>; + sender: z.ZodOptional>; + sending: z.ZodOptional>; counted: z.ZodOptional; - id: z.ZodOptional; - backup: z.ZodOptional; + id: z.ZodOptional>; + backup: z.ZodOptional>; created: z.ZodOptional>>; updated: z.ZodOptional>>; expiry: z.ZodOptional>>; @@ -608,19 +685,21 @@ export declare namespace Account { /** * Compile-time proof that {@link Schema} produces {@link Interface}. * - * This also pins {@link Interface.links} against the shared-helpers - * `User.InterfaceLinks` definition: if that type gains or changes a field and - * `linksSchema` is not updated to match, the divergence is a build failure - * here rather than a field silently rejected at runtime. + * This compares inferred *values*, so it catches `linksSchema` declaring a + * member of {@link Links} with the wrong type. It does **not** catch a member + * added upstream that `linksSchema` never declared — an absent optional + * property is a legal shape, so the check passes. {@link LinksKeysCovered} + * covers that case; the two together are what pin this package to the + * shared-helpers `User.InterfaceLinks` definition. */ - type SchemaOutput = AssertSchemaOutput, Interface>; + export type SchemaOutput = AssertSchemaOutput, Interface>; /** * Validates untrusted data as an account document without throwing. * * @param {unknown} value - Untrusted value, typically the raw data of a stored account document. * @return {ParseResult} Success carrying the typed account, or failure carrying the reasons. */ - const safeParse: (value: unknown) => ParseResult; + export const safeParse: (value: unknown) => ParseResult; /** * Validates untrusted data as an account document, throwing when it does not * conform. @@ -629,5 +708,6 @@ export declare namespace Account { * @return {Interface} The validated account document. * @throws {ParseError} When the value does not conform to {@link Schema}. */ - const parse: (value: unknown) => Interface; + export const parse: (value: unknown) => Interface; + export {}; } diff --git a/lib/model/Account.js b/lib/model/Account.js index f59546e..e912222 100644 --- a/lib/model/Account.js +++ b/lib/model/Account.js @@ -311,74 +311,79 @@ export var Account; /** * See {@link AuthorizedRepresentative.firstName}. */ - firstName: z.string().optional(), + firstName: z.string().nullish(), /** * See {@link AuthorizedRepresentative.lastName}. */ - lastName: z.string().optional(), + lastName: z.string().nullish(), /** * See {@link AuthorizedRepresentative.email}. */ - email: z.string().optional(), + email: z.string().nullish(), /** * See {@link AuthorizedRepresentative.phoneNumber}. */ - phoneNumber: z.string().optional(), + phoneNumber: z.string().nullish(), /** * See {@link AuthorizedRepresentative.businessTitle}. */ - businessTitle: z.string().optional(), + businessTitle: z.string().nullish(), /** * See {@link AuthorizedRepresentative.jobPosition}. */ - jobPosition: z.enum(AuthorizedRepresentativeJobPosition).optional(), + jobPosition: z.enum(AuthorizedRepresentativeJobPosition).nullish(), }); /** * Schema for the social and web links block sourced from the shared-helpers * `User.InterfaceLinks` definition. * - * Declared here rather than imported because that package ships types only; - * the compile-time proof on {@link Schema} is what keeps this copy honest, so - * a change to `User.InterfaceLinks` that this schema does not follow becomes a - * build failure rather than a silent divergence. + * Declared here rather than imported because that package ships types only. + * {@link LinksKeysCovered} below is what keeps this copy honest — **not** the + * compile-time proof on {@link Schema}, which cannot see a member added + * upstream. `z.looseObject` infers a `[x: string]: unknown` index signature, + * and an index signature on the source of an assignment does not supply named + * members to satisfy an optional property on the target, so a new upstream + * `mastodon?: string` is simply read as absent-and-optional and checks clean. + * That was measured, by adding a member upstream and observing the build stay + * green. */ const linksSchema = z.looseObject({ /** * Behance profile URL. */ - behance: z.string().optional(), + behance: z.string().nullish(), /** * Dribbble profile URL. */ - dribbble: z.string().optional(), + dribbble: z.string().nullish(), /** * Facebook page or profile URL. */ - facebook: z.string().optional(), + facebook: z.string().nullish(), /** * Instagram profile URL. */ - instagram: z.string().optional(), + instagram: z.string().nullish(), /** * LinkedIn profile URL. */ - linkedin: z.string().optional(), + linkedin: z.string().nullish(), /** * TikTok profile URL. */ - tiktok: z.string().optional(), + tiktok: z.string().nullish(), /** * X profile URL. */ - x: z.string().optional(), + x: z.string().nullish(), /** * YouTube channel URL. */ - youtube: z.string().optional(), + youtube: z.string().nullish(), /** * Primary website URL. */ - website: z.string().optional(), + website: z.string().nullish(), }); /** * Runtime schema producing {@link Interface}. @@ -398,6 +403,21 @@ export var Account; * * Unknown keys are preserved, matching the `[x: string]: any` index signature * inherited from {@link BaseFirestore}. + * + * Every optional field is `.nullish()` rather than `.optional()`. A stored + * account is filled in progressively and its unfilled registration fields are + * written as an explicit `null` rather than omitted, so a schema accepting + * only `undefined` rejected substantially every stored account rather than an + * unusual one. The loosening is bounded to `null` alone: an unrecognised enum + * member, a wrong type and an out-of-range offset are all still rejected, as + * are `null` on {@link Interface.domainTimestamp} and on the audit timestamps. + * + * Stored accounts also carry place fields — `geohash`, `latitude`, + * `longitude`, `placeId` — that {@link Interface} does not declare. They pass + * through as unknown keys and are preserved rather than rejected, so they are + * unaffected by any of the above. Whether they should be modelled here, most + * likely by spreading `basePlaceDataShape`, is a separate question for the + * owner of this shape and is deliberately not answered by this schema. */ Account.Schema = z.looseObject({ ...baseFirestoreShape, @@ -405,158 +425,158 @@ export var Account; /** * See {@link Interface.language}. */ - language: nonEmptyString().optional(), + language: nonEmptyString().nullish(), /** * See {@link Interface.image}. */ - image: nonEmptyString().optional(), + image: nonEmptyString().nullish(), /** * See {@link Interface.imageURL}. */ - imageURL: nonEmptyString().optional(), + imageURL: nonEmptyString().nullish(), /** * See {@link Interface.name}. */ - name: z.string().optional(), + name: z.string().nullish(), /** * See {@link Interface.businessName}. */ - businessName: z.string().optional(), + businessName: z.string().nullish(), /** * See {@link Interface.useName}. */ - useName: z.string().optional(), + useName: z.string().nullish(), /** * See {@link Interface.description}. */ - description: z.string().optional(), + description: z.string().nullish(), /** * See {@link Interface.status}. Validated against {@link Status}: this field * gates whether the message queue runs at all, so a value that is neither a * known status nor `paused` fails open and keeps sending. */ - status: z.enum(Status).optional(), + status: z.enum(Status).nullish(), /** * See {@link Interface.type}. */ - type: z.enum(Type).optional(), + type: z.enum(Type).nullish(), /** * See {@link Interface.uid}. */ - uid: nonEmptyString().optional(), + uid: nonEmptyString().nullish(), /** * See {@link Interface.links}. */ - links: linksSchema.optional(), + links: linksSchema.nullish(), /** * See {@link Interface.companyType}. */ - companyType: z.enum(CompanyType).optional(), + companyType: z.enum(CompanyType).nullish(), /** * See {@link Interface.stockExchange}. */ - stockExchange: z.enum(StockExchange).optional(), + stockExchange: z.enum(StockExchange).nullish(), /** * See {@link Interface.stockTicker}. */ - stockTicker: z.string().optional(), + stockTicker: z.string().nullish(), /** * See {@link Interface.businessType}. */ - businessType: z.enum(BusinessType).optional(), + businessType: z.enum(BusinessType).nullish(), /** * See {@link Interface.businessRegionsOfOperations}. */ - businessRegionsOfOperations: z.enum(BusinessRegionsOfOperations).optional(), + businessRegionsOfOperations: z.enum(BusinessRegionsOfOperations).nullish(), /** * See {@link Interface.businessRegistrationIdentifier}. */ - businessRegistrationIdentifier: z.enum(BusinessRegistrationIdentifier).optional(), + businessRegistrationIdentifier: z.enum(BusinessRegistrationIdentifier).nullish(), /** * See {@link Interface.businessIndustry}. */ - businessIndustry: z.enum(BusinessIndustry).optional(), + businessIndustry: z.enum(BusinessIndustry).nullish(), /** * See {@link Interface.businessRegistrationNumber}. */ - businessRegistrationNumber: z.string().optional(), + businessRegistrationNumber: z.string().nullish(), /** * See {@link Interface.authorizedRepresentative1}. */ - authorizedRepresentative1: authorizedRepresentativeSchema.optional(), + authorizedRepresentative1: authorizedRepresentativeSchema.nullish(), /** * See {@link Interface.authorizedRepresentative2}. */ - authorizedRepresentative2: authorizedRepresentativeSchema.optional(), + authorizedRepresentative2: authorizedRepresentativeSchema.nullish(), /** * See {@link Interface.estimatedVolume}. A whole number of messages per * month; {@link Interface.brandType} is derived from it, so a value that * arrived as a string and became `NaN` would select a brand tier by * comparing `NaN` against every threshold and losing every comparison. */ - estimatedVolume: counter().optional(), + estimatedVolume: counter().nullish(), /** * See {@link Interface.brandType}. */ - brandType: z.enum(BrandType).optional(), + brandType: z.enum(BrandType).nullish(), /** * See {@link Interface.appToPersonUseCase}. */ - appToPersonUseCase: z.enum(AppToPersonUseCase).optional(), + appToPersonUseCase: z.enum(AppToPersonUseCase).nullish(), /** * See {@link Interface.tollFreeUseCase}. */ - tollFreeUseCase: z.string().optional(), + tollFreeUseCase: z.string().nullish(), /** * See {@link Interface.useCaseDescription}. */ - useCaseDescription: z.string().optional(), + useCaseDescription: z.string().nullish(), /** * See {@link Interface.useCaseDescriptionCTA}. */ - useCaseDescriptionCTA: z.string().optional(), + useCaseDescriptionCTA: z.string().nullish(), /** * See {@link Interface.automaticHeader}. */ - automaticHeader: z.boolean().optional(), + automaticHeader: z.boolean().nullish(), /** * See {@link Interface.postalCode}. */ - postalCode: z.string().optional(), + postalCode: z.string().nullish(), /** * See {@link Interface.area}. */ - area: z.string().optional(), + area: z.string().nullish(), /** * See {@link Interface.city}. */ - city: z.string().optional(), + city: z.string().nullish(), /** * See {@link Interface.street1}. */ - street1: z.string().optional(), + street1: z.string().nullish(), /** * See {@link Interface.street2}. */ - street2: z.string().optional(), + street2: z.string().nullish(), /** * See {@link Interface.country}. */ - country: z.string().optional(), + country: z.string().nullish(), /** * See {@link Interface.utcOffset}. Whole minutes, not hours: offsets such as * `+05:45` are not expressible in whole hours at all, so an hours value * accepted here would be wrong by a factor of sixty. */ - utcOffset: z.int().min(-1440).max(1440).optional(), + utcOffset: z.int().min(-1440).max(1440).nullish(), /** * See {@link Interface.domain}. */ - domain: nonEmptyString().optional(), + domain: nonEmptyString().nullish(), /** * See {@link Interface.domainOk}. */ - domainOk: z.boolean().optional(), + domainOk: z.boolean().nullish(), /** * See {@link Interface.domainTimestamp}. Declared `any`; resolved at this * boundary by `auditTimestamp` rather than by a type that cannot name a @@ -566,31 +586,31 @@ export var Account; /** * See {@link Interface.alias}. */ - alias: nonEmptyString().optional(), + alias: nonEmptyString().nullish(), /** * See {@link Interface.sampleMessage1}. */ - sampleMessage1: z.string().optional(), + sampleMessage1: z.string().nullish(), /** * See {@link Interface.sampleMessage2}. */ - sampleMessage2: z.string().optional(), + sampleMessage2: z.string().nullish(), /** * See {@link Interface.sampleMessage3}. */ - sampleMessage3: z.string().optional(), + sampleMessage3: z.string().nullish(), /** * See {@link Interface.sampleMessage4}. */ - sampleMessage4: z.string().optional(), + sampleMessage4: z.string().nullish(), /** * See {@link Interface.sampleMessage5}. */ - sampleMessage5: z.string().optional(), + sampleMessage5: z.string().nullish(), /** * See {@link Interface.bca}. */ - bca: nonEmptyString().optional(), + bca: nonEmptyString().nullish(), }); /** * Validates untrusted data as an account document without throwing. diff --git a/lib/model/Block.d.ts b/lib/model/Block.d.ts index ab85e81..c521808 100644 --- a/lib/model/Block.d.ts +++ b/lib/model/Block.d.ts @@ -63,12 +63,12 @@ export declare namespace Block { * Optional display width hint in pixels for media blocks such as images * and videos. */ - width?: number; + width?: number | null; /** * Optional display height hint in pixels for media blocks such as images * and videos. */ - height?: number; + height?: number | null; } /** * Runtime schema producing {@link Interface}. @@ -87,8 +87,8 @@ export declare namespace Block { type: z.ZodEnum; value: z.ZodUnion, z.ZodArray]>; label: z.ZodString; - width: z.ZodOptional; - height: z.ZodOptional; + width: z.ZodOptional>; + height: z.ZodOptional>; }, z.core.$loose>; /** * Compile-time proof that {@link Schema} produces {@link Interface}. diff --git a/lib/model/Block.js b/lib/model/Block.js index 258e579..e302ec5 100644 --- a/lib/model/Block.js +++ b/lib/model/Block.js @@ -81,11 +81,11 @@ export var Block; /** * See {@link Interface.width}. Whole pixels. */ - width: counter().optional(), + width: counter().nullish(), /** * See {@link Interface.height}. Whole pixels. */ - height: counter().optional(), + height: counter().nullish(), }); /** * Validates untrusted data as a content block without throwing. diff --git a/lib/model/Capacity.d.ts b/lib/model/Capacity.d.ts index 25eaeec..48c2898 100644 --- a/lib/model/Capacity.d.ts +++ b/lib/model/Capacity.d.ts @@ -124,8 +124,8 @@ export declare namespace Capacity { generation: z.ZodNumber; expiresAt: z.ZodNumber; expires: z.ZodOptional>>; - id: z.ZodOptional; - backup: z.ZodOptional; + id: z.ZodOptional>; + backup: z.ZodOptional>; created: z.ZodOptional>>; updated: z.ZodOptional>>; expiry: z.ZodOptional>>; @@ -154,8 +154,8 @@ export declare namespace Capacity { generation: z.ZodNumber; expiresAt: z.ZodNumber; expires: z.ZodOptional>>; - id: z.ZodOptional; - backup: z.ZodOptional; + id: z.ZodOptional>; + backup: z.ZodOptional>; created: z.ZodOptional>>; updated: z.ZodOptional>>; expiry: z.ZodOptional>>; diff --git a/lib/model/Entitlement.d.ts b/lib/model/Entitlement.d.ts index 7f1470c..ccefaf6 100644 --- a/lib/model/Entitlement.d.ts +++ b/lib/model/Entitlement.d.ts @@ -88,7 +88,7 @@ export declare namespace Entitlement { * re-claimed, and a caller collapsing the two loses the distinction between * "never attempted" and "attempted and did not work". */ - status?: Status; + status?: Status | null; /** * Stable identity of the entitlement being conferred, independent of which * purchase attempt conferred it. @@ -97,7 +97,7 @@ export declare namespace Entitlement { * value here, which is what lets a duplicate be recognised as a duplicate * rather than as a second purchase. */ - entitlement?: string; + entitlement?: string | null; /** * Token identifying the delivery that currently holds the claim. * @@ -105,7 +105,7 @@ export declare namespace Entitlement { * delivery that lost its claim cannot write the outcome of work another * delivery has since completed. */ - ownerToken?: string; + ownerToken?: string | null; } /** * Runtime schema producing {@link Interface}. @@ -126,11 +126,11 @@ export declare namespace Entitlement { price: z.ZodString; source: z.ZodString; type: z.ZodEnum; - status: z.ZodOptional>; - entitlement: z.ZodOptional; - ownerToken: z.ZodOptional; - id: z.ZodOptional; - backup: z.ZodOptional; + status: z.ZodOptional>>; + entitlement: z.ZodOptional>; + ownerToken: z.ZodOptional>; + id: z.ZodOptional>; + backup: z.ZodOptional>; created: z.ZodOptional>>; updated: z.ZodOptional>>; expiry: z.ZodOptional>>; diff --git a/lib/model/Entitlement.js b/lib/model/Entitlement.js index 984ba1c..48754e9 100644 --- a/lib/model/Entitlement.js +++ b/lib/model/Entitlement.js @@ -83,15 +83,15 @@ export var Entitlement; /** * See {@link Interface.status}. Validated against {@link Status}. */ - status: z.enum(Status).optional(), + status: z.enum(Status).nullish(), /** * See {@link Interface.entitlement}. */ - entitlement: nonEmptyString().max(512).optional(), + entitlement: nonEmptyString().max(512).nullish(), /** * See {@link Interface.ownerToken}. */ - ownerToken: token().optional(), + ownerToken: token().nullish(), }); /** * Validates untrusted data as an entitlement record without throwing. diff --git a/lib/model/EventData.d.ts b/lib/model/EventData.d.ts index 43aa1f0..b79fb92 100644 --- a/lib/model/EventData.d.ts +++ b/lib/model/EventData.d.ts @@ -59,41 +59,41 @@ export declare namespace EventData { /** * Public display name of the event. */ - name?: string; + name?: string | null; /** * Detailed description of the event shown to potential participants. */ - description?: string; + description?: string | null; /** * BCP 47 language tag for the event's primary language (e.g., `"en"`). */ - language?: string; + language?: string | null; /** * Firestore document ID of the account that owns this event. */ - account?: string; + account?: string | null; /** * Array of media asset URLs (images, videos) associated with the event. */ - media?: string[]; + media?: string[] | null; /** * Event format; see {@link Type} for accepted values. * Accepts a typed enum member or a raw string for forward-compatibility * with values stored in Firestore before this enum existed. */ - type?: Type | string; + type?: Type | string | null; /** * Recurrence cadence; see {@link Frequency} for accepted values. * Accepts a typed enum member or a raw string for forward-compatibility * with values stored in Firestore before this enum existed. */ - frequency?: Frequency | string; + frequency?: Frequency | string | null; /** * Current lifecycle status; see {@link Status} for accepted values. * Accepts a typed enum member or a raw string for forward-compatibility * with values stored in Firestore before this enum existed. */ - status?: Status | string; + status?: Status | string | null; /** * Firebase Auth UID of the user who created this event, or `null` for * system-generated events. @@ -103,30 +103,30 @@ export declare namespace EventData { * Ordered array of content blocks that compose the event's rich-media * detail page. */ - blocks?: Block.Interface[]; + blocks?: Block.Interface[] | null; /** * @deprecated Use the `Price` namespace instead. * ISO 4217 currency code for the event ticket price. */ - currency?: string; + currency?: string | null; /** * @deprecated Use the `Price` namespace instead. * Ticket price amount expressed in the smallest currency unit (e.g., cents). */ - amount?: number; + amount?: number | null; /** * Firebase Auth UIDs of participants who have booked or joined this event. */ - users?: string[]; + users?: string[] | null; /** * Firebase Auth UIDs of users who have been designated as event hosts. */ - hosts?: string[]; + hosts?: string[] | null; /** * Maximum number of participants allowed to join; enforced by Cloud * Functions during the booking process. */ - limit?: number; + limit?: number | null; /** * Timestamp at which the event starts; stored as a Firestore Timestamp * or ISO 8601 string. @@ -140,30 +140,30 @@ export declare namespace EventData { /** * Planned duration of the event in minutes. */ - duration?: number; + duration?: number | null; /** * UTC hour of the day (0–23) at which recurring Cloud Function jobs * process or re-schedule this event. */ - runHour?: number; + runHour?: number | null; /** * Cumulative number of times this event's detail page has been clicked * from a listing view. */ - clicks?: number; + clicks?: number | null; /** * Cumulative number of times this event's detail page has been viewed. */ - views?: number; + views?: number | null; /** * Cumulative number of users who initiated the checkout / booking flow for * this event. */ - checkout?: number; + checkout?: number | null; /** * Cumulative number of confirmed bookings for this event. */ - booked?: number; + booked?: number | null; } /** * Runtime schema producing {@link Interface}. @@ -189,46 +189,46 @@ export declare namespace EventData { * accepted and how to widen the schema for a write payload. */ const Schema: z.ZodObject<{ - name: z.ZodOptional; - description: z.ZodOptional; - language: z.ZodOptional; - account: z.ZodOptional; - media: z.ZodOptional>; - type: z.ZodOptional, z.ZodString]>>; - frequency: z.ZodOptional, z.ZodString]>>; - status: z.ZodOptional, z.ZodString]>>; + name: z.ZodOptional>; + description: z.ZodOptional>; + language: z.ZodOptional>; + account: z.ZodOptional>; + media: z.ZodOptional>>; + type: z.ZodOptional, z.ZodString]>>>; + frequency: z.ZodOptional, z.ZodString]>>>; + status: z.ZodOptional, z.ZodString]>>>; uid: z.ZodOptional>; - blocks: z.ZodOptional; value: z.ZodUnion, z.ZodArray]>; label: z.ZodString; - width: z.ZodOptional; - height: z.ZodOptional; - }, z.core.$loose>>>; - currency: z.ZodOptional; - amount: z.ZodOptional; - users: z.ZodOptional>; - hosts: z.ZodOptional>; - limit: z.ZodOptional; + width: z.ZodOptional>; + height: z.ZodOptional>; + }, z.core.$loose>>>>; + currency: z.ZodOptional>; + amount: z.ZodOptional>; + users: z.ZodOptional>>; + hosts: z.ZodOptional>>; + limit: z.ZodOptional>; startTime: z.ZodOptional>>; endTime: z.ZodOptional>>; - duration: z.ZodOptional; - runHour: z.ZodOptional; - clicks: z.ZodOptional; - views: z.ZodOptional; - checkout: z.ZodOptional; - booked: z.ZodOptional; - location: z.ZodOptional>; - placeId: z.ZodOptional; - latitude: z.ZodOptional; - longitude: z.ZodOptional; - placeName: z.ZodOptional; - utcOffset: z.ZodOptional; - country: z.ZodOptional; - geohash: z.ZodOptional; - area: z.ZodOptional; - id: z.ZodOptional; - backup: z.ZodOptional; + duration: z.ZodOptional>; + runHour: z.ZodOptional>; + clicks: z.ZodOptional>; + views: z.ZodOptional>; + checkout: z.ZodOptional>; + booked: z.ZodOptional>; + location: z.ZodOptional>>; + placeId: z.ZodOptional>; + latitude: z.ZodOptional>; + longitude: z.ZodOptional>; + placeName: z.ZodOptional>; + utcOffset: z.ZodOptional>; + country: z.ZodOptional>; + geohash: z.ZodOptional>; + area: z.ZodOptional>; + id: z.ZodOptional>; + backup: z.ZodOptional>; created: z.ZodOptional>>; updated: z.ZodOptional>>; expiry: z.ZodOptional>>; diff --git a/lib/model/EventData.js b/lib/model/EventData.js index 48cf46e..b8b0563 100644 --- a/lib/model/EventData.js +++ b/lib/model/EventData.js @@ -80,72 +80,72 @@ export var EventData; /** * See {@link Interface.name}. */ - name: z.string().optional(), + name: z.string().nullish(), /** * See {@link Interface.description}. */ - description: z.string().optional(), + description: z.string().nullish(), /** * See {@link Interface.language}. */ - language: nonEmptyString().optional(), + language: nonEmptyString().nullish(), /** * See {@link Interface.account}. */ - account: documentId().optional(), + account: documentId().nullish(), /** * See {@link Interface.media}. */ - media: z.array(z.string()).optional(), + media: z.array(z.string()).nullish(), /** * See {@link Interface.type}, and the note on this schema about why a raw * string is still accepted. */ - type: z.union([z.enum(Type), z.string()]).optional(), + type: z.union([z.enum(Type), z.string()]).nullish(), /** * See {@link Interface.frequency}, and the note on this schema about why a * raw string is still accepted. */ - frequency: z.union([z.enum(Frequency), z.string()]).optional(), + frequency: z.union([z.enum(Frequency), z.string()]).nullish(), /** * See {@link Interface.status}, and the note on this schema about why a raw * string is still accepted. */ - status: z.union([z.enum(Status), z.string()]).optional(), + status: z.union([z.enum(Status), z.string()]).nullish(), /** * See {@link Interface.uid}. `null` denotes a system-generated event and * must survive a JSON round-trip. */ - uid: z.string().nullable().optional(), + uid: z.string().nullish(), /** * See {@link Interface.blocks}. Every element is validated against * {@link Block.Schema}, so one malformed block fails the event rather than * reaching a renderer that has no branch for it. */ - blocks: z.array(Block.Schema).optional(), + blocks: z.array(Block.Schema).nullish(), /** * See {@link Interface.currency}. * @deprecated Use the `Price` namespace instead. */ - currency: z.string().regex(/^[A-Za-z]{3}$/, { error: 'Expected a three-letter ISO 4217 currency code' }).optional(), + currency: z.string().regex(/^[A-Za-z]{3}$/, { error: 'Expected a three-letter ISO 4217 currency code' }).nullish(), /** * See {@link Interface.amount}. Money, so a non-numeric value is rejected * rather than coerced into `NaN`. * @deprecated Use the `Price` namespace instead. */ - amount: finiteNumber().optional(), + amount: finiteNumber().nullish(), /** * See {@link Interface.users}. */ - users: z.array(z.string()).optional(), + users: z.array(z.string()).nullish(), /** * See {@link Interface.hosts}. */ - hosts: z.array(z.string()).optional(), + hosts: z.array(z.string()).nullish(), /** * See {@link Interface.limit}. */ - limit: counter().optional(), + limit: counter().nullish(), /** * See {@link Interface.startTime}. */ @@ -157,28 +157,28 @@ export var EventData; /** * See {@link Interface.duration}. Whole minutes. */ - duration: counter().optional(), + duration: counter().nullish(), /** * See {@link Interface.runHour}. A UTC hour of day, so the accepted range is * 0 to 23; a value outside it schedules a recurring job that never fires. */ - runHour: z.int().min(0).max(23).optional(), + runHour: z.int().min(0).max(23).nullish(), /** * See {@link Interface.clicks}. */ - clicks: counter().optional(), + clicks: counter().nullish(), /** * See {@link Interface.views}. */ - views: counter().optional(), + views: counter().nullish(), /** * See {@link Interface.checkout}. */ - checkout: counter().optional(), + checkout: counter().nullish(), /** * See {@link Interface.booked}. */ - booked: counter().optional(), + booked: counter().nullish(), }); /** * Validates untrusted data as an event document without throwing. diff --git a/lib/model/Idempotency.d.ts b/lib/model/Idempotency.d.ts index 3812fa4..61ac335 100644 --- a/lib/model/Idempotency.d.ts +++ b/lib/model/Idempotency.d.ts @@ -98,14 +98,14 @@ export declare namespace Idempotency { * of work another process has since redone. Absent once the record has * settled. */ - ownerToken?: string; + ownerToken?: string | null; /** * Number of times this key has been claimed, including the first. * * Increments each time a lapsed lease is reclaimed, so a value climbing * without the record settling indicates a handler that keeps dying mid-flight. */ - attempts?: number; + attempts?: number | null; /** * Durable, operation-specific checkpoints retained across failed attempts and * lease reclaims, so a retried handler can skip work that already succeeded. @@ -114,12 +114,12 @@ export declare namespace Idempotency { * no checkpoint has been recorded yet, which is not the same as the operation * having no steps. */ - progress?: Record; + progress?: Record | null; /** * Outcome of the original attempt, present only once * {@link Interface.state} is {@link State.completed}. */ - response?: Response; + response?: Response | null; /** * Instant at which the current holder's lease lapses, after which another * attempt may reclaim the key. @@ -149,17 +149,17 @@ export declare namespace Idempotency { const Schema: z.ZodObject<{ state: z.ZodEnum; requestHash: z.ZodString; - ownerToken: z.ZodOptional; - attempts: z.ZodOptional; - progress: z.ZodOptional>; - response: z.ZodOptional>; + attempts: z.ZodOptional>; + progress: z.ZodOptional>>; + response: z.ZodOptional; truncated: z.ZodBoolean; - }, z.core.$loose>>; + }, z.core.$loose>>>; lockExpires: z.ZodOptional>>; - id: z.ZodOptional; - backup: z.ZodOptional; + id: z.ZodOptional>; + backup: z.ZodOptional>; created: z.ZodOptional>>; updated: z.ZodOptional>>; expiry: z.ZodOptional>>; diff --git a/lib/model/Idempotency.js b/lib/model/Idempotency.js index 5f2348a..eccb885 100644 --- a/lib/model/Idempotency.js +++ b/lib/model/Idempotency.js @@ -101,19 +101,19 @@ export var Idempotency; /** * See {@link Interface.ownerToken}. */ - ownerToken: token().optional(), + ownerToken: token().nullish(), /** * See {@link Interface.attempts}. */ - attempts: counter().optional(), + attempts: counter().nullish(), /** * See {@link Interface.progress}. */ - progress: z.record(z.string(), z.unknown()).optional(), + progress: z.record(z.string(), z.unknown()).nullish(), /** * See {@link Interface.response}. */ - response: responseSchema.optional(), + response: responseSchema.nullish(), /** * See {@link Interface.lockExpires}. */ diff --git a/lib/model/Ledger.d.ts b/lib/model/Ledger.d.ts index 84e14c7..6867e85 100644 --- a/lib/model/Ledger.d.ts +++ b/lib/model/Ledger.d.ts @@ -68,7 +68,7 @@ export declare namespace Ledger { * "settled at zero". A caller that collapses the two with `?? 0` will treat * an outstanding reservation as a completed no-op and release it. */ - consumed?: number; + consumed?: number | null; /** * Allowance this scope was seeded from, recorded for observability. * @@ -87,7 +87,7 @@ export declare namespace Ledger { * spend that actually happened. Absent or `false` both mean "not confirmed" — * a record is only confirmed when this is explicitly `true`. */ - spendConfirmed?: boolean; + spendConfirmed?: boolean | null; } /** * Runtime schema producing {@link Interface}. @@ -106,11 +106,11 @@ export declare namespace Ledger { service: z.ZodString; scope: z.ZodString; amount: z.ZodNumber; - consumed: z.ZodOptional; + consumed: z.ZodOptional>; limit: z.ZodOptional>; - spendConfirmed: z.ZodOptional; - id: z.ZodOptional; - backup: z.ZodOptional; + spendConfirmed: z.ZodOptional>; + id: z.ZodOptional>; + backup: z.ZodOptional>; created: z.ZodOptional>>; updated: z.ZodOptional>>; expiry: z.ZodOptional>>; diff --git a/lib/model/Ledger.js b/lib/model/Ledger.js index 60457c6..c710fff 100644 --- a/lib/model/Ledger.js +++ b/lib/model/Ledger.js @@ -52,15 +52,15 @@ export var Ledger; /** * See {@link Interface.consumed}. */ - consumed: nonNegativeNumber().optional(), + consumed: nonNegativeNumber().nullish(), /** * See {@link Interface.limit}. */ - limit: nonNegativeNumber().nullable().optional(), + limit: nonNegativeNumber().nullish(), /** * See {@link Interface.spendConfirmed}. */ - spendConfirmed: z.boolean().optional(), + spendConfirmed: z.boolean().nullish(), }); /** * Validates untrusted data as a ledger record without throwing. diff --git a/lib/model/MessageUsage.d.ts b/lib/model/MessageUsage.d.ts index dbc48df..31d03fa 100644 --- a/lib/model/MessageUsage.d.ts +++ b/lib/model/MessageUsage.d.ts @@ -97,7 +97,7 @@ export declare namespace MessageUsage { * harmless when the read genuinely succeeded — which is exactly the case a * caller cannot distinguish without validating first. */ - reported?: number; + reported?: number | null; /** * An in-flight report awaiting acknowledgement; see {@link Pending}. * @@ -105,7 +105,7 @@ export declare namespace MessageUsage { * handed to the provider, and it must be re-sent verbatim rather than * recomputed. */ - pending?: Pending; + pending?: Pending | null; } /** * Runtime schema producing {@link Interface}. @@ -123,16 +123,16 @@ export declare namespace MessageUsage { const Schema: z.ZodObject<{ period: z.ZodString; token: z.ZodString; - reported: z.ZodOptional; - pending: z.ZodOptional>; + pending: z.ZodOptional>>; - }, z.core.$loose>>; - id: z.ZodOptional; - backup: z.ZodOptional; + }, z.core.$loose>>>; + id: z.ZodOptional>; + backup: z.ZodOptional>; created: z.ZodOptional>>; updated: z.ZodOptional>>; expiry: z.ZodOptional>>; diff --git a/lib/model/MessageUsage.js b/lib/model/MessageUsage.js index 1a1918b..ccbaa3e 100644 --- a/lib/model/MessageUsage.js +++ b/lib/model/MessageUsage.js @@ -73,11 +73,11 @@ export var MessageUsage; * See {@link Interface.reported}. A watermark, so it never decreases and is * never negative. */ - reported: nonNegativeNumber().optional(), + reported: nonNegativeNumber().nullish(), /** * See {@link Interface.pending}. */ - pending: pendingSchema.optional(), + pending: pendingSchema.nullish(), }); /** * Validates untrusted data as a message-usage record without throwing. diff --git a/lib/model/MessagingEvent.d.ts b/lib/model/MessagingEvent.d.ts index 8520a99..fba9894 100644 --- a/lib/model/MessagingEvent.d.ts +++ b/lib/model/MessagingEvent.d.ts @@ -77,31 +77,31 @@ export declare namespace MessagingEvent { /** * Firestore document ID of the account that owns this messaging event. */ - account?: string; + account?: string | null; /** * Identifier of the messaging service (e.g., Twilio Messaging Service SID) * used to deliver this event. */ - service?: string; + service?: string | null; /** * BCP 47 language tag for the message body (e.g., `"en"`, `"es"`), used * for content moderation and ML processing. */ - language?: string; + language?: string | null; /** * Array of media attachment URLs associated with this message (MMS only). */ - media?: string[]; + media?: string[] | null; /** * Plain-text body of the message. */ - body?: string; + body?: string | null; /** * Channel type of this event; see {@link Type} for accepted values. * Accepts a typed enum member or a raw string for forward-compatibility * with values stored in Firestore before this enum existed. */ - type?: Type | string; + type?: Type | string | null; /** * Firebase Auth UID of the user who originated this event, or `null` for * system-generated events. @@ -110,15 +110,15 @@ export declare namespace MessagingEvent { /** * When `true`, this event was processed by the machine-learning pipeline. */ - ml?: boolean; + ml?: boolean | null; /** * When `true`, the ML pipeline flagged this message content as unsafe. */ - unsafe?: boolean; + unsafe?: boolean | null; /** * Content classification labels applied by the ML safety classifier. */ - labels?: string[]; + labels?: string[] | null; /** * Human-readable error message if delivery failed, or `null` if no error * occurred. @@ -128,7 +128,7 @@ export declare namespace MessagingEvent { * Provider-specific error code returned by the downstream messaging * provider (e.g., a Twilio error code integer or string). */ - errorCodeProvider?: number | string; + errorCodeProvider?: number | string | null; /** * Snapshot of the sender's public profile at the time this event was * created; used for display purposes without a secondary Firestore lookup. @@ -137,33 +137,33 @@ export declare namespace MessagingEvent { /** * URL to the sender's avatar image. */ - avatar?: string; + avatar?: string | null; /** * Sender's first name. */ - firstName?: string; + firstName?: string | null; /** * Sender's last name. */ - lastName?: string; + lastName?: string | null; /** * Sender's full display name. */ - name?: string; + name?: string | null; /** * Abbreviated form of the sender's name (e.g., initials) for compact UI. */ - abbr?: string; + abbr?: string | null; /** * Sender's unique username handle. */ - username?: string; + username?: string | null; /** * Firebase Auth UID of the sender; required for all user-originated * events. */ id: string; - }; + } | null; } /** * Runtime schema producing {@link Interface}. @@ -185,29 +185,29 @@ export declare namespace MessagingEvent { * survives a parse and a round-trip untouched. */ const Schema: z.ZodObject<{ - account: z.ZodOptional; - service: z.ZodOptional; - language: z.ZodOptional; - media: z.ZodOptional>; - body: z.ZodOptional; - type: z.ZodOptional, z.ZodString]>>; + account: z.ZodOptional>; + service: z.ZodOptional>; + language: z.ZodOptional>; + media: z.ZodOptional>>; + body: z.ZodOptional>; + type: z.ZodOptional, z.ZodString]>>>; uid: z.ZodOptional>; - ml: z.ZodOptional; - unsafe: z.ZodOptional; - labels: z.ZodOptional>; + ml: z.ZodOptional>; + unsafe: z.ZodOptional>; + labels: z.ZodOptional>>; error: z.ZodOptional>; - errorCodeProvider: z.ZodOptional>; - user: z.ZodOptional; - firstName: z.ZodOptional; - lastName: z.ZodOptional; - name: z.ZodOptional; - abbr: z.ZodOptional; - username: z.ZodOptional; + errorCodeProvider: z.ZodOptional>>; + user: z.ZodOptional>; + firstName: z.ZodOptional>; + lastName: z.ZodOptional>; + name: z.ZodOptional>; + abbr: z.ZodOptional>; + username: z.ZodOptional>; id: z.ZodString; - }, z.core.$loose>>; - id: z.ZodOptional; - backup: z.ZodOptional; + }, z.core.$loose>>>; + id: z.ZodOptional>; + backup: z.ZodOptional>; created: z.ZodOptional>>; updated: z.ZodOptional>>; expiry: z.ZodOptional>>; diff --git a/lib/model/MessagingEvent.js b/lib/model/MessagingEvent.js index 7074102..261e195 100644 --- a/lib/model/MessagingEvent.js +++ b/lib/model/MessagingEvent.js @@ -81,27 +81,27 @@ export var MessagingEvent; /** * See {@link Interface.user}. */ - avatar: nonEmptyString().optional(), + avatar: nonEmptyString().nullish(), /** * Sender's first name. */ - firstName: z.string().optional(), + firstName: z.string().nullish(), /** * Sender's last name. */ - lastName: z.string().optional(), + lastName: z.string().nullish(), /** * Sender's full display name. */ - name: z.string().optional(), + name: z.string().nullish(), /** * Abbreviated form of the sender's name. */ - abbr: z.string().optional(), + abbr: z.string().nullish(), /** * Sender's unique username handle. */ - username: z.string().optional(), + username: z.string().nullish(), /** * Firebase Auth UID of the sender. */ @@ -131,62 +131,62 @@ export var MessagingEvent; /** * See {@link Interface.account}. */ - account: documentId().optional(), + account: documentId().nullish(), /** * See {@link Interface.service}. */ - service: nonEmptyString().optional(), + service: nonEmptyString().nullish(), /** * See {@link Interface.language}. */ - language: nonEmptyString().optional(), + language: nonEmptyString().nullish(), /** * See {@link Interface.media}. */ - media: z.array(z.string()).optional(), + media: z.array(z.string()).nullish(), /** * See {@link Interface.body}. Permitted to be empty: a delivery receipt for * a media-only message legitimately carries no text. */ - body: z.string().optional(), + body: z.string().nullish(), /** * See {@link Interface.type}, and the note on this schema about why a raw * string is still accepted. */ - type: z.union([z.enum(Type), z.string()]).optional(), + type: z.union([z.enum(Type), z.string()]).nullish(), /** * See {@link Interface.uid}. `null` denotes a system-generated event and * must survive a JSON round-trip. */ - uid: z.string().nullable().optional(), + uid: z.string().nullish(), /** * See {@link Interface.ml}. */ - ml: z.boolean().optional(), + ml: z.boolean().nullish(), /** * See {@link Interface.unsafe}. */ - unsafe: z.boolean().optional(), + unsafe: z.boolean().nullish(), /** * See {@link Interface.labels}. */ - labels: z.array(z.string()).optional(), + labels: z.array(z.string()).nullish(), /** * See {@link Interface.error}. `null` denotes "no error occurred", which is * a different claim from the field being absent. */ - error: z.string().nullable().optional(), + error: z.string().nullish(), /** * See {@link Interface.errorCodeProvider}. Accepted as either a number or a * string because providers differ, but **never coerced between them**: a * code turned into `NaN` by a reflexive `Number()` would compare equal to no * known code and silently classify a hard failure as unrecognised. */ - errorCodeProvider: z.union([z.number(), z.string()]).optional(), + errorCodeProvider: z.union([z.number(), z.string()]).nullish(), /** * See {@link Interface.user}. */ - user: userSnapshotSchema.optional(), + user: userSnapshotSchema.nullish(), }); /** * Validates untrusted data as a messaging event without throwing. diff --git a/lib/model/Post.d.ts b/lib/model/Post.d.ts index 96f28b3..69305e0 100644 --- a/lib/model/Post.d.ts +++ b/lib/model/Post.d.ts @@ -53,12 +53,12 @@ export declare namespace Post { /** * Firestore document ID of the account that owns this post. */ - account?: string; + account?: string | null; /** * Identifier of the service configuration used to fetch this post (e.g., * a Furcata service document ID). */ - service?: string; + service?: string | null; /** * Source URL or platform-specific content identifier, interpreted * according to the `type` field. @@ -68,7 +68,7 @@ export declare namespace Post { * Current lifecycle status of the post; see {@link Status} for accepted * values. */ - status?: Status; + status?: Status | null; /** * Platform origin of the post; see {@link Type} for accepted values. */ @@ -76,101 +76,101 @@ export declare namespace Post { /** * Firebase Auth UID of the user who added this post to the platform. */ - uid?: string; + uid?: string | null; /** * Content category label assigned to this post for filtering and * discovery purposes. */ - category?: string; + category?: string | null; /** * Human-readable description of the post, either sourced from the * external platform or provided by the curator. */ - description?: string; + description?: string | null; /** * When `true`, this post has been manually marked as featured and will * receive priority placement in listing results. */ - featured?: boolean; + featured?: boolean | null; /** * Curator-assigned tags used for internal categorisation and search. */ - tags?: string[]; + tags?: string[] | null; /** * Hashtags associated with this post, either extracted from the source * platform or generated by the ML pipeline. */ - hashtags?: string[]; + hashtags?: string[] | null; /** * URL of the primary featured image for this post. */ - image?: string; + image?: string | null; /** * URLs of all images extracted from this post (e.g., carousel items). */ - images?: string[]; + images?: string[] | null; /** * BCP 47 language tag detected or assigned for this post's content * (e.g., `"en"`, `"es"`). */ - language?: string; + language?: string | null; /** * URL of the primary media asset (video or audio) for this post. */ - media?: string; + media?: string | null; /** * When `true`, the content has been reviewed and is considered safe for * all audiences. */ - safe?: boolean; + safe?: boolean | null; /** * Title of the post, either sourced from the external platform or * provided by the curator. */ - title?: string; + title?: string | null; /** * Canonical or resolved final URL for the post, used for link previews and * sharing. */ - url?: string; + url?: string | null; /** * Firebase Auth UID of the originating content creator on the external * platform, if resolvable. */ - user?: string; + user?: string | null; /** * When `true`, this post has been processed by the ML enrichment pipeline. */ - ml?: boolean; + ml?: boolean | null; /** * ML-generated title suggestion for the post. */ - mlTitle?: string; + mlTitle?: string | null; /** * ML-generated description suggestion for the post. */ - mlDescription?: string; + mlDescription?: string | null; /** * ML-generated hashtag suggestions for the post. */ - mlHashtags?: string[]; + mlHashtags?: string[] | null; /** * ML-selected or generated featured image URL for the post. */ - mlImage?: string; + mlImage?: string | null; /** * When `true`, the external platform metadata for this post has been * successfully fetched and stored. */ - fetched?: boolean; + fetched?: boolean | null; /** * Cumulative view count for this post within the Furcata platform. */ - views?: number; + views?: number | null; /** * Cumulative like count for this post within the Furcata platform. */ - likes?: number; + likes?: number | null; } /** * Runtime schema producing {@link Interface}. @@ -186,35 +186,35 @@ export declare namespace Post { * {@link BaseFirestore}. */ const Schema: z.ZodObject<{ - account: z.ZodOptional; - service: z.ZodOptional; + account: z.ZodOptional>; + service: z.ZodOptional>; source: z.ZodString; - status: z.ZodOptional>; + status: z.ZodOptional>>; type: z.ZodEnum; - uid: z.ZodOptional; - category: z.ZodOptional; - description: z.ZodOptional; - featured: z.ZodOptional; - tags: z.ZodOptional>; - hashtags: z.ZodOptional>; - image: z.ZodOptional; - images: z.ZodOptional>; - language: z.ZodOptional; - media: z.ZodOptional; - safe: z.ZodOptional; - title: z.ZodOptional; - url: z.ZodOptional; - user: z.ZodOptional; - ml: z.ZodOptional; - mlTitle: z.ZodOptional; - mlDescription: z.ZodOptional; - mlHashtags: z.ZodOptional>; - mlImage: z.ZodOptional; - fetched: z.ZodOptional; - views: z.ZodOptional; - likes: z.ZodOptional; - id: z.ZodOptional; - backup: z.ZodOptional; + uid: z.ZodOptional>; + category: z.ZodOptional>; + description: z.ZodOptional>; + featured: z.ZodOptional>; + tags: z.ZodOptional>>; + hashtags: z.ZodOptional>>; + image: z.ZodOptional>; + images: z.ZodOptional>>; + language: z.ZodOptional>; + media: z.ZodOptional>; + safe: z.ZodOptional>; + title: z.ZodOptional>; + url: z.ZodOptional>; + user: z.ZodOptional>; + ml: z.ZodOptional>; + mlTitle: z.ZodOptional>; + mlDescription: z.ZodOptional>; + mlHashtags: z.ZodOptional>>; + mlImage: z.ZodOptional>; + fetched: z.ZodOptional>; + views: z.ZodOptional>; + likes: z.ZodOptional>; + id: z.ZodOptional>; + backup: z.ZodOptional>; created: z.ZodOptional>>; updated: z.ZodOptional>>; expiry: z.ZodOptional>>; diff --git a/lib/model/Post.js b/lib/model/Post.js index 1f88d25..f6bd54a 100644 --- a/lib/model/Post.js +++ b/lib/model/Post.js @@ -63,11 +63,11 @@ export var Post; /** * See {@link Interface.account}. */ - account: documentId().optional(), + account: documentId().nullish(), /** * See {@link Interface.service}. */ - service: nonEmptyString().optional(), + service: nonEmptyString().nullish(), /** * See {@link Interface.source}. Required: a post with no source URL or * platform identifier cannot be fetched or de-duplicated. @@ -76,7 +76,7 @@ export var Post; /** * See {@link Interface.status}. */ - status: z.enum(Status).optional(), + status: z.enum(Status).nullish(), /** * See {@link Interface.type}. Required and enum-constrained. */ @@ -84,91 +84,91 @@ export var Post; /** * See {@link Interface.uid}. */ - uid: nonEmptyString().optional(), + uid: nonEmptyString().nullish(), /** * See {@link Interface.category}. */ - category: z.string().optional(), + category: z.string().nullish(), /** * See {@link Interface.description}. */ - description: z.string().optional(), + description: z.string().nullish(), /** * See {@link Interface.featured}. */ - featured: z.boolean().optional(), + featured: z.boolean().nullish(), /** * See {@link Interface.tags}. */ - tags: z.array(z.string()).optional(), + tags: z.array(z.string()).nullish(), /** * See {@link Interface.hashtags}. */ - hashtags: z.array(z.string()).optional(), + hashtags: z.array(z.string()).nullish(), /** * See {@link Interface.image}. */ - image: nonEmptyString().optional(), + image: nonEmptyString().nullish(), /** * See {@link Interface.images}. */ - images: z.array(z.string()).optional(), + images: z.array(z.string()).nullish(), /** * See {@link Interface.language}. */ - language: nonEmptyString().optional(), + language: nonEmptyString().nullish(), /** * See {@link Interface.media}. */ - media: nonEmptyString().optional(), + media: nonEmptyString().nullish(), /** * See {@link Interface.safe}. */ - safe: z.boolean().optional(), + safe: z.boolean().nullish(), /** * See {@link Interface.title}. */ - title: z.string().optional(), + title: z.string().nullish(), /** * See {@link Interface.url}. */ - url: nonEmptyString().optional(), + url: nonEmptyString().nullish(), /** * See {@link Interface.user}. */ - user: nonEmptyString().optional(), + user: nonEmptyString().nullish(), /** * See {@link Interface.ml}. */ - ml: z.boolean().optional(), + ml: z.boolean().nullish(), /** * See {@link Interface.mlTitle}. */ - mlTitle: z.string().optional(), + mlTitle: z.string().nullish(), /** * See {@link Interface.mlDescription}. */ - mlDescription: z.string().optional(), + mlDescription: z.string().nullish(), /** * See {@link Interface.mlHashtags}. */ - mlHashtags: z.array(z.string()).optional(), + mlHashtags: z.array(z.string()).nullish(), /** * See {@link Interface.mlImage}. */ - mlImage: nonEmptyString().optional(), + mlImage: nonEmptyString().nullish(), /** * See {@link Interface.fetched}. */ - fetched: z.boolean().optional(), + fetched: z.boolean().nullish(), /** * See {@link Interface.views}. */ - views: counter().optional(), + views: counter().nullish(), /** * See {@link Interface.likes}. */ - likes: counter().optional(), + likes: counter().nullish(), }); /** * Validates untrusted data as a post document without throwing. diff --git a/lib/model/Price.d.ts b/lib/model/Price.d.ts index b54a669..49e50b7 100644 --- a/lib/model/Price.d.ts +++ b/lib/model/Price.d.ts @@ -51,39 +51,44 @@ export declare namespace Price { * Price amount expressed in the smallest currency unit (e.g., cents for * USD) to avoid floating-point rounding errors. */ - amount?: number; + amount?: number | null; /** * ISO 4217 currency code for this price (e.g., `"usd"`, `"eur"`). */ - currency?: string; + currency?: string | null; /** * Firestore document ID of the parent product or event that this price is * associated with. */ - source?: string; + source?: string | null; /** * URL of the primary display image for this price (e.g., product photo or * event cover art). */ - image?: string; + image?: string | null; /** * Short display name shown to buyers during the checkout flow. */ - label?: string; + label?: string | null; /** * Longer description of what the buyer is purchasing, displayed on the * checkout and confirmation pages. */ - description?: string; + description?: string | null; /** * Maximum number of users that may purchase this price; enforced by Cloud - * Functions during checkout. `undefined` means unlimited. + * Functions during checkout. + * + * Unlimited is expressed **both** ways in stored data: `null` in documents + * written by the usual path, and absent in older ones. Read it as + * `limit ?? Infinity` rather than testing for `undefined`, which answers + * `false` for the far more common of the two. */ - limit?: number; + limit?: number | null; /** * Item category for this price; see {@link Type} for accepted values. */ - type?: Type; + type?: Type | null; /** * Firebase Auth UID of a specific user this price is restricted to, or * `null` for publicly purchasable prices. @@ -92,29 +97,29 @@ export declare namespace Price { /** * Firebase Auth UIDs of users who have successfully purchased this price. */ - users?: string[]; + users?: string[] | null; /** * Visibility scope of this price record; see {@link Visibility} for * accepted values. */ - visibility?: Visibility; + visibility?: Visibility | null; /** * Cumulative number of times a link to this price has been clicked. */ - clicks?: number; + clicks?: number | null; /** * Cumulative number of times this price's detail page has been viewed. */ - views?: number; + views?: number | null; /** * Cumulative number of users who initiated the checkout flow for this * price. */ - checkout?: number; + checkout?: number | null; /** * Cumulative number of confirmed purchases for this price. */ - booked?: number; + booked?: number | null; } /** * Runtime schema producing {@link Interface}. @@ -129,26 +134,35 @@ export declare namespace Price { * signature inherited from {@link BaseFirestore}: a stripping schema would * delete unrecognised fields on a read-modify-write, and a strict one would * reject documents written before this schema existed. + * + * Every optional field is `.nullish()` rather than `.optional()`, because a + * stored price writes its unset fields as an explicit `null` rather than + * omitting them — `limit`, `description` and `image` in particular. A schema + * that accepted only `undefined` rejected the documents it exists to + * validate. The loosening is bounded to `null` alone: a wrong type, a + * fractional counter and an unrecognised enum member are all still rejected, + * as are `null` on the required {@link Interface.account} and on the audit + * timestamps. */ const Schema: z.ZodObject<{ account: z.ZodString; - amount: z.ZodOptional; - currency: z.ZodOptional; - source: z.ZodOptional; - image: z.ZodOptional; - label: z.ZodOptional; - description: z.ZodOptional; - limit: z.ZodOptional; - type: z.ZodOptional>; + amount: z.ZodOptional>; + currency: z.ZodOptional>; + source: z.ZodOptional>; + image: z.ZodOptional>; + label: z.ZodOptional>; + description: z.ZodOptional>; + limit: z.ZodOptional>; + type: z.ZodOptional>>; uid: z.ZodOptional>; - users: z.ZodOptional>; - visibility: z.ZodOptional>; - clicks: z.ZodOptional; - views: z.ZodOptional; - checkout: z.ZodOptional; - booked: z.ZodOptional; - id: z.ZodOptional; - backup: z.ZodOptional; + users: z.ZodOptional>>; + visibility: z.ZodOptional>>; + clicks: z.ZodOptional>; + views: z.ZodOptional>; + checkout: z.ZodOptional>; + booked: z.ZodOptional>; + id: z.ZodOptional>; + backup: z.ZodOptional>; created: z.ZodOptional>>; updated: z.ZodOptional>>; expiry: z.ZodOptional>>; diff --git a/lib/model/Price.js b/lib/model/Price.js index 6d8da64..7230ca1 100644 --- a/lib/model/Price.js +++ b/lib/model/Price.js @@ -51,6 +51,15 @@ export var Price; * signature inherited from {@link BaseFirestore}: a stripping schema would * delete unrecognised fields on a read-modify-write, and a strict one would * reject documents written before this schema existed. + * + * Every optional field is `.nullish()` rather than `.optional()`, because a + * stored price writes its unset fields as an explicit `null` rather than + * omitting them — `limit`, `description` and `image` in particular. A schema + * that accepted only `undefined` rejected the documents it exists to + * validate. The loosening is bounded to `null` alone: a wrong type, a + * fractional counter and an unrecognised enum member are all still rejected, + * as are `null` on the required {@link Interface.account} and on the audit + * timestamps. */ Price.Schema = z.looseObject({ ...baseFirestoreShape, @@ -64,71 +73,71 @@ export var Price; * a finite number: this field is money, and a silent `NaN` is the defect * this schema exists to stop. */ - amount: finiteNumber().optional(), + amount: finiteNumber().nullish(), /** * See {@link Interface.currency}. Constrained to a three-letter ISO 4217 * code, which is a genuinely closed grammar rather than a convention. */ - currency: z.string().regex(/^[A-Za-z]{3}$/, { error: 'Expected a three-letter ISO 4217 currency code' }).optional(), + currency: z.string().regex(/^[A-Za-z]{3}$/, { error: 'Expected a three-letter ISO 4217 currency code' }).nullish(), /** * See {@link Interface.source}. */ - source: documentId().optional(), + source: documentId().nullish(), /** * See {@link Interface.image}. */ - image: nonEmptyString().optional(), + image: nonEmptyString().nullish(), /** * See {@link Interface.label}. */ - label: z.string().optional(), + label: z.string().nullish(), /** * See {@link Interface.description}. */ - description: z.string().optional(), + description: z.string().nullish(), /** - * See {@link Interface.limit}. Absent means unlimited; a present value is a - * whole number of buyers, so a fractional limit is rejected. + * See {@link Interface.limit}. `null` or absent means unlimited; a present + * value is a whole number of buyers, so a fractional limit is rejected. */ - limit: counter().optional(), + limit: counter().nullish(), /** * See {@link Interface.type}. Validated against {@link Type} rather than * asserted into it, so an unrecognised item category fails here instead of * routing post-payment logic down the wrong branch. */ - type: z.enum(Type).optional(), + type: z.enum(Type).nullish(), /** * See {@link Interface.uid}. Explicitly nullable: `null` means publicly * purchasable and must survive a JSON round-trip, which `undefined` would * not. */ - uid: z.string().nullable().optional(), + uid: z.string().nullish(), /** * See {@link Interface.users}. */ - users: z.array(z.string()).optional(), + users: z.array(z.string()).nullish(), /** * See {@link Interface.visibility}. Validated against {@link Visibility}, * so an unrecognised value cannot widen access by failing an equality check * against `private`. */ - visibility: z.enum(Visibility).optional(), + visibility: z.enum(Visibility).nullish(), /** * See {@link Interface.clicks}. */ - clicks: counter().optional(), + clicks: counter().nullish(), /** * See {@link Interface.views}. */ - views: counter().optional(), + views: counter().nullish(), /** * See {@link Interface.checkout}. */ - checkout: counter().optional(), + checkout: counter().nullish(), /** * See {@link Interface.booked}. */ - booked: counter().optional(), + booked: counter().nullish(), }); /** * Validates untrusted data as a price document without throwing. diff --git a/src/interface/base_db.ts b/src/interface/base_db.ts index 3468d99..891d496 100644 --- a/src/interface/base_db.ts +++ b/src/interface/base_db.ts @@ -19,13 +19,13 @@ export interface BaseFirestore { /** * Firestore document identifier, typically the auto-generated document ID. */ - id?: string; + id?: string | null; /** * Backup status flag for long-term historical storage. * When `true`, the document has been successfully backed up to the * historical long-term database. When `false`, the backup is still pending. */ - backup?: boolean; + backup?: boolean | null; /** * Server-side timestamp recorded when the document was first created. */ @@ -63,16 +63,30 @@ export interface BaseFirestore { * at the boundary instead, where it can actually be checked. See * {@link auditTimestamp} for the shapes accepted, what is rejected, and how to * widen a schema for a write payload. + * + * {@link BaseFirestore.id} and {@link BaseFirestore.backup} are `.nullish()`, + * because a stored document writes an unset optional field as an explicit + * `null` rather than omitting it, and a field that accepted only `undefined` + * would reject documents that are otherwise entirely well formed. + * + * The three timestamps are deliberately **not** `.nullish()` and keep rejecting + * `null`. An explicitly null timestamp is not a time: read as one it becomes + * epoch zero, which sorts first and — for {@link BaseFirestore.expiry}, a TTL — + * expires the document immediately. No stored null was observed in any of the + * three, so the exemption costs nothing today. If one is ever observed, the fix + * is a decision about what a null instant means, recorded here, rather than a + * blanket loosening. The `nullRejecting` inventory in + * `test/interface/schema.test.ts` is what holds that line. */ export const baseFirestoreShape = { /** * See {@link BaseFirestore.id}. */ - id: documentId().optional(), + id: documentId().nullish(), /** * See {@link BaseFirestore.backup}. */ - backup: z.boolean().optional(), + backup: z.boolean().nullish(), /** * See {@link BaseFirestore.created}. */ diff --git a/src/interface/place.ts b/src/interface/place.ts index 10e516d..20ffb84 100644 --- a/src/interface/place.ts +++ b/src/interface/place.ts @@ -24,41 +24,41 @@ export interface BasePlaceData { /** * GeoJSON-style `[longitude, latitude]` coordinate pair for the place. */ - location?: number[]; + location?: number[] | null; /** * Google Places API place identifier for the location. */ - placeId?: string; + placeId?: string | null; /** * Decimal degrees latitude of the place. */ - latitude?: number; + latitude?: number | null; /** * Decimal degrees longitude of the place. */ - longitude?: number; + longitude?: number | null; /** * Human-readable display name for the place. */ - placeName?: string; + placeName?: string | null; /** * UTC offset in minutes for the place's local timezone. */ - utcOffset?: number; + utcOffset?: number | null; /** * ISO 3166-1 alpha-2 country code (e.g., `"US"`, `"CA"`). */ - country?: string; + country?: string | null; /** * Geohash string encoding the place's latitude/longitude for efficient * proximity queries in Firestore. */ - geohash?: string; + geohash?: string | null; /** * Administrative region/state/province name, also known as the geographic * area designation. */ - area?: string; // AKA: region + area?: string | null; // AKA: region } /** @@ -102,42 +102,42 @@ export const basePlaceDataShape = { * unconstrained: the published type is `number[]`, and rejecting a stored * array of another length would narrow it. */ - location: z.array(z.number()).optional(), + location: z.array(z.number()).nullish(), /** * See {@link BasePlaceData.placeId}. */ - placeId: nonEmptyString().optional(), + placeId: nonEmptyString().nullish(), /** * See {@link BasePlaceData.latitude}. */ - latitude: latitudeDegrees().optional(), + latitude: latitudeDegrees().nullish(), /** * See {@link BasePlaceData.longitude}. */ - longitude: longitudeDegrees().optional(), + longitude: longitudeDegrees().nullish(), /** * See {@link BasePlaceData.placeName}. */ - placeName: nonEmptyString().optional(), + placeName: nonEmptyString().nullish(), /** * See {@link BasePlaceData.utcOffset}. */ - utcOffset: utcOffsetMinutes().optional(), + utcOffset: utcOffsetMinutes().nullish(), /** * See {@link BasePlaceData.country}. Accepted as any non-empty string rather * than a two-letter code: the field is documented as ISO 3166-1 alpha-2, but * narrowing a published field to a fixed length would reject any stored * document that predates that convention. */ - country: nonEmptyString().optional(), + country: nonEmptyString().nullish(), /** * See {@link BasePlaceData.geohash}. */ - geohash: nonEmptyString().optional(), + geohash: nonEmptyString().nullish(), /** * See {@link BasePlaceData.area}. */ - area: nonEmptyString().optional(), + area: nonEmptyString().nullish(), }; /** @@ -172,11 +172,11 @@ export interface PlaceData { * ISO 8601 timestamp string recorded when this place document was first * created; required for auditing. */ - created?: string; // Required - timestamp + created?: string | null; // Required - timestamp /** * Unique Firestore document identifier for this place; required for lookups. */ - id?: string; // Required + id?: string | null; // Required /** * Short administrative area (region/state) name, or `null` if unavailable. */ @@ -188,7 +188,7 @@ export interface PlaceData { /** * Numeric identifiers for parent area documents used in hierarchical queries. */ - areas?: number[]; + areas?: number[] | null; /** * Short city name, or `null` if unavailable. */ @@ -208,16 +208,16 @@ export interface PlaceData { /** * Decimal degrees latitude; required for geospatial queries. */ - latitude?: number; // Required + latitude?: number | null; // Required /** * Decimal degrees longitude; required for geospatial queries. */ - longitude?: number; // Required + longitude?: number | null; // Required /** * When `true`, indicates this place is local/domestic relative to the * primary operating region; required for filtering. */ - local?: boolean; // Required + local?: boolean | null; // Required /** * Full display name of the place, or `null` if unavailable. */ @@ -241,27 +241,27 @@ export interface PlaceData { /** * UTC offset in minutes for the place's timezone; required for scheduling. */ - timeOffset?: number; // Required + timeOffset?: number | null; // Required /** * IANA timezone identifier (e.g., `"America/New_York"`); required for * accurate local-time calculations. */ - timeZoneId?: string; // Required + timeZoneId?: string | null; // Required /** * Human-readable timezone name (e.g., `"Eastern Standard Time"`); required * for display purposes. */ - timeZoneName?: string; // Required + timeZoneName?: string | null; // Required /** * Administrative level of this place as categorised by {@link PlaceType}; * required for hierarchical filtering. */ - type?: PlaceType; // Required + type?: PlaceType | null; // Required /** * ISO 8601 timestamp string recorded the last time this document was * modified; required for cache invalidation. */ - updated?: string; // Required - timestamp + updated?: string | null; // Required - timestamp /** * Public URL for this place on an external directory or maps service, or * `null` if unavailable. @@ -285,7 +285,7 @@ export interface PlaceData { latitude: number; longitude: number; }; - }; + } | null; } /** @@ -314,108 +314,108 @@ export const placeDataShape = { /** * See {@link PlaceData.created}. */ - created: nonEmptyString().optional(), + created: nonEmptyString().nullish(), /** * See {@link PlaceData.id}. */ - id: nonEmptyString().optional(), + id: nonEmptyString().nullish(), /** * See {@link PlaceData.area}. */ - area: z.string().nullable().optional(), + area: z.string().nullish(), /** * See {@link PlaceData.areaLong}. */ - areaLong: z.string().nullable().optional(), + areaLong: z.string().nullish(), /** * See {@link PlaceData.areas}. */ - areas: z.array(z.number()).optional(), + areas: z.array(z.number()).nullish(), /** * See {@link PlaceData.city}. */ - city: z.string().nullable().optional(), + city: z.string().nullish(), /** * See {@link PlaceData.cityLong}. */ - cityLong: z.string().nullable().optional(), + cityLong: z.string().nullish(), /** * See {@link PlaceData.country}. */ - country: z.string().nullable().optional(), + country: z.string().nullish(), /** * See {@link PlaceData.countryLong}. */ - countryLong: z.string().nullable().optional(), + countryLong: z.string().nullish(), /** * See {@link PlaceData.latitude}. */ - latitude: latitudeDegrees().optional(), + latitude: latitudeDegrees().nullish(), /** * See {@link PlaceData.longitude}. */ - longitude: longitudeDegrees().optional(), + longitude: longitudeDegrees().nullish(), /** * See {@link PlaceData.local}. */ - local: z.boolean().optional(), + local: z.boolean().nullish(), /** * See {@link PlaceData.longName}. */ - longName: z.string().nullable().optional(), + longName: z.string().nullish(), /** * See {@link PlaceData.name}. */ - name: z.string().nullable().optional(), + name: z.string().nullish(), /** * See {@link PlaceData.postalCode}. Numeric rather than string by declaration; * a postal code supplied as text is rejected here rather than coerced, because * `Number('SW1A')` is `NaN` and a `NaN` postal code matches nothing while * looking like a value. */ - postalCode: z.number().nullable().optional(), + postalCode: z.number().nullish(), /** * See {@link PlaceData.state}. */ - state: z.string().nullable().optional(), + state: z.string().nullish(), /** * See {@link PlaceData.stateLong}. */ - stateLong: z.string().nullable().optional(), + stateLong: z.string().nullish(), /** * See {@link PlaceData.timeOffset}. */ - timeOffset: utcOffsetMinutes().optional(), + timeOffset: utcOffsetMinutes().nullish(), /** * See {@link PlaceData.timeZoneId}. */ - timeZoneId: nonEmptyString().optional(), + timeZoneId: nonEmptyString().nullish(), /** * See {@link PlaceData.timeZoneName}. */ - timeZoneName: nonEmptyString().optional(), + timeZoneName: nonEmptyString().nullish(), /** * See {@link PlaceData.type}. Constrained to {@link PlaceType}, so an * unrecognised administrative level is rejected instead of being asserted into * the enum by a cast. */ - type: z.enum(PlaceType).optional(), + type: z.enum(PlaceType).nullish(), /** * See {@link PlaceData.updated}. */ - updated: nonEmptyString().optional(), + updated: nonEmptyString().nullish(), /** * See {@link PlaceData.url}. */ - url: z.string().nullable().optional(), + url: z.string().nullish(), /** * See {@link PlaceData.vicinity}. */ - vicinity: z.string().nullable().optional(), + vicinity: z.string().nullish(), /** * See {@link PlaceData.viewport}. */ - viewport: viewportCornerBoxSchema().optional(), + viewport: viewportCornerBoxSchema().nullish(), }; /** diff --git a/src/interface/queue.ts b/src/interface/queue.ts index fdc6511..b6844f5 100644 --- a/src/interface/queue.ts +++ b/src/interface/queue.ts @@ -18,21 +18,21 @@ export interface MessageQueue { * Number of messages that have been created but not yet validated or approved * for sending. */ - pending?: number; + pending?: number | null; /** * Number of messages that have passed validation and are ready to be picked * up by the sender worker. */ - ready?: number; + ready?: number | null; /** * Number of messages currently assigned to a sender worker for processing. */ - sender?: number; + sender?: number | null; /** * Number of messages actively being transmitted to the downstream messaging * provider (e.g., Twilio). */ - sending?: number; + sending?: number | null; /** * Arbitrary snapshot or metadata captured at the time the queue was last * counted; used for auditing and diagnostics. @@ -55,19 +55,19 @@ export const messageQueueShape = { /** * See {@link MessageQueue.pending}. */ - pending: counter().optional(), + pending: counter().nullish(), /** * See {@link MessageQueue.ready}. */ - ready: counter().optional(), + ready: counter().nullish(), /** * See {@link MessageQueue.sender}. */ - sender: counter().optional(), + sender: counter().nullish(), /** * See {@link MessageQueue.sending}. */ - sending: counter().optional(), + sending: counter().nullish(), /** * See {@link MessageQueue.counted}. Deliberately open: the field is declared * as an arbitrary diagnostic snapshot and constraining it here would narrow a diff --git a/src/interface/schema.ts b/src/interface/schema.ts index 182c16e..2233e0b 100644 --- a/src/interface/schema.ts +++ b/src/interface/schema.ts @@ -647,6 +647,17 @@ export const timestampLike = (): z.ZodType => z.cu * time, and treating it as one produces an epoch-zero date that sorts first and * expires immediately. * + * That rejection is why every field validated by this helper stays `.optional()` + * while the rest of the package's stored-document fields are `.nullish()`. The + * general rule there is that a stored optional field arrives as an explicit + * `null`, so a schema must accept one; the exception here is that for an instant + * specifically, accepting `null` would hand a caller a value that reads as a + * date and denotes 1970. The exemption is inventoried in `nullRejecting` in + * `test/interface/schema.test.ts`, so it cannot be widened silently — and it is + * an exemption rather than a preference: if a stored document is ever observed + * carrying `null` in one of these fields, the correct response is to decide what + * a null instant means and record it, not to reach for `.nullish()`. + * * @return {z.ZodType} Schema accepting any read shape of a stored timestamp. */ export const auditTimestamp = (): z.ZodType => diff --git a/src/model/Account.ts b/src/model/Account.ts index 1b88833..afa421c 100644 --- a/src/model/Account.ts +++ b/src/model/Account.ts @@ -91,28 +91,28 @@ export namespace Account { /** * Representative's legal first name. */ - firstName?: string; + firstName?: string | null; /** * Representative's legal last name. */ - lastName?: string; + lastName?: string | null; /** * Representative's business email address. */ - email?: string; + email?: string | null; /** * Representative's direct phone number in E.164 format. */ - phoneNumber?: string; + phoneNumber?: string | null; /** * Representative's business title as it appears on company documents. */ - businessTitle?: string; + businessTitle?: string | null; /** * Representative's seniority or functional role; see * {@link AuthorizedRepresentativeJobPosition} for accepted values. */ - jobPosition?: AuthorizedRepresentativeJobPosition; + jobPosition?: AuthorizedRepresentativeJobPosition | null; } // This is required for Twilio brand registration. Must be calculated depending on the estimatedVolume. @@ -344,6 +344,24 @@ export namespace Account { sweepstake = 'SWEEPSTAKE', } + /** + * Social and web links, derived from the shared-helpers `User.InterfaceLinks` + * definition with every member additionally permitted to be `null`. + * + * Declared as a mapped type over `User.InterfaceLinks` rather than as a + * hand-written copy, so a member added or renamed upstream appears here + * automatically and this type cannot drift from the definition it is derived + * from. The runtime schema is a separate hand-written copy and *can* drift; + * {@link LinksKeysCovered} is what catches that. + * + * The `| null` is what the stored documents actually require. A link that has + * never been filled in is written as an explicit `null` rather than omitted, + * and `User.InterfaceLinks` alone cannot describe that — which is also why + * this type exists rather than the field being declared `User.InterfaceLinks` + * directly. + */ + export type Links = {[TMember in keyof User.InterfaceLinks]?: User.InterfaceLinks[TMember] | null}; + /** * Firestore document shape for a Furcata account. * @@ -357,24 +375,24 @@ export namespace Account { /** * Preferred language. */ - language?: string; + language?: string | null; /** * Image path. */ - image?: string; + image?: string | null; /** * Full image URL for quick use. */ - imageURL?: string; + imageURL?: string | null; /** * Account name. * It should be the legal name or in case of sending on behalf an eleted official, use that name */ - name?: string; + name?: string | null; /** * Legal business name. */ - businessName?: string; + businessName?: string | null; /** * The name to use. * Examples: @@ -382,153 +400,155 @@ export namespace Account { * If they don't match: businessName (name) * This works for example in case of registering a government organization that sends on behalf of elected official */ - useName?: string; + useName?: string | null; /** * Public site description. */ - description?: string; + description?: string | null; /** * Current lifecycle status of the account; see {@link Status} for accepted * values. */ - status?: Status; + status?: Status | null; /** * Organisation classification; see {@link Type} for accepted values. */ - type?: Type; + type?: Type | null; /** * Firebase Auth UID of the account owner. */ - uid?: string; + uid?: string | null; /** * Social and web links associated with the account, sourced from the - * shared-helpers `User.InterfaceLinks` definition. + * shared-helpers `User.InterfaceLinks` definition; see {@link Links} for why + * the field is declared through a mapped type rather than as + * `User.InterfaceLinks` directly. */ - links?: User.InterfaceLinks, + links?: Links | null, // Registration /** * Legal company structure; see {@link CompanyType} for accepted values. * Submitted to Twilio during brand registration. */ - companyType?: CompanyType; + companyType?: CompanyType | null; /** * Stock exchange on which the company is listed; see {@link StockExchange} * for accepted values. Use `NONE` for private companies. */ - stockExchange?: StockExchange; + stockExchange?: StockExchange | null; /** * Ticker symbol of the company on the `stockExchange`, if publicly traded. */ - stockTicker?: string; + stockTicker?: string | null; /** * Legal form of the business entity; see {@link BusinessType} for accepted * values. */ - businessType?: BusinessType; + businessType?: BusinessType | null; /** * Regions where the account operates; see {@link BusinessRegionsOfOperations} * for accepted values. */ - businessRegionsOfOperations?: BusinessRegionsOfOperations; + businessRegionsOfOperations?: BusinessRegionsOfOperations | null; /** * Type of government-issued registration number provided; see * {@link BusinessRegistrationIdentifier} for accepted values. Must be * sent to Twilio in uppercase. */ - businessRegistrationIdentifier?: BusinessRegistrationIdentifier; + businessRegistrationIdentifier?: BusinessRegistrationIdentifier | null; /** * Primary industry of the account; see {@link BusinessIndustry} for * accepted values. */ - businessIndustry?: BusinessIndustry; + businessIndustry?: BusinessIndustry | null; /** * Government-issued business registration number corresponding to the * `businessRegistrationIdentifier` type (e.g., EIN, DUNS). */ - businessRegistrationNumber?: string; + businessRegistrationNumber?: string | null; /** * Primary authorised representative for Twilio brand registration. */ - authorizedRepresentative1?: AuthorizedRepresentative; + authorizedRepresentative1?: AuthorizedRepresentative | null; /** * Secondary authorised representative for Twilio brand registration * (optional). */ - authorizedRepresentative2?: AuthorizedRepresentative; + authorizedRepresentative2?: AuthorizedRepresentative | null; /** * Estimated monthly message volume used to auto-select the appropriate * `brandType` for Twilio A2P 10DLC registration. */ - estimatedVolume?: number; + estimatedVolume?: number | null; /** * Twilio brand tier derived from `estimatedVolume`; see {@link BrandType} * for accepted values. */ - brandType?: BrandType; + brandType?: BrandType | null; /** * A2P 10DLC campaign use case; see {@link AppToPersonUseCase} for accepted * values. Submitted to Twilio during campaign registration. */ - appToPersonUseCase?: AppToPersonUseCase; + appToPersonUseCase?: AppToPersonUseCase | null; /** * Declared use-case description for toll-free number campaign registration. */ - tollFreeUseCase?: string; + tollFreeUseCase?: string | null; /** * Detailed description of the use case to use on the 10DLC registration and for Twilio to understand the use case and be able to approve it. */ - useCaseDescription?: string; + useCaseDescription?: string | null; /** * Shorter and to the point to use on the opt-in consent. */ - useCaseDescriptionCTA?: string; + useCaseDescriptionCTA?: string | null; /** * This is used to turn on/off the automatic header that is added to the top of the message for compliance reasons. * This is a custom feature for bulk and test messages in case the customer wants to use their own header or put the identification on the footer. * This should not be used for transactional messages. */ - automaticHeader?: boolean; + automaticHeader?: boolean | null; // Address /** * Postal or ZIP code of the account's registered business address. */ - postalCode?: string; + postalCode?: string | null; /** * Administrative region (state/province) of the business address. */ - area?: string; // Region + area?: string | null; // Region /** * City of the business address. */ - city?: string; + city?: string | null; /** * First line of the street address. */ - street1?: string; + street1?: string | null; /** * Second line of the street address (suite, floor, etc.). */ - street2?: string; + street2?: string | null; /** * ISO 3166-1 alpha-2 country code for the business address (e.g., `"US"`). */ - country?: string; + country?: string | null; /** * UTC offset in minutes for the place's local timezone. */ - utcOffset?: number; + utcOffset?: number | null; // Domain /** * Custom domain associated with this account (e.g., `"example.com"`), used * for domain-based authentication and white-labelling. */ - domain?: string; + domain?: string | null; /** * When `true`, the `domain` value has been verified and is active for * routing. */ - domainOk?: boolean; + domainOk?: boolean | null; /** * Timestamp recording when the domain was last verified or checked. */ @@ -537,35 +557,35 @@ export namespace Account { * Short alphanumeric alias for this account used in public-facing URLs * and API routes. */ - alias?: string; + alias?: string | null; // Sample Messages /** * First sample message submitted to Twilio during A2P 10DLC campaign * registration to demonstrate the type of content that will be sent. */ - sampleMessage1?: string; + sampleMessage1?: string | null; /** * Second sample message for Twilio campaign registration. */ - sampleMessage2?: string; + sampleMessage2?: string | null; /** * Third sample message for Twilio campaign registration. */ - sampleMessage3?: string; + sampleMessage3?: string | null; /** * Fourth sample message for Twilio campaign registration. */ - sampleMessage4?: string; + sampleMessage4?: string | null; /** * Fifth sample message for Twilio campaign registration. */ - sampleMessage5?: string; + sampleMessage5?: string | null; // Billing /** * Stripe Connected Account ID used for billing and payment processing on * behalf of this account. */ - bca?: string; // Billing Connected Account + bca?: string | null; // Billing Connected Account } /** @@ -583,77 +603,99 @@ export namespace Account { /** * See {@link AuthorizedRepresentative.firstName}. */ - firstName: z.string().optional(), + firstName: z.string().nullish(), /** * See {@link AuthorizedRepresentative.lastName}. */ - lastName: z.string().optional(), + lastName: z.string().nullish(), /** * See {@link AuthorizedRepresentative.email}. */ - email: z.string().optional(), + email: z.string().nullish(), /** * See {@link AuthorizedRepresentative.phoneNumber}. */ - phoneNumber: z.string().optional(), + phoneNumber: z.string().nullish(), /** * See {@link AuthorizedRepresentative.businessTitle}. */ - businessTitle: z.string().optional(), + businessTitle: z.string().nullish(), /** * See {@link AuthorizedRepresentative.jobPosition}. */ - jobPosition: z.enum(AuthorizedRepresentativeJobPosition).optional(), + jobPosition: z.enum(AuthorizedRepresentativeJobPosition).nullish(), }); /** * Schema for the social and web links block sourced from the shared-helpers * `User.InterfaceLinks` definition. * - * Declared here rather than imported because that package ships types only; - * the compile-time proof on {@link Schema} is what keeps this copy honest, so - * a change to `User.InterfaceLinks` that this schema does not follow becomes a - * build failure rather than a silent divergence. + * Declared here rather than imported because that package ships types only. + * {@link LinksKeysCovered} below is what keeps this copy honest — **not** the + * compile-time proof on {@link Schema}, which cannot see a member added + * upstream. `z.looseObject` infers a `[x: string]: unknown` index signature, + * and an index signature on the source of an assignment does not supply named + * members to satisfy an optional property on the target, so a new upstream + * `mastodon?: string` is simply read as absent-and-optional and checks clean. + * That was measured, by adding a member upstream and observing the build stay + * green. */ const linksSchema = z.looseObject({ /** * Behance profile URL. */ - behance: z.string().optional(), + behance: z.string().nullish(), /** * Dribbble profile URL. */ - dribbble: z.string().optional(), + dribbble: z.string().nullish(), /** * Facebook page or profile URL. */ - facebook: z.string().optional(), + facebook: z.string().nullish(), /** * Instagram profile URL. */ - instagram: z.string().optional(), + instagram: z.string().nullish(), /** * LinkedIn profile URL. */ - linkedin: z.string().optional(), + linkedin: z.string().nullish(), /** * TikTok profile URL. */ - tiktok: z.string().optional(), + tiktok: z.string().nullish(), /** * X profile URL. */ - x: z.string().optional(), + x: z.string().nullish(), /** * YouTube channel URL. */ - youtube: z.string().optional(), + youtube: z.string().nullish(), /** * Primary website URL. */ - website: z.string().optional(), + website: z.string().nullish(), }); + /** + * Compile-time proof that {@link linksSchema} declares a field for **every** + * member of the shared-helpers `User.InterfaceLinks` definition. + * + * This is the drift detector for the hand-maintained copy above, and it is + * separate from {@link SchemaOutput} because the two catch opposite failures. + * `SchemaOutput` compares inferred *values* and so catches this copy declaring + * a member with the wrong type; it is structurally blind to a member that is + * missing here, because an absent optional property is a legal shape. This + * alias compares *keys*, so a member added or renamed upstream and not + * followed here is a build failure naming the member. + * + * Verified by adding a member to the upstream definition and observing this + * alias turn red while everything else stayed green. + */ + export type LinksKeysCovered = AssertSchemaOutput; + /** * Runtime schema producing {@link Interface}. * @@ -672,6 +714,21 @@ export namespace Account { * * Unknown keys are preserved, matching the `[x: string]: any` index signature * inherited from {@link BaseFirestore}. + * + * Every optional field is `.nullish()` rather than `.optional()`. A stored + * account is filled in progressively and its unfilled registration fields are + * written as an explicit `null` rather than omitted, so a schema accepting + * only `undefined` rejected substantially every stored account rather than an + * unusual one. The loosening is bounded to `null` alone: an unrecognised enum + * member, a wrong type and an out-of-range offset are all still rejected, as + * are `null` on {@link Interface.domainTimestamp} and on the audit timestamps. + * + * Stored accounts also carry place fields — `geohash`, `latitude`, + * `longitude`, `placeId` — that {@link Interface} does not declare. They pass + * through as unknown keys and are preserved rather than rejected, so they are + * unaffected by any of the above. Whether they should be modelled here, most + * likely by spreading `basePlaceDataShape`, is a separate question for the + * owner of this shape and is deliberately not answered by this schema. */ export const Schema = z.looseObject({ ...baseFirestoreShape, @@ -679,158 +736,158 @@ export namespace Account { /** * See {@link Interface.language}. */ - language: nonEmptyString().optional(), + language: nonEmptyString().nullish(), /** * See {@link Interface.image}. */ - image: nonEmptyString().optional(), + image: nonEmptyString().nullish(), /** * See {@link Interface.imageURL}. */ - imageURL: nonEmptyString().optional(), + imageURL: nonEmptyString().nullish(), /** * See {@link Interface.name}. */ - name: z.string().optional(), + name: z.string().nullish(), /** * See {@link Interface.businessName}. */ - businessName: z.string().optional(), + businessName: z.string().nullish(), /** * See {@link Interface.useName}. */ - useName: z.string().optional(), + useName: z.string().nullish(), /** * See {@link Interface.description}. */ - description: z.string().optional(), + description: z.string().nullish(), /** * See {@link Interface.status}. Validated against {@link Status}: this field * gates whether the message queue runs at all, so a value that is neither a * known status nor `paused` fails open and keeps sending. */ - status: z.enum(Status).optional(), + status: z.enum(Status).nullish(), /** * See {@link Interface.type}. */ - type: z.enum(Type).optional(), + type: z.enum(Type).nullish(), /** * See {@link Interface.uid}. */ - uid: nonEmptyString().optional(), + uid: nonEmptyString().nullish(), /** * See {@link Interface.links}. */ - links: linksSchema.optional(), + links: linksSchema.nullish(), /** * See {@link Interface.companyType}. */ - companyType: z.enum(CompanyType).optional(), + companyType: z.enum(CompanyType).nullish(), /** * See {@link Interface.stockExchange}. */ - stockExchange: z.enum(StockExchange).optional(), + stockExchange: z.enum(StockExchange).nullish(), /** * See {@link Interface.stockTicker}. */ - stockTicker: z.string().optional(), + stockTicker: z.string().nullish(), /** * See {@link Interface.businessType}. */ - businessType: z.enum(BusinessType).optional(), + businessType: z.enum(BusinessType).nullish(), /** * See {@link Interface.businessRegionsOfOperations}. */ - businessRegionsOfOperations: z.enum(BusinessRegionsOfOperations).optional(), + businessRegionsOfOperations: z.enum(BusinessRegionsOfOperations).nullish(), /** * See {@link Interface.businessRegistrationIdentifier}. */ - businessRegistrationIdentifier: z.enum(BusinessRegistrationIdentifier).optional(), + businessRegistrationIdentifier: z.enum(BusinessRegistrationIdentifier).nullish(), /** * See {@link Interface.businessIndustry}. */ - businessIndustry: z.enum(BusinessIndustry).optional(), + businessIndustry: z.enum(BusinessIndustry).nullish(), /** * See {@link Interface.businessRegistrationNumber}. */ - businessRegistrationNumber: z.string().optional(), + businessRegistrationNumber: z.string().nullish(), /** * See {@link Interface.authorizedRepresentative1}. */ - authorizedRepresentative1: authorizedRepresentativeSchema.optional(), + authorizedRepresentative1: authorizedRepresentativeSchema.nullish(), /** * See {@link Interface.authorizedRepresentative2}. */ - authorizedRepresentative2: authorizedRepresentativeSchema.optional(), + authorizedRepresentative2: authorizedRepresentativeSchema.nullish(), /** * See {@link Interface.estimatedVolume}. A whole number of messages per * month; {@link Interface.brandType} is derived from it, so a value that * arrived as a string and became `NaN` would select a brand tier by * comparing `NaN` against every threshold and losing every comparison. */ - estimatedVolume: counter().optional(), + estimatedVolume: counter().nullish(), /** * See {@link Interface.brandType}. */ - brandType: z.enum(BrandType).optional(), + brandType: z.enum(BrandType).nullish(), /** * See {@link Interface.appToPersonUseCase}. */ - appToPersonUseCase: z.enum(AppToPersonUseCase).optional(), + appToPersonUseCase: z.enum(AppToPersonUseCase).nullish(), /** * See {@link Interface.tollFreeUseCase}. */ - tollFreeUseCase: z.string().optional(), + tollFreeUseCase: z.string().nullish(), /** * See {@link Interface.useCaseDescription}. */ - useCaseDescription: z.string().optional(), + useCaseDescription: z.string().nullish(), /** * See {@link Interface.useCaseDescriptionCTA}. */ - useCaseDescriptionCTA: z.string().optional(), + useCaseDescriptionCTA: z.string().nullish(), /** * See {@link Interface.automaticHeader}. */ - automaticHeader: z.boolean().optional(), + automaticHeader: z.boolean().nullish(), /** * See {@link Interface.postalCode}. */ - postalCode: z.string().optional(), + postalCode: z.string().nullish(), /** * See {@link Interface.area}. */ - area: z.string().optional(), + area: z.string().nullish(), /** * See {@link Interface.city}. */ - city: z.string().optional(), + city: z.string().nullish(), /** * See {@link Interface.street1}. */ - street1: z.string().optional(), + street1: z.string().nullish(), /** * See {@link Interface.street2}. */ - street2: z.string().optional(), + street2: z.string().nullish(), /** * See {@link Interface.country}. */ - country: z.string().optional(), + country: z.string().nullish(), /** * See {@link Interface.utcOffset}. Whole minutes, not hours: offsets such as * `+05:45` are not expressible in whole hours at all, so an hours value * accepted here would be wrong by a factor of sixty. */ - utcOffset: z.int().min(-1440).max(1440).optional(), + utcOffset: z.int().min(-1440).max(1440).nullish(), /** * See {@link Interface.domain}. */ - domain: nonEmptyString().optional(), + domain: nonEmptyString().nullish(), /** * See {@link Interface.domainOk}. */ - domainOk: z.boolean().optional(), + domainOk: z.boolean().nullish(), /** * See {@link Interface.domainTimestamp}. Declared `any`; resolved at this * boundary by `auditTimestamp` rather than by a type that cannot name a @@ -840,40 +897,42 @@ export namespace Account { /** * See {@link Interface.alias}. */ - alias: nonEmptyString().optional(), + alias: nonEmptyString().nullish(), /** * See {@link Interface.sampleMessage1}. */ - sampleMessage1: z.string().optional(), + sampleMessage1: z.string().nullish(), /** * See {@link Interface.sampleMessage2}. */ - sampleMessage2: z.string().optional(), + sampleMessage2: z.string().nullish(), /** * See {@link Interface.sampleMessage3}. */ - sampleMessage3: z.string().optional(), + sampleMessage3: z.string().nullish(), /** * See {@link Interface.sampleMessage4}. */ - sampleMessage4: z.string().optional(), + sampleMessage4: z.string().nullish(), /** * See {@link Interface.sampleMessage5}. */ - sampleMessage5: z.string().optional(), + sampleMessage5: z.string().nullish(), /** * See {@link Interface.bca}. */ - bca: nonEmptyString().optional(), + bca: nonEmptyString().nullish(), }); /** * Compile-time proof that {@link Schema} produces {@link Interface}. * - * This also pins {@link Interface.links} against the shared-helpers - * `User.InterfaceLinks` definition: if that type gains or changes a field and - * `linksSchema` is not updated to match, the divergence is a build failure - * here rather than a field silently rejected at runtime. + * This compares inferred *values*, so it catches `linksSchema` declaring a + * member of {@link Links} with the wrong type. It does **not** catch a member + * added upstream that `linksSchema` never declared — an absent optional + * property is a legal shape, so the check passes. {@link LinksKeysCovered} + * covers that case; the two together are what pin this package to the + * shared-helpers `User.InterfaceLinks` definition. */ export type SchemaOutput = AssertSchemaOutput, Interface>; diff --git a/src/model/Block.ts b/src/model/Block.ts index 3b32a02..ead6a44 100644 --- a/src/model/Block.ts +++ b/src/model/Block.ts @@ -73,12 +73,12 @@ export namespace Block { * Optional display width hint in pixels for media blocks such as images * and videos. */ - width?: number, + width?: number | null, /** * Optional display height hint in pixels for media blocks such as images * and videos. */ - height?: number, + height?: number | null, } /** @@ -126,11 +126,11 @@ export namespace Block { /** * See {@link Interface.width}. Whole pixels. */ - width: counter().optional(), + width: counter().nullish(), /** * See {@link Interface.height}. Whole pixels. */ - height: counter().optional(), + height: counter().nullish(), }); /** diff --git a/src/model/Entitlement.ts b/src/model/Entitlement.ts index 2884913..b893a24 100644 --- a/src/model/Entitlement.ts +++ b/src/model/Entitlement.ts @@ -98,7 +98,7 @@ export namespace Entitlement { * re-claimed, and a caller collapsing the two loses the distinction between * "never attempted" and "attempted and did not work". */ - status?: Status; + status?: Status | null; /** * Stable identity of the entitlement being conferred, independent of which * purchase attempt conferred it. @@ -107,7 +107,7 @@ export namespace Entitlement { * value here, which is what lets a duplicate be recognised as a duplicate * rather than as a second purchase. */ - entitlement?: string; + entitlement?: string | null; /** * Token identifying the delivery that currently holds the claim. * @@ -115,7 +115,7 @@ export namespace Entitlement { * delivery that lost its claim cannot write the outcome of work another * delivery has since completed. */ - ownerToken?: string; + ownerToken?: string | null; } /** @@ -158,15 +158,15 @@ export namespace Entitlement { /** * See {@link Interface.status}. Validated against {@link Status}. */ - status: z.enum(Status).optional(), + status: z.enum(Status).nullish(), /** * See {@link Interface.entitlement}. */ - entitlement: nonEmptyString().max(512).optional(), + entitlement: nonEmptyString().max(512).nullish(), /** * See {@link Interface.ownerToken}. */ - ownerToken: token().optional(), + ownerToken: token().nullish(), }); /** diff --git a/src/model/EventData.ts b/src/model/EventData.ts index 1b6991f..b8e6b0a 100644 --- a/src/model/EventData.ts +++ b/src/model/EventData.ts @@ -73,41 +73,41 @@ export namespace EventData { /** * Public display name of the event. */ - name?: string; + name?: string | null; /** * Detailed description of the event shown to potential participants. */ - description?: string; + description?: string | null; /** * BCP 47 language tag for the event's primary language (e.g., `"en"`). */ - language?: string; + language?: string | null; /** * Firestore document ID of the account that owns this event. */ - account?: string; + account?: string | null; /** * Array of media asset URLs (images, videos) associated with the event. */ - media?: string[]; + media?: string[] | null; /** * Event format; see {@link Type} for accepted values. * Accepts a typed enum member or a raw string for forward-compatibility * with values stored in Firestore before this enum existed. */ - type?: Type | string; + type?: Type | string | null; /** * Recurrence cadence; see {@link Frequency} for accepted values. * Accepts a typed enum member or a raw string for forward-compatibility * with values stored in Firestore before this enum existed. */ - frequency?: Frequency | string; + frequency?: Frequency | string | null; /** * Current lifecycle status; see {@link Status} for accepted values. * Accepts a typed enum member or a raw string for forward-compatibility * with values stored in Firestore before this enum existed. */ - status?: Status | string; + status?: Status | string | null; /** * Firebase Auth UID of the user who created this event, or `null` for * system-generated events. @@ -117,30 +117,30 @@ export namespace EventData { * Ordered array of content blocks that compose the event's rich-media * detail page. */ - blocks?: Block.Interface[], + blocks?: Block.Interface[] | null, /** * @deprecated Use the `Price` namespace instead. * ISO 4217 currency code for the event ticket price. */ - currency?: string; + currency?: string | null; /** * @deprecated Use the `Price` namespace instead. * Ticket price amount expressed in the smallest currency unit (e.g., cents). */ - amount?: number; + amount?: number | null; /** * Firebase Auth UIDs of participants who have booked or joined this event. */ - users?: string[]; // user ids + users?: string[] | null; // user ids /** * Firebase Auth UIDs of users who have been designated as event hosts. */ - hosts?: string[]; // user ids + hosts?: string[] | null; // user ids /** * Maximum number of participants allowed to join; enforced by Cloud * Functions during the booking process. */ - limit?: number; + limit?: number | null; /** * Timestamp at which the event starts; stored as a Firestore Timestamp * or ISO 8601 string. @@ -154,30 +154,30 @@ export namespace EventData { /** * Planned duration of the event in minutes. */ - duration?: number; // in minutes + duration?: number | null; // in minutes /** * UTC hour of the day (0–23) at which recurring Cloud Function jobs * process or re-schedule this event. */ - runHour?: number; + runHour?: number | null; /** * Cumulative number of times this event's detail page has been clicked * from a listing view. */ - clicks?: number; + clicks?: number | null; /** * Cumulative number of times this event's detail page has been viewed. */ - views?: number; + views?: number | null; /** * Cumulative number of users who initiated the checkout / booking flow for * this event. */ - checkout?: number; + checkout?: number | null; /** * Cumulative number of confirmed bookings for this event. */ - booked?: number; + booked?: number | null; } /** @@ -209,72 +209,72 @@ export namespace EventData { /** * See {@link Interface.name}. */ - name: z.string().optional(), + name: z.string().nullish(), /** * See {@link Interface.description}. */ - description: z.string().optional(), + description: z.string().nullish(), /** * See {@link Interface.language}. */ - language: nonEmptyString().optional(), + language: nonEmptyString().nullish(), /** * See {@link Interface.account}. */ - account: documentId().optional(), + account: documentId().nullish(), /** * See {@link Interface.media}. */ - media: z.array(z.string()).optional(), + media: z.array(z.string()).nullish(), /** * See {@link Interface.type}, and the note on this schema about why a raw * string is still accepted. */ - type: z.union([z.enum(Type), z.string()]).optional(), + type: z.union([z.enum(Type), z.string()]).nullish(), /** * See {@link Interface.frequency}, and the note on this schema about why a * raw string is still accepted. */ - frequency: z.union([z.enum(Frequency), z.string()]).optional(), + frequency: z.union([z.enum(Frequency), z.string()]).nullish(), /** * See {@link Interface.status}, and the note on this schema about why a raw * string is still accepted. */ - status: z.union([z.enum(Status), z.string()]).optional(), + status: z.union([z.enum(Status), z.string()]).nullish(), /** * See {@link Interface.uid}. `null` denotes a system-generated event and * must survive a JSON round-trip. */ - uid: z.string().nullable().optional(), + uid: z.string().nullish(), /** * See {@link Interface.blocks}. Every element is validated against * {@link Block.Schema}, so one malformed block fails the event rather than * reaching a renderer that has no branch for it. */ - blocks: z.array(Block.Schema).optional(), + blocks: z.array(Block.Schema).nullish(), /** * See {@link Interface.currency}. * @deprecated Use the `Price` namespace instead. */ - currency: z.string().regex(/^[A-Za-z]{3}$/, {error: 'Expected a three-letter ISO 4217 currency code'}).optional(), + currency: z.string().regex(/^[A-Za-z]{3}$/, {error: 'Expected a three-letter ISO 4217 currency code'}).nullish(), /** * See {@link Interface.amount}. Money, so a non-numeric value is rejected * rather than coerced into `NaN`. * @deprecated Use the `Price` namespace instead. */ - amount: finiteNumber().optional(), + amount: finiteNumber().nullish(), /** * See {@link Interface.users}. */ - users: z.array(z.string()).optional(), + users: z.array(z.string()).nullish(), /** * See {@link Interface.hosts}. */ - hosts: z.array(z.string()).optional(), + hosts: z.array(z.string()).nullish(), /** * See {@link Interface.limit}. */ - limit: counter().optional(), + limit: counter().nullish(), /** * See {@link Interface.startTime}. */ @@ -286,28 +286,28 @@ export namespace EventData { /** * See {@link Interface.duration}. Whole minutes. */ - duration: counter().optional(), + duration: counter().nullish(), /** * See {@link Interface.runHour}. A UTC hour of day, so the accepted range is * 0 to 23; a value outside it schedules a recurring job that never fires. */ - runHour: z.int().min(0).max(23).optional(), + runHour: z.int().min(0).max(23).nullish(), /** * See {@link Interface.clicks}. */ - clicks: counter().optional(), + clicks: counter().nullish(), /** * See {@link Interface.views}. */ - views: counter().optional(), + views: counter().nullish(), /** * See {@link Interface.checkout}. */ - checkout: counter().optional(), + checkout: counter().nullish(), /** * See {@link Interface.booked}. */ - booked: counter().optional(), + booked: counter().nullish(), }); /** diff --git a/src/model/Idempotency.ts b/src/model/Idempotency.ts index d55e295..8df37b4 100644 --- a/src/model/Idempotency.ts +++ b/src/model/Idempotency.ts @@ -111,14 +111,14 @@ export namespace Idempotency { * of work another process has since redone. Absent once the record has * settled. */ - ownerToken?: string; + ownerToken?: string | null; /** * Number of times this key has been claimed, including the first. * * Increments each time a lapsed lease is reclaimed, so a value climbing * without the record settling indicates a handler that keeps dying mid-flight. */ - attempts?: number; + attempts?: number | null; /** * Durable, operation-specific checkpoints retained across failed attempts and * lease reclaims, so a retried handler can skip work that already succeeded. @@ -127,12 +127,12 @@ export namespace Idempotency { * no checkpoint has been recorded yet, which is not the same as the operation * having no steps. */ - progress?: Record; + progress?: Record | null; /** * Outcome of the original attempt, present only once * {@link Interface.state} is {@link State.completed}. */ - response?: Response; + response?: Response | null; /** * Instant at which the current holder's lease lapses, after which another * attempt may reclaim the key. @@ -199,19 +199,19 @@ export namespace Idempotency { /** * See {@link Interface.ownerToken}. */ - ownerToken: token().optional(), + ownerToken: token().nullish(), /** * See {@link Interface.attempts}. */ - attempts: counter().optional(), + attempts: counter().nullish(), /** * See {@link Interface.progress}. */ - progress: z.record(z.string(), z.unknown()).optional(), + progress: z.record(z.string(), z.unknown()).nullish(), /** * See {@link Interface.response}. */ - response: responseSchema.optional(), + response: responseSchema.nullish(), /** * See {@link Interface.lockExpires}. */ diff --git a/src/model/Ledger.ts b/src/model/Ledger.ts index 98c47bd..8a4b7e2 100644 --- a/src/model/Ledger.ts +++ b/src/model/Ledger.ts @@ -77,7 +77,7 @@ export namespace Ledger { * "settled at zero". A caller that collapses the two with `?? 0` will treat * an outstanding reservation as a completed no-op and release it. */ - consumed?: number; + consumed?: number | null; /** * Allowance this scope was seeded from, recorded for observability. * @@ -96,7 +96,7 @@ export namespace Ledger { * spend that actually happened. Absent or `false` both mean "not confirmed" — * a record is only confirmed when this is explicitly `true`. */ - spendConfirmed?: boolean; + spendConfirmed?: boolean | null; } /** @@ -129,15 +129,15 @@ export namespace Ledger { /** * See {@link Interface.consumed}. */ - consumed: nonNegativeNumber().optional(), + consumed: nonNegativeNumber().nullish(), /** * See {@link Interface.limit}. */ - limit: nonNegativeNumber().nullable().optional(), + limit: nonNegativeNumber().nullish(), /** * See {@link Interface.spendConfirmed}. */ - spendConfirmed: z.boolean().optional(), + spendConfirmed: z.boolean().nullish(), }); /** diff --git a/src/model/MessageUsage.ts b/src/model/MessageUsage.ts index 8387e52..03a2d96 100644 --- a/src/model/MessageUsage.ts +++ b/src/model/MessageUsage.ts @@ -108,7 +108,7 @@ export namespace MessageUsage { * harmless when the read genuinely succeeded — which is exactly the case a * caller cannot distinguish without validating first. */ - reported?: number; + reported?: number | null; /** * An in-flight report awaiting acknowledgement; see {@link Pending}. * @@ -116,7 +116,7 @@ export namespace MessageUsage { * handed to the provider, and it must be re-sent verbatim rather than * recomputed. */ - pending?: Pending; + pending?: Pending | null; } /** @@ -174,11 +174,11 @@ export namespace MessageUsage { * See {@link Interface.reported}. A watermark, so it never decreases and is * never negative. */ - reported: nonNegativeNumber().optional(), + reported: nonNegativeNumber().nullish(), /** * See {@link Interface.pending}. */ - pending: pendingSchema.optional(), + pending: pendingSchema.nullish(), }); /** diff --git a/src/model/MessagingEvent.ts b/src/model/MessagingEvent.ts index 3f7514f..516d17d 100644 --- a/src/model/MessagingEvent.ts +++ b/src/model/MessagingEvent.ts @@ -90,31 +90,31 @@ export namespace MessagingEvent { /** * Firestore document ID of the account that owns this messaging event. */ - account?: string; + account?: string | null; /** * Identifier of the messaging service (e.g., Twilio Messaging Service SID) * used to deliver this event. */ - service?: string; + service?: string | null; /** * BCP 47 language tag for the message body (e.g., `"en"`, `"es"`), used * for content moderation and ML processing. */ - language?: string; + language?: string | null; /** * Array of media attachment URLs associated with this message (MMS only). */ - media?: string[]; + media?: string[] | null; /** * Plain-text body of the message. */ - body?: string; + body?: string | null; /** * Channel type of this event; see {@link Type} for accepted values. * Accepts a typed enum member or a raw string for forward-compatibility * with values stored in Firestore before this enum existed. */ - type?: Type | string; + type?: Type | string | null; /** * Firebase Auth UID of the user who originated this event, or `null` for * system-generated events. @@ -123,15 +123,15 @@ export namespace MessagingEvent { /** * When `true`, this event was processed by the machine-learning pipeline. */ - ml?: boolean; + ml?: boolean | null; /** * When `true`, the ML pipeline flagged this message content as unsafe. */ - unsafe?: boolean, + unsafe?: boolean | null, /** * Content classification labels applied by the ML safety classifier. */ - labels?: string[], + labels?: string[] | null, /** * Human-readable error message if delivery failed, or `null` if no error * occurred. @@ -141,7 +141,7 @@ export namespace MessagingEvent { * Provider-specific error code returned by the downstream messaging * provider (e.g., a Twilio error code integer or string). */ - errorCodeProvider?: number | string; + errorCodeProvider?: number | string | null; /** * Snapshot of the sender's public profile at the time this event was * created; used for display purposes without a secondary Firestore lookup. @@ -150,33 +150,33 @@ export namespace MessagingEvent { /** * URL to the sender's avatar image. */ - avatar?: string; + avatar?: string | null; /** * Sender's first name. */ - firstName?: string; + firstName?: string | null; /** * Sender's last name. */ - lastName?: string; + lastName?: string | null; /** * Sender's full display name. */ - name?: string; + name?: string | null; /** * Abbreviated form of the sender's name (e.g., initials) for compact UI. */ - abbr?: string; + abbr?: string | null; /** * Sender's unique username handle. */ - username?: string; + username?: string | null; /** * Firebase Auth UID of the sender; required for all user-originated * events. */ id: string; - }; + } | null; } /** @@ -190,27 +190,27 @@ export namespace MessagingEvent { /** * See {@link Interface.user}. */ - avatar: nonEmptyString().optional(), + avatar: nonEmptyString().nullish(), /** * Sender's first name. */ - firstName: z.string().optional(), + firstName: z.string().nullish(), /** * Sender's last name. */ - lastName: z.string().optional(), + lastName: z.string().nullish(), /** * Sender's full display name. */ - name: z.string().optional(), + name: z.string().nullish(), /** * Abbreviated form of the sender's name. */ - abbr: z.string().optional(), + abbr: z.string().nullish(), /** * Sender's unique username handle. */ - username: z.string().optional(), + username: z.string().nullish(), /** * Firebase Auth UID of the sender. */ @@ -241,62 +241,62 @@ export namespace MessagingEvent { /** * See {@link Interface.account}. */ - account: documentId().optional(), + account: documentId().nullish(), /** * See {@link Interface.service}. */ - service: nonEmptyString().optional(), + service: nonEmptyString().nullish(), /** * See {@link Interface.language}. */ - language: nonEmptyString().optional(), + language: nonEmptyString().nullish(), /** * See {@link Interface.media}. */ - media: z.array(z.string()).optional(), + media: z.array(z.string()).nullish(), /** * See {@link Interface.body}. Permitted to be empty: a delivery receipt for * a media-only message legitimately carries no text. */ - body: z.string().optional(), + body: z.string().nullish(), /** * See {@link Interface.type}, and the note on this schema about why a raw * string is still accepted. */ - type: z.union([z.enum(Type), z.string()]).optional(), + type: z.union([z.enum(Type), z.string()]).nullish(), /** * See {@link Interface.uid}. `null` denotes a system-generated event and * must survive a JSON round-trip. */ - uid: z.string().nullable().optional(), + uid: z.string().nullish(), /** * See {@link Interface.ml}. */ - ml: z.boolean().optional(), + ml: z.boolean().nullish(), /** * See {@link Interface.unsafe}. */ - unsafe: z.boolean().optional(), + unsafe: z.boolean().nullish(), /** * See {@link Interface.labels}. */ - labels: z.array(z.string()).optional(), + labels: z.array(z.string()).nullish(), /** * See {@link Interface.error}. `null` denotes "no error occurred", which is * a different claim from the field being absent. */ - error: z.string().nullable().optional(), + error: z.string().nullish(), /** * See {@link Interface.errorCodeProvider}. Accepted as either a number or a * string because providers differ, but **never coerced between them**: a * code turned into `NaN` by a reflexive `Number()` would compare equal to no * known code and silently classify a hard failure as unrecognised. */ - errorCodeProvider: z.union([z.number(), z.string()]).optional(), + errorCodeProvider: z.union([z.number(), z.string()]).nullish(), /** * See {@link Interface.user}. */ - user: userSnapshotSchema.optional(), + user: userSnapshotSchema.nullish(), }); /** diff --git a/src/model/Post.ts b/src/model/Post.ts index 1bd343d..ca05d4a 100644 --- a/src/model/Post.ts +++ b/src/model/Post.ts @@ -65,12 +65,12 @@ export namespace Post { /** * Firestore document ID of the account that owns this post. */ - account?: string; + account?: string | null; /** * Identifier of the service configuration used to fetch this post (e.g., * a Furcata service document ID). */ - service?: string; + service?: string | null; /** * Source URL or platform-specific content identifier, interpreted * according to the `type` field. @@ -81,7 +81,7 @@ export namespace Post { * Current lifecycle status of the post; see {@link Status} for accepted * values. */ - status?: Status; + status?: Status | null; /** * Platform origin of the post; see {@link Type} for accepted values. */ @@ -89,105 +89,105 @@ export namespace Post { /** * Firebase Auth UID of the user who added this post to the platform. */ - uid?: string; + uid?: string | null; /** * Content category label assigned to this post for filtering and * discovery purposes. */ - category?: string; + category?: string | null; /** * Human-readable description of the post, either sourced from the * external platform or provided by the curator. */ - description?: string; + description?: string | null; /** * When `true`, this post has been manually marked as featured and will * receive priority placement in listing results. */ - featured?: boolean; + featured?: boolean | null; /** * Curator-assigned tags used for internal categorisation and search. */ - tags?: string[]; + tags?: string[] | null; /** * Hashtags associated with this post, either extracted from the source * platform or generated by the ML pipeline. */ - hashtags?: string[]; + hashtags?: string[] | null; /** * URL of the primary featured image for this post. */ // Featured Image - image?: string; + image?: string | null; /** * URLs of all images extracted from this post (e.g., carousel items). */ // All images - images?: string[]; + images?: string[] | null; /** * BCP 47 language tag detected or assigned for this post's content * (e.g., `"en"`, `"es"`). */ - language?: string; + language?: string | null; /** * URL of the primary media asset (video or audio) for this post. */ - media?: string; + media?: string | null; /** * When `true`, the content has been reviewed and is considered safe for * all audiences. */ - safe?: boolean; + safe?: boolean | null; /** * Title of the post, either sourced from the external platform or * provided by the curator. */ - title?: string; + title?: string | null; /** * Canonical or resolved final URL for the post, used for link previews and * sharing. */ // Final URL - url?: string; + url?: string | null; /** * Firebase Auth UID of the originating content creator on the external * platform, if resolvable. */ - user?: string; + user?: string | null; /** * When `true`, this post has been processed by the ML enrichment pipeline. */ // ML - ml?: boolean; + ml?: boolean | null; /** * ML-generated title suggestion for the post. */ - mlTitle?: string; + mlTitle?: string | null; /** * ML-generated description suggestion for the post. */ - mlDescription?: string; + mlDescription?: string | null; /** * ML-generated hashtag suggestions for the post. */ - mlHashtags?: string[]; + mlHashtags?: string[] | null; /** * ML-selected or generated featured image URL for the post. */ - mlImage?: string; + mlImage?: string | null; /** * When `true`, the external platform metadata for this post has been * successfully fetched and stored. */ - fetched?: boolean; + fetched?: boolean | null; /** * Cumulative view count for this post within the Furcata platform. */ - views?: number; + views?: number | null; /** * Cumulative like count for this post within the Furcata platform. */ - likes?: number; + likes?: number | null; } /** @@ -208,11 +208,11 @@ export namespace Post { /** * See {@link Interface.account}. */ - account: documentId().optional(), + account: documentId().nullish(), /** * See {@link Interface.service}. */ - service: nonEmptyString().optional(), + service: nonEmptyString().nullish(), /** * See {@link Interface.source}. Required: a post with no source URL or * platform identifier cannot be fetched or de-duplicated. @@ -221,7 +221,7 @@ export namespace Post { /** * See {@link Interface.status}. */ - status: z.enum(Status).optional(), + status: z.enum(Status).nullish(), /** * See {@link Interface.type}. Required and enum-constrained. */ @@ -229,91 +229,91 @@ export namespace Post { /** * See {@link Interface.uid}. */ - uid: nonEmptyString().optional(), + uid: nonEmptyString().nullish(), /** * See {@link Interface.category}. */ - category: z.string().optional(), + category: z.string().nullish(), /** * See {@link Interface.description}. */ - description: z.string().optional(), + description: z.string().nullish(), /** * See {@link Interface.featured}. */ - featured: z.boolean().optional(), + featured: z.boolean().nullish(), /** * See {@link Interface.tags}. */ - tags: z.array(z.string()).optional(), + tags: z.array(z.string()).nullish(), /** * See {@link Interface.hashtags}. */ - hashtags: z.array(z.string()).optional(), + hashtags: z.array(z.string()).nullish(), /** * See {@link Interface.image}. */ - image: nonEmptyString().optional(), + image: nonEmptyString().nullish(), /** * See {@link Interface.images}. */ - images: z.array(z.string()).optional(), + images: z.array(z.string()).nullish(), /** * See {@link Interface.language}. */ - language: nonEmptyString().optional(), + language: nonEmptyString().nullish(), /** * See {@link Interface.media}. */ - media: nonEmptyString().optional(), + media: nonEmptyString().nullish(), /** * See {@link Interface.safe}. */ - safe: z.boolean().optional(), + safe: z.boolean().nullish(), /** * See {@link Interface.title}. */ - title: z.string().optional(), + title: z.string().nullish(), /** * See {@link Interface.url}. */ - url: nonEmptyString().optional(), + url: nonEmptyString().nullish(), /** * See {@link Interface.user}. */ - user: nonEmptyString().optional(), + user: nonEmptyString().nullish(), /** * See {@link Interface.ml}. */ - ml: z.boolean().optional(), + ml: z.boolean().nullish(), /** * See {@link Interface.mlTitle}. */ - mlTitle: z.string().optional(), + mlTitle: z.string().nullish(), /** * See {@link Interface.mlDescription}. */ - mlDescription: z.string().optional(), + mlDescription: z.string().nullish(), /** * See {@link Interface.mlHashtags}. */ - mlHashtags: z.array(z.string()).optional(), + mlHashtags: z.array(z.string()).nullish(), /** * See {@link Interface.mlImage}. */ - mlImage: nonEmptyString().optional(), + mlImage: nonEmptyString().nullish(), /** * See {@link Interface.fetched}. */ - fetched: z.boolean().optional(), + fetched: z.boolean().nullish(), /** * See {@link Interface.views}. */ - views: counter().optional(), + views: counter().nullish(), /** * See {@link Interface.likes}. */ - likes: counter().optional(), + likes: counter().nullish(), }); /** diff --git a/src/model/Price.ts b/src/model/Price.ts index 907e787..2925788 100644 --- a/src/model/Price.ts +++ b/src/model/Price.ts @@ -63,41 +63,46 @@ export namespace Price { * Price amount expressed in the smallest currency unit (e.g., cents for * USD) to avoid floating-point rounding errors. */ - amount?: number; + amount?: number | null; /** * ISO 4217 currency code for this price (e.g., `"usd"`, `"eur"`). */ - currency?: string; + currency?: string | null; /** * Firestore document ID of the parent product or event that this price is * associated with. */ // The product this price is associated with - source?: string; + source?: string | null; /** * URL of the primary display image for this price (e.g., product photo or * event cover art). */ - image?: string; + image?: string | null; /** * Short display name shown to buyers during the checkout flow. */ - label?: string; + label?: string | null; /** * Longer description of what the buyer is purchasing, displayed on the * checkout and confirmation pages. */ - description?: string; + description?: string | null; /** * Maximum number of users that may purchase this price; enforced by Cloud - * Functions during checkout. `undefined` means unlimited. + * Functions during checkout. + * + * Unlimited is expressed **both** ways in stored data: `null` in documents + * written by the usual path, and absent in older ones. Read it as + * `limit ?? Infinity` rather than testing for `undefined`, which answers + * `false` for the far more common of the two. */ // Limit the number of users that can pay for this price - limit?: number; + limit?: number | null; /** * Item category for this price; see {@link Type} for accepted values. */ - type?: Type; + type?: Type | null; /** * Firebase Auth UID of a specific user this price is restricted to, or * `null` for publicly purchasable prices. @@ -106,30 +111,30 @@ export namespace Price { /** * Firebase Auth UIDs of users who have successfully purchased this price. */ - users?: string[]; // user ids that have paid for this price + users?: string[] | null; // user ids that have paid for this price /** * Visibility scope of this price record; see {@link Visibility} for * accepted values. */ - visibility?: Visibility; + visibility?: Visibility | null; /** * Cumulative number of times a link to this price has been clicked. */ // Track - clicks?: number; + clicks?: number | null; /** * Cumulative number of times this price's detail page has been viewed. */ - views?: number; + views?: number | null; /** * Cumulative number of users who initiated the checkout flow for this * price. */ - checkout?: number; + checkout?: number | null; /** * Cumulative number of confirmed purchases for this price. */ - booked?: number; + booked?: number | null; } /** @@ -145,6 +150,15 @@ export namespace Price { * signature inherited from {@link BaseFirestore}: a stripping schema would * delete unrecognised fields on a read-modify-write, and a strict one would * reject documents written before this schema existed. + * + * Every optional field is `.nullish()` rather than `.optional()`, because a + * stored price writes its unset fields as an explicit `null` rather than + * omitting them — `limit`, `description` and `image` in particular. A schema + * that accepted only `undefined` rejected the documents it exists to + * validate. The loosening is bounded to `null` alone: a wrong type, a + * fractional counter and an unrecognised enum member are all still rejected, + * as are `null` on the required {@link Interface.account} and on the audit + * timestamps. */ export const Schema = z.looseObject({ ...baseFirestoreShape, @@ -158,71 +172,71 @@ export namespace Price { * a finite number: this field is money, and a silent `NaN` is the defect * this schema exists to stop. */ - amount: finiteNumber().optional(), + amount: finiteNumber().nullish(), /** * See {@link Interface.currency}. Constrained to a three-letter ISO 4217 * code, which is a genuinely closed grammar rather than a convention. */ - currency: z.string().regex(/^[A-Za-z]{3}$/, {error: 'Expected a three-letter ISO 4217 currency code'}).optional(), + currency: z.string().regex(/^[A-Za-z]{3}$/, {error: 'Expected a three-letter ISO 4217 currency code'}).nullish(), /** * See {@link Interface.source}. */ - source: documentId().optional(), + source: documentId().nullish(), /** * See {@link Interface.image}. */ - image: nonEmptyString().optional(), + image: nonEmptyString().nullish(), /** * See {@link Interface.label}. */ - label: z.string().optional(), + label: z.string().nullish(), /** * See {@link Interface.description}. */ - description: z.string().optional(), + description: z.string().nullish(), /** - * See {@link Interface.limit}. Absent means unlimited; a present value is a - * whole number of buyers, so a fractional limit is rejected. + * See {@link Interface.limit}. `null` or absent means unlimited; a present + * value is a whole number of buyers, so a fractional limit is rejected. */ - limit: counter().optional(), + limit: counter().nullish(), /** * See {@link Interface.type}. Validated against {@link Type} rather than * asserted into it, so an unrecognised item category fails here instead of * routing post-payment logic down the wrong branch. */ - type: z.enum(Type).optional(), + type: z.enum(Type).nullish(), /** * See {@link Interface.uid}. Explicitly nullable: `null` means publicly * purchasable and must survive a JSON round-trip, which `undefined` would * not. */ - uid: z.string().nullable().optional(), + uid: z.string().nullish(), /** * See {@link Interface.users}. */ - users: z.array(z.string()).optional(), + users: z.array(z.string()).nullish(), /** * See {@link Interface.visibility}. Validated against {@link Visibility}, * so an unrecognised value cannot widen access by failing an equality check * against `private`. */ - visibility: z.enum(Visibility).optional(), + visibility: z.enum(Visibility).nullish(), /** * See {@link Interface.clicks}. */ - clicks: counter().optional(), + clicks: counter().nullish(), /** * See {@link Interface.views}. */ - views: counter().optional(), + views: counter().nullish(), /** * See {@link Interface.checkout}. */ - checkout: counter().optional(), + checkout: counter().nullish(), /** * See {@link Interface.booked}. */ - booked: counter().optional(), + booked: counter().nullish(), }); /** diff --git a/test/interface/schema.test.ts b/test/interface/schema.test.ts index a1948c9..bbf6efe 100644 --- a/test/interface/schema.test.ts +++ b/test/interface/schema.test.ts @@ -397,25 +397,32 @@ describe('parse plumbing', () => { * those two artifacts against each other. * * These tests are the enforcement. Each entry states, explicitly, which keys - * accept `null` and which are required, and the assertions compare that - * statement against what the schema actually does. Adding `.nullable()` to a - * field without also declaring `| null` on its interface turns one of them red, - * and so does the reverse. + * still **reject** `null` and which are required, and the assertions compare + * that statement against what the schema actually does. * * The policy itself is: * - * - A field annotated `| null` **accepts `null`** via `.nullable()`. - * - A field annotated only `?` **rejects `null`**, because `?` and `| null` are - * different claims and a parse must not return a value the declared type says - * cannot occur. - * - A field annotated `any` is decided case by case and named below, because - * `any` permits `null` without meaning to. The audit and event timestamps - * **reject** it — an explicitly null timestamp is not a time, and reading one - * as epoch zero sorts it first and expires it immediately — while genuinely - * open diagnostic fields accept it. - * - `.nullish()` is used nowhere. Optionality and nullability are declared - * separately so each one is a deliberate statement rather than a side effect - * of the other. + * - A stored-document field declared optional **accepts `null`** via + * `.nullish()`, and its interface is annotated `| null` to match. Firestore + * stores an absent optional field as an explicit `null` under common write + * patterns, so a schema that accepted only `undefined` rejected the documents + * it existed to validate. + * - An **instant-valued** field — one validated by `auditTimestamp()` or + * `timestampLike()` — still **rejects `null`**, and stays `.optional()`. An + * explicitly null timestamp is not a time, and reading one as epoch zero sorts + * it first and expires it immediately. These are named in `nullRejecting` + * below, one per case, so the exemption is an inventory rather than an + * accident. + * - A **required** field still rejects `null`, because a required field carrying + * `null` is exactly the load-bearing absence the requirement exists to stop. + * - A field annotated `any` is decided case by case: genuinely open diagnostic + * fields accept `null`, instant-valued ones do not. + * + * `nullRejecting` is deliberately the **complement** of the loosening rather + * than a restatement of it. An accept-list would grow by one entry every time a + * field was loosened and would therefore never catch a blanket + * `.optional()` → `.nullish()` sweep over the whole package; a reject-list + * shrinks to empty, so any such sweep turns these red. */ describe('null and optionality policy', () => { /** @@ -431,8 +438,11 @@ describe('null and optionality policy', () => { parse: (value: unknown) => {success: boolean}; /** A document that parses, used as the baseline for every probe. */ base: Record; - /** Keys that must accept an explicit `null`. */ - nullAccepting: string[]; + /** + * Keys that must still **reject** an explicit `null`: every required field, + * plus every instant-valued field. Every other declared key must accept it. + */ + nullRejecting: string[]; /** Keys whose absence must be rejected. */ required: string[]; } @@ -443,11 +453,9 @@ describe('null and optionality policy', () => { keys: Object.keys(placeDataShape), parse: (value) => safeParsePlaceData(value), base: {id: 'place_synthetic', latitude: 1, longitude: 1}, - // Exactly the thirteen fields place.ts declares as `X | null`. - nullAccepting: [ - 'area', 'areaLong', 'city', 'cityLong', 'country', 'countryLong', - 'longName', 'name', 'postalCode', 'state', 'stateLong', 'url', 'vicinity', - ], + // Nothing: every key is an optional stored field, and none is instant-valued. + // `created` here is a declared ISO 8601 *string*, not a Firestore instant. + nullRejecting: [], required: [], }, { @@ -455,8 +463,8 @@ describe('null and optionality policy', () => { keys: Object.keys(messageQueueShape), parse: (value) => safeParseMessageQueue(value), base: {pending: 1}, - // `counted` alone, because it is declared `any` and genuinely open. - nullAccepting: ['counted'], + // Nothing: four optional stored counters and one genuinely open `any`. + nullRejecting: [], required: [], }, { @@ -464,7 +472,7 @@ describe('null and optionality policy', () => { keys: Object.keys(Price.Schema.shape), parse: (value) => Price.safeParse(value), base: {account: 'account_synthetic'}, - nullAccepting: ['uid'], + nullRejecting: ['account', 'created', 'expiry', 'updated'], required: ['account'], }, { @@ -472,7 +480,7 @@ describe('null and optionality policy', () => { keys: Object.keys(EventData.Schema.shape), parse: (value) => EventData.safeParse(value), base: {}, - nullAccepting: ['uid'], + nullRejecting: ['created', 'endTime', 'expiry', 'startTime', 'updated'], required: [], }, { @@ -480,7 +488,7 @@ describe('null and optionality policy', () => { keys: Object.keys(MessagingEvent.Schema.shape), parse: (value) => MessagingEvent.safeParse(value), base: {}, - nullAccepting: ['uid', 'error'], + nullRejecting: ['created', 'expiry', 'updated'], required: [], }, { @@ -488,7 +496,7 @@ describe('null and optionality policy', () => { keys: Object.keys(Post.Schema.shape), parse: (value) => Post.safeParse(value), base: {source: 'source_synthetic', type: Post.Type.link}, - nullAccepting: [], + nullRejecting: ['created', 'expiry', 'source', 'type', 'updated'], required: ['source', 'type'], }, { @@ -496,7 +504,7 @@ describe('null and optionality policy', () => { keys: Object.keys(Block.Schema.shape), parse: (value) => Block.safeParse(value), base: {type: Block.Type.text, value: 'v', label: 'l'}, - nullAccepting: [], + nullRejecting: ['label', 'type', 'value'], required: ['type', 'value', 'label'], }, { @@ -504,7 +512,7 @@ describe('null and optionality policy', () => { keys: Object.keys(Account.Schema.shape), parse: (value) => Account.safeParse(value), base: {}, - nullAccepting: ['counted'], + nullRejecting: ['created', 'domainTimestamp', 'expiry', 'updated'], required: [], }, { @@ -512,7 +520,7 @@ describe('null and optionality policy', () => { keys: Object.keys(Idempotency.Schema.shape), parse: (value) => Idempotency.safeParse(value), base: {state: Idempotency.State.failed, requestHash: 'h'}, - nullAccepting: [], + nullRejecting: ['created', 'expiry', 'lockExpires', 'requestHash', 'state', 'updated'], required: ['state', 'requestHash'], }, { @@ -520,7 +528,7 @@ describe('null and optionality policy', () => { keys: Object.keys(Ledger.Schema.shape), parse: (value) => Ledger.safeParse(value), base: {service: 's', scope: 'sc', amount: 1}, - nullAccepting: ['limit'], + nullRejecting: ['amount', 'created', 'expiry', 'scope', 'service', 'updated'], required: ['service', 'scope', 'amount'], }, { @@ -528,7 +536,7 @@ describe('null and optionality policy', () => { keys: Object.keys(Reservation.Schema.shape), parse: (value) => Reservation.safeParse(value), base: {token: 't', identity: 'i', expiresAt: 1767225600000}, - nullAccepting: [], + nullRejecting: ['expiresAt', 'identity', 'token'], required: ['token', 'identity', 'expiresAt'], }, { @@ -536,7 +544,7 @@ describe('null and optionality policy', () => { keys: Object.keys(Entitlement.Schema.shape), parse: (value) => Entitlement.safeParse(value), base: {account: 'a', uid: 'u', price: 'p', source: 's', type: Price.Type.event}, - nullAccepting: [], + nullRejecting: ['account', 'created', 'expiry', 'price', 'source', 'type', 'uid', 'updated'], required: ['account', 'uid', 'price', 'source', 'type'], }, { @@ -544,7 +552,10 @@ describe('null and optionality policy', () => { keys: Object.keys(Capacity.ObjectSchema.shape), parse: (value) => Capacity.safeParse(value), base: {uid: 'u', price: 'p', source: 's', type: Price.Type.event, token: 't', generation: 0, expiresAt: 1767225600}, - nullAccepting: [], + nullRejecting: [ + 'created', 'expires', 'expiresAt', 'expiry', 'generation', 'price', + 'source', 'token', 'type', 'uid', 'updated', + ], required: ['uid', 'price', 'source', 'type', 'token', 'generation', 'expiresAt'], }, { @@ -552,7 +563,7 @@ describe('null and optionality policy', () => { keys: Object.keys(MessageUsage.Schema.shape), parse: (value) => MessageUsage.safeParse(value), base: {period: '2026-01-01', token: 't'}, - nullAccepting: [], + nullRejecting: ['created', 'expiry', 'period', 'token', 'updated'], required: ['period', 'token'], }, ]; @@ -568,12 +579,38 @@ describe('null and optionality policy', () => { describe('null acceptance', () => { it.each(cases.map((entry) => [entry.label, entry] as const))( - '%s should accept null on exactly the declared nullable fields', + '%s should reject null on exactly the required and instant-valued fields', + (_label, entry) => { + const rejecting = entry.keys.filter((key) => !entry.parse({...entry.base, [key]: null}).success); + expect(rejecting.sort()).toEqual([...entry.nullRejecting].sort()); + }, + ); + + it.each(cases.map((entry) => [entry.label, entry] as const))( + '%s should accept null on every other declared key, which is what a stored document carries', (_label, entry) => { - const accepting = entry.keys.filter((key) => entry.parse({...entry.base, [key]: null}).success); - expect(accepting.sort()).toEqual([...entry.nullAccepting].sort()); + const shouldAccept = entry.keys.filter((key) => !entry.nullRejecting.includes(key)); + const refused = shouldAccept.filter((key) => !entry.parse({...entry.base, [key]: null}).success); + expect(refused).toEqual([]); }, ); + + /** + * Guards the assertion above against passing vacuously. + * + * `Reservation` legitimately has no optional fields at all, so its + * accept-set is empty and a per-case non-empty guard would be wrong. The + * meaningful check is that the sweep as a whole probes a substantial number + * of keys: if a future refactor emptied `keys` for every case, the + * per-case assertion would still pass on an empty list while proving + * nothing, and this turns red instead. + */ + it('should probe a substantial number of null-accepting keys, or the sweep above is vacuous', () => { + const accepting = cases.flatMap((entry) => entry.keys.filter((key) => !entry.nullRejecting.includes(key))); + expect(accepting.length).toBeGreaterThan(150); + expect(cases.filter((entry) => entry.keys.every((key) => entry.nullRejecting.includes(key))).map((entry) => entry.label)) + .toEqual(['Reservation.Interface']); + }); }); describe('required fields', () => { diff --git a/test/model/Account.test.ts b/test/model/Account.test.ts index 02ddfb3..4b81db7 100644 --- a/test/model/Account.test.ts +++ b/test/model/Account.test.ts @@ -1045,3 +1045,130 @@ describe('Account.Schema', () => { }); }); }); + +/** + * Regression cover for the defect that stored `null` used to trigger. + * + * This is the worst-affected shape in the package: an account document carries + * a large number of progressively-filled registration fields, and the ones that + * have not been filled in are written as explicit `null` rather than omitted. A + * schema built only from `.optional()` therefore rejected essentially every + * stored account, not an unusual one. + * + * The fixture reproduces the field layout of a real stored account — the same + * keys, with `null` in the same places — carrying entirely synthetic values. The + * layout is the load-bearing part: a fixture using `undefined` where a stored + * document has `null` parses identically under `.optional()` and `.nullish()` + * and so asserts nothing. + */ +describe('Account.Schema against the stored document layout', () => { + /** + * A stored account with the unfilled registration fields as explicit `null`. + * + * `geohash`, `latitude`, `longitude` and `placeId` are place fields that + * stored accounts carry but {@link Account.Interface} does not declare. They + * are in the fixture because they are in the documents, and they exercise the + * loose-object policy rather than the null policy. + * + * @return {Record} An account document in stored form. + */ + const storedAccount = (): Record => ({ + id: 'account_synthetic', + name: 'Synthetic Org', + language: 'en', + status: Account.Status.active, + type: Account.Type.business, + uid: 'uid_synthetic', + stockTicker: null, + stockExchange: null, + useName: null, + street1: null, + street2: null, + city: null, + area: null, + country: null, + postalCode: null, + utcOffset: null, + businessName: null, + businessType: null, + businessIndustry: null, + businessRegistrationNumber: null, + businessRegistrationIdentifier: null, + companyType: null, + appToPersonUseCase: null, + tollFreeUseCase: null, + useCaseDescription: null, + useCaseDescriptionCTA: null, + description: null, + sampleMessage1: null, + sampleMessage2: null, + sampleMessage3: null, + sampleMessage4: null, + sampleMessage5: null, + links: { website: 'https://example.invalid', facebook: null, instagram: null }, + geohash: null, + latitude: null, + longitude: null, + placeId: null, + pending: 0, + ready: 0, + created: '2026-01-01T00:00:00.000Z', + updated: '2026-01-01T00:00:00.000Z', + }); + + it('should parse a stored account whose unfilled fields are explicit null', () => { + expect(Account.safeParse(storedAccount()).success).toBe(true); + }); + + it('should parse a stored account with every non-required declared field null', () => { + const everyOptionalNull: Record = {}; + for (const key of Object.keys(Account.Schema.shape)) { + if (['created', 'updated', 'expiry', 'domainTimestamp'].includes(key)) continue; + everyOptionalNull[key] = null; + } + expect(Account.safeParse(everyOptionalNull).success).toBe(true); + }); + + it('should preserve null on a nested links member rather than folding it into undefined', () => { + const parsed = Account.parse(storedAccount()); + expect(parsed.links?.facebook).toBeNull(); + expect(parsed.links?.website).toBe('https://example.invalid'); + }); + + it('should leave a null enum reading as unset under a nullish-coalescing read', () => { + const parsed = Account.parse(storedAccount()); + expect(parsed.companyType ?? Account.CompanyType.private).toBe(Account.CompanyType.private); + expect(parsed.businessType).toBeNull(); + }); + + it('should preserve the undeclared place fields a stored account carries', () => { + const parsed = Account.parse(storedAccount()); + expect('geohash' in parsed).toBe(true); + expect(parsed['geohash']).toBeNull(); + }); + + describe('the loosening is bounded to null and nothing else', () => { + it('should still reject an unrecognised enum member in a field that now accepts null', () => { + for (const field of ['status', 'type', 'companyType', 'businessType', 'businessIndustry', 'appToPersonUseCase']) { + expect(Account.safeParse({ ...storedAccount(), [field]: 'not_a_member' }).success).toBe(false); + } + }); + + it('should still reject a wrongly typed value in a field that now accepts null', () => { + expect(Account.safeParse({ ...storedAccount(), utcOffset: '-300' }).success).toBe(false); + expect(Account.safeParse({ ...storedAccount(), estimatedVolume: 1.5 }).success).toBe(false); + expect(Account.safeParse({ ...storedAccount(), automaticHeader: 'yes' }).success).toBe(false); + expect(Account.safeParse({ ...storedAccount(), links: 'https://example.invalid' }).success).toBe(false); + }); + + it('should still reject an out-of-range utcOffset in a field that now accepts null', () => { + expect(Account.safeParse({ ...storedAccount(), utcOffset: 5000 }).success).toBe(false); + }); + + it('should still reject a null audit timestamp, which is not a time', () => { + for (const field of ['created', 'updated', 'expiry', 'domainTimestamp']) { + expect(Account.safeParse({ ...storedAccount(), [field]: null }).success).toBe(false); + } + }); + }); +}); diff --git a/test/model/EventData.test.ts b/test/model/EventData.test.ts index 926eeb9..96ec324 100644 --- a/test/model/EventData.test.ts +++ b/test/model/EventData.test.ts @@ -464,3 +464,96 @@ describe('EventData.Schema', () => { }); }); }); + +/** + * Regression cover for the defect that stored `null` used to trigger. + * + * An event document is written progressively, so its unset fields — including + * the array-valued ones — are stored as explicit `null` rather than omitted. + * `blocks: null` is the interesting case: an array field nulled out is not + * distinguishable from an absent one by any read that uses `?? []`, but it was + * enough to reject the whole document at the parse boundary. + * + * The fixture reproduces the field layout of a real stored event, with entirely + * synthetic values. A fixture using `undefined` where a stored document has + * `null` would parse identically under `.optional()` and `.nullish()`, and so + * would assert nothing. + */ +describe('EventData.Schema against the stored document layout', () => { + /** + * A stored event whose unset fields are explicit `null`. + * + * @return {Record} An event document in stored form. + */ + const storedEvent = (): Record => ({ + name: 'Synthetic event', + account: 'account_synthetic', + type: EventData.Type.online, + status: EventData.Status.scheduled, + description: null, + language: null, + media: null, + blocks: null, + images: null, + currency: null, + amount: null, + users: null, + maxUsers: null, + successMessage: null, + redirectUrl: null, + successUrl: null, + uid: null, + limit: null, + startTime: '2026-01-01T00:00:00.000Z', + endTime: '2026-01-01T02:00:00.000Z', + created: '2026-01-01T00:00:00.000Z', + updated: '2026-01-01T00:00:00.000Z', + }); + + it('should parse a stored event whose unset fields are explicit null', () => { + expect(EventData.safeParse(storedEvent()).success).toBe(true); + }); + + it('should parse a stored event with every non-required declared field null', () => { + const everyOptionalNull: Record = {}; + for (const key of Object.keys(EventData.Schema.shape)) { + if (['created', 'updated', 'expiry', 'startTime', 'endTime'].includes(key)) continue; + everyOptionalNull[key] = null; + } + expect(EventData.safeParse(everyOptionalNull).success).toBe(true); + }); + + it('should accept a null array field and leave it reading as empty under a nullish-coalescing read', () => { + const parsed = EventData.parse(storedEvent()); + expect(parsed.blocks).toBeNull(); + expect(parsed.blocks ?? []).toEqual([]); + expect(parsed.users ?? []).toEqual([]); + }); + + describe('the loosening is bounded to null and nothing else', () => { + it('should still reject a non-array value in an array field that now accepts null', () => { + for (const field of ['blocks', 'users', 'hosts', 'media']) { + expect(EventData.safeParse({ ...storedEvent(), [field]: 'not_an_array' }).success).toBe(false); + } + }); + + it('should still reject a malformed element inside an array field that now accepts null', () => { + expect(EventData.safeParse({ ...storedEvent(), blocks: [{ type: Block.Type.text }] }).success).toBe(false); + expect(EventData.safeParse({ ...storedEvent(), users: [42] }).success).toBe(false); + }); + + it('should still reject a malformed currency in a field that now accepts null', () => { + expect(EventData.safeParse({ ...storedEvent(), currency: 'dollars' }).success).toBe(false); + }); + + it('should still reject an out-of-range runHour in a field that now accepts null', () => { + expect(EventData.safeParse({ ...storedEvent(), runHour: 24 }).success).toBe(false); + }); + + it('should still reject a null instant, which is not a time', () => { + for (const field of ['created', 'updated', 'expiry', 'startTime', 'endTime']) { + expect(EventData.safeParse({ ...storedEvent(), [field]: null }).success).toBe(false); + } + }); + }); +}); diff --git a/test/model/Price.test.ts b/test/model/Price.test.ts index 191d825..2265cbd 100644 --- a/test/model/Price.test.ts +++ b/test/model/Price.test.ts @@ -425,3 +425,113 @@ describe('Price.Schema', () => { }); }); }); + +/** + * Regression cover for the defect that stored `null` used to trigger. + * + * Firestore stores an absent optional field as an explicit `null` under common + * write patterns, so a schema built only from `.optional()` rejected the very + * documents it existed to validate. The fixture below reproduces the field + * layout of a real stored price — the same keys, with `null` in the same places + * — carrying entirely synthetic values. + * + * The layout is the load-bearing part. A fixture that used `undefined` where a + * stored document has `null` parses identically under `.optional()` and + * `.nullish()`, so it would assert nothing at all: that is precisely how this + * defect survived. + */ +describe('Price.Schema against the stored document layout', () => { + /** + * A stored price with the optional fields written as explicit `null`. + * + * `active` and `update` are undeclared keys that stored documents carry; they + * are here so the fixture exercises the loose-object policy at the same time. + * + * @return {Record} A price document in stored form. + */ + const storedPrice = (): Record => ({ + id: 'price_synthetic', + account: 'account_synthetic', + amount: 2500, + currency: 'usd', + label: 'Synthetic ticket', + type: Price.Type.event, + visibility: Price.Visibility.public, + users: [], + uid: null, + limit: null, + description: null, + image: null, + source: null, + booked: 0, + checkout: 0, + clicks: 0, + views: 0, + active: true, + update: false, + backup: false, + created: '2026-01-01T00:00:00.000Z', + updated: '2026-01-01T00:00:00.000Z', + }); + + it('should parse a stored price whose absent optionals are explicit null', () => { + expect(Price.safeParse(storedPrice()).success).toBe(true); + }); + + it('should parse a stored price with every non-required declared field null', () => { + const everyOptionalNull: Record = { account: 'account_synthetic' }; + for (const key of Object.keys(Price.Schema.shape)) { + if (['account', 'created', 'updated', 'expiry'].includes(key)) continue; + everyOptionalNull[key] = null; + } + expect(Price.safeParse(everyOptionalNull).success).toBe(true); + }); + + it('should preserve null rather than folding it into undefined', () => { + const parsed = Price.parse(storedPrice()); + expect(parsed.limit).toBeNull(); + expect('limit' in parsed).toBe(true); + }); + + it('should leave a null limit meaning unlimited under a nullish-coalescing read', () => { + const parsed = Price.parse(storedPrice()); + expect(parsed.limit ?? Number.POSITIVE_INFINITY).toBe(Number.POSITIVE_INFINITY); + expect((parsed.booked ?? 0) < (parsed.limit ?? Number.POSITIVE_INFINITY)).toBe(true); + }); + + it('should survive a round-trip with the nulls and the undeclared keys intact', () => { + const parsed = Price.parse(Price.parse(storedPrice())); + expect(parsed.limit).toBeNull(); + expect(parsed['active']).toBe(true); + }); + + describe('the loosening is bounded to null and nothing else', () => { + it('should still reject a wrongly typed value in a field that now accepts null', () => { + for (const [field, wrong] of [['limit', '100'], ['amount', 'free'], ['currency', 'dollars'], ['users', 'uid_synthetic']] as const) { + expect(Price.safeParse({ ...storedPrice(), [field]: wrong }).success).toBe(false); + } + }); + + it('should still reject a fractional or negative counter in a field that now accepts null', () => { + for (const field of ['limit', 'clicks', 'views', 'checkout', 'booked']) { + expect(Price.safeParse({ ...storedPrice(), [field]: 1.5 }).success).toBe(false); + expect(Price.safeParse({ ...storedPrice(), [field]: -1 }).success).toBe(false); + } + }); + + it('should still reject an unrecognised enum member in a field that now accepts null', () => { + expect(Price.safeParse({ ...storedPrice(), type: 'subscription' }).success).toBe(false); + expect(Price.safeParse({ ...storedPrice(), visibility: 'everyone' }).success).toBe(false); + }); + + it('should still reject a null account, because a required field carrying null is the absence it exists to stop', () => { + expect(Price.safeParse({ ...storedPrice(), account: null }).success).toBe(false); + }); + + it('should still reject a null audit timestamp, which is not a time', () => { + for (const field of ['created', 'updated', 'expiry']) { + expect(Price.safeParse({ ...storedPrice(), [field]: null }).success).toBe(false); + } + }); + }); +});