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); + } + }); + }); +});