UNPKG

@plasius/schema

Version:

Entity schema definition & validation helpers for Plasius ecosystem

1,766 lines (1,749 loc) 53.4 kB
// src/field.builder.ts var FieldBuilder = class _FieldBuilder { constructor(type, options = {}) { this.type = type; this._shape = options.shape; this.itemType = options.itemType; this.refType = options.refType; } _type; _storageType; isSystem = false; isImmutable = false; isRequired = true; _validator; _description = ""; _version = "1.0.0"; _default; _upgrade; _shape; itemType; refType; _pii = { classification: "none", action: "none", logHandling: "plain", purpose: "an ordinary value" }; enumValues; immutable() { this.isImmutable = true; return this; } system() { this.isSystem = true; return this; } required() { this.isRequired = true; return this; } optional() { this.isRequired = false; return this; } validator(fn) { this._validator = fn; return this; } description(desc) { this._description = desc; return this; } default(value) { this._default = value; this.isRequired = false; return this; } /** * Configure an upgrader used when validating older entities against a newer schema. * The upgrader receives the current field value and version context, and should * return { ok: true, value } with the upgraded value, or { ok: false, error }. */ upgrade(fn) { this._upgrade = fn; return this; } getDefault() { const v = this._default; return typeof v === "function" ? v() : v; } version(ver) { this._version = ver; return this; } /// PID informs the schema PII handling of the manner in /// which to handle data relating to this field. PID(pii) { this._pii = pii; return this; } min(min) { if (this.type === "number") { const prevValidator = this._validator; this._validator = (value) => { const valid = typeof value === "number" && value >= min; return prevValidator ? prevValidator(value) && valid : valid; }; } else if (this.type === "string") { const prevValidator = this._validator; this._validator = (value) => { const valid = typeof value === "string" && value.length >= min; return prevValidator ? prevValidator(value) && valid : valid; }; } else if (this.type === "array") { const prevValidator = this._validator; this._validator = (value) => { const valid = Array.isArray(value) && value.length >= min; return prevValidator ? prevValidator(value) && valid : valid; }; } else { throw new Error( "Min is only supported on number, string, or array fields." ); } return this; } max(max) { if (this.type === "number") { const prevValidator = this._validator; this._validator = (value) => { const valid = typeof value === "number" && value <= max; return prevValidator ? prevValidator(value) && valid : valid; }; } else if (this.type === "string") { const prevValidator = this._validator; this._validator = (value) => { const valid = typeof value === "string" && value.length <= max; return prevValidator ? prevValidator(value) && valid : valid; }; } else if (this.type === "array") { const prevValidator = this._validator; this._validator = (value) => { const valid = Array.isArray(value) && value.length <= max; return prevValidator ? prevValidator(value) && valid : valid; }; } else { throw new Error( "Max is only supported on number, string, or array fields." ); } return this; } pattern(regex) { if (this.type !== "string") { throw new Error("Pattern is only supported on string fields."); } const prevValidator = this._validator; this._validator = (value) => { const valid = typeof value === "string" && regex.test(value); return prevValidator ? prevValidator(value) && valid : valid; }; return this; } enum(values) { if (this.type !== "string" && this.type !== "number" && !(this.type === "array" && (this.itemType?.type === "string" || this.itemType?.type === "number"))) { throw new Error( "Enums are only supported on string or number fields or arrays of strings or numbers." ); } this.enumValues = values; return this; } /** * Create a shallow clone with a different external type parameter. * Note: shape and itemType are passed by reference (shallow). If you need * deep isolation of nested FieldBuilders, clone them explicitly. */ as() { const clone = new _FieldBuilder(this.type, { shape: this._shape, itemType: this.itemType, refType: this.refType }); clone.enumValues = this.enumValues; clone.isImmutable = this.isImmutable; clone.isSystem = this.isSystem; clone.isRequired = this.isRequired; clone._description = this._description; clone._version = this._version; clone._pii = this._pii; clone._validator = this._validator; clone._default = this._default; clone._upgrade = this._upgrade; return clone; } }; // src/validation/email.RFC5322.ts var validateEmail = (value) => { if (typeof value !== "string") return false; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(value); }; // src/validation/phone.E.164.ts var validatePhone = (value) => { if (typeof value !== "string") return false; const phoneRegex = /^\+[1-9]\d{1,14}$/; return phoneRegex.test(value); }; // src/validation/url.WHATWG.ts var validateUrl = (value) => { if (typeof value !== "string") return false; try { const url = new URL(value); if (url.protocol !== "http:" && url.protocol !== "https:") return false; return true; } catch { return false; } }; // src/validation/uuid.RFC4122.ts var validateUUID = (value) => { if (typeof value !== "string") return false; const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; return uuidRegex.test(value); }; // src/validation/dateTime.ISO8601.ts var validateDateTimeISO = (value, options) => { const mode = options?.mode ?? "datetime"; if (typeof value !== "string") return false; if (mode === "datetime") { const isoDateTimeRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/; if (!isoDateTimeRegex.test(value)) return false; const date = new Date(value); if (Number.isNaN(date.getTime())) return false; return true; } if (mode === "date") { const dateRegex = /^\d{4}-\d{2}-\d{2}$/; if (!dateRegex.test(value)) return false; const date = new Date(value); if (isNaN(date.getTime())) return false; const [year, month, day] = value.split("-").map(Number); return date.getUTCFullYear() === year && date.getUTCMonth() + 1 === month && date.getUTCDate() === day; } if (mode === "time") { const timeRegex = /^([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d+)?Z?$/; return timeRegex.test(value); } return false; }; // src/validation/countryCode.ISO3166.ts var isoCountryCodes = /* @__PURE__ */ new Set([ "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW" ]); var validateCountryCode = (value) => { if (typeof value !== "string") return false; return isoCountryCodes.has(value.toUpperCase()); }; // src/validation/currencyCode.ISO4217.ts var isoCurrencyCodes = /* @__PURE__ */ new Set([ "AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM", "BBD", "BDT", "BGN", "BHD", "BIF", "BMD", "BND", "BOB", "BOV", "BRL", "BSD", "BTN", "BWP", "BYN", "BZD", "CAD", "CDF", "CHE", "CHF", "CHW", "CLF", "CLP", "CNY", "COP", "COU", "CRC", "CUC", "CUP", "CVE", "CZK", "DJF", "DKK", "DOP", "DZD", "EGP", "ERN", "ETB", "EUR", "FJD", "FKP", "GBP", "GEL", "GHS", "GIP", "GMD", "GNF", "GTQ", "GYD", "HKD", "HNL", "HRK", "HTG", "HUF", "IDR", "ILS", "INR", "IQD", "IRR", "ISK", "JMD", "JOD", "JPY", "KES", "KGS", "KHR", "KMF", "KPW", "KRW", "KWD", "KYD", "KZT", "LAK", "LBP", "LKR", "LRD", "LSL", "LYD", "MAD", "MDL", "MGA", "MKD", "MMK", "MNT", "MOP", "MRU", "MUR", "MVR", "MWK", "MXN", "MXV", "MYR", "MZN", "NAD", "NGN", "NIO", "NOK", "NPR", "NZD", "OMR", "PAB", "PEN", "PGK", "PHP", "PKR", "PLN", "PYG", "QAR", "RON", "RSD", "RUB", "RWF", "SAR", "SBD", "SCR", "SDG", "SEK", "SGD", "SHP", "SLL", "SOS", "SRD", "SSP", "STN", "SVC", "SYP", "SZL", "THB", "TJS", "TMT", "TND", "TOP", "TRY", "TTD", "TWD", "TZS", "UAH", "UGX", "USD", "USN", "UYI", "UYU", "UYW", "UZS", "VES", "VND", "VUV", "WST", "XAF", "XAG", "XAU", "XBA", "XBB", "XBC", "XBD", "XCD", "XDR", "XOF", "XPD", "XPF", "XPT", "XSU", "XTS", "XUA", "XXX", "YER", "ZAR", "ZMW", "ZWL" ]); var validateCurrencyCode = (value) => { if (typeof value !== "string") return false; return isoCurrencyCodes.has(value.toUpperCase()); }; // src/validation/generalText.OWASP.ts function validateSafeText(value) { if (typeof value !== "string") return false; const trimmed = value.trim(); if (trimmed.length === 0) return false; for (let i = 0; i < trimmed.length; i++) { const code = trimmed.codePointAt(i); if (code !== void 0 && (code >= 0 && code <= 31 || code === 127)) { return false; } } if (/['"<>\\{}();]/.test(trimmed)) return false; if (/(--|\b(SELECT|UPDATE|DELETE|INSERT|DROP|ALTER|EXEC|UNION|GRANT|REVOKE)\b|\/\*|\*\/|@@)/i.test( trimmed )) return false; if (trimmed.includes("\0")) return false; if (trimmed.length > 1024) return false; return true; } // src/validation/version.SEMVER2.0.0.ts function validateSemVer(value) { if (typeof value !== "string") return false; return /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/.test( value ); } // src/validation/percentage.ISO80000-1.ts function validatePercentage(value) { if (typeof value !== "number") return false; return value >= 0 && value <= 100; } // src/validation/richtext.OWASP.ts function validateRichText(value) { if (typeof value !== "string") return false; const trimmed = value.trim(); if (trimmed.length === 0) return true; if (/<(script|iframe|object|embed|style|link|meta|base|form|input|button|textarea|select)\b/i.test( trimmed )) { return false; } if (/javascript:/i.test(trimmed)) { return false; } if (/on\w+=["']?/i.test(trimmed)) { return false; } if (trimmed.length > 1e4) return false; return true; } // src/validation/name.OWASP.ts function validateName(value) { if (typeof value !== "string") return false; const trimmed = value.trim(); if (trimmed.length === 0) return false; if (trimmed.length > 256) return false; for (const ch of trimmed) { const cp = ch.codePointAt(0); if (cp >= 0 && cp <= 31 || cp === 127) return false; } const namePattern = /^[\p{L}\p{M}'\- ]+$/u; if (!namePattern.test(trimmed)) return false; return true; } // src/validation/user.MS-GOOGLE-APPLE.ts function validateUserId(value) { if (typeof value !== "string") return false; const trimmed = value.trim(); if (trimmed.length === 0) return false; const googlePattern = /^\d{21,22}$/; const microsoftPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const applePattern = /^[\w\-.]{6,255}$/; return googlePattern.test(trimmed) || microsoftPattern.test(trimmed) || applePattern.test(trimmed); } function validateUserIdArray(value) { if (!Array.isArray(value)) return false; return value.every(validateUserId); } // src/validation/languageCode.BCP47.ts var IsoLanguageCode = /* @__PURE__ */ ((IsoLanguageCode2) => { IsoLanguageCode2["Afar"] = "aa"; IsoLanguageCode2["Abkhazian"] = "ab"; IsoLanguageCode2["Afrikaans"] = "af"; IsoLanguageCode2["Akan"] = "ak"; IsoLanguageCode2["Albanian"] = "sq"; IsoLanguageCode2["Amharic"] = "am"; IsoLanguageCode2["Arabic"] = "ar"; IsoLanguageCode2["Aragonese"] = "an"; IsoLanguageCode2["Armenian"] = "hy"; IsoLanguageCode2["Assamese"] = "as"; IsoLanguageCode2["Avaric"] = "av"; IsoLanguageCode2["Aymara"] = "ay"; IsoLanguageCode2["Azerbaijani"] = "az"; IsoLanguageCode2["Bashkir"] = "ba"; IsoLanguageCode2["Bambara"] = "bm"; IsoLanguageCode2["Basque"] = "eu"; IsoLanguageCode2["Belarusian"] = "be"; IsoLanguageCode2["Bengali"] = "bn"; IsoLanguageCode2["Bislama"] = "bi"; IsoLanguageCode2["Bosnian"] = "bs"; IsoLanguageCode2["Breton"] = "br"; IsoLanguageCode2["Bulgarian"] = "bg"; IsoLanguageCode2["Burmese"] = "my"; IsoLanguageCode2["Catalan"] = "ca"; IsoLanguageCode2["Chamorro"] = "ch"; IsoLanguageCode2["Chechen"] = "ce"; IsoLanguageCode2["Chinese"] = "zh"; IsoLanguageCode2["ChurchSlavic"] = "cu"; IsoLanguageCode2["Chuvash"] = "cv"; IsoLanguageCode2["Cornish"] = "kw"; IsoLanguageCode2["Corsican"] = "co"; IsoLanguageCode2["Cree"] = "cr"; IsoLanguageCode2["Croatian"] = "hr"; IsoLanguageCode2["Czech"] = "cs"; IsoLanguageCode2["Danish"] = "da"; IsoLanguageCode2["Divehi"] = "dv"; IsoLanguageCode2["Dutch"] = "nl"; IsoLanguageCode2["Dzongkha"] = "dz"; IsoLanguageCode2["English"] = "en"; IsoLanguageCode2["Esperanto"] = "eo"; IsoLanguageCode2["Estonian"] = "et"; IsoLanguageCode2["Ewe"] = "ee"; IsoLanguageCode2["Faroese"] = "fo"; IsoLanguageCode2["Fijian"] = "fj"; IsoLanguageCode2["Finnish"] = "fi"; IsoLanguageCode2["French"] = "fr"; IsoLanguageCode2["WesternFrisian"] = "fy"; IsoLanguageCode2["Fulah"] = "ff"; IsoLanguageCode2["Gaelic"] = "gd"; IsoLanguageCode2["Galician"] = "gl"; IsoLanguageCode2["Ganda"] = "lg"; IsoLanguageCode2["Georgian"] = "ka"; IsoLanguageCode2["German"] = "de"; IsoLanguageCode2["Greek"] = "el"; IsoLanguageCode2["Kalaallisut"] = "kl"; IsoLanguageCode2["Guarani"] = "gn"; IsoLanguageCode2["Gujarati"] = "gu"; IsoLanguageCode2["Haitian"] = "ht"; IsoLanguageCode2["Hausa"] = "ha"; IsoLanguageCode2["Hebrew"] = "he"; IsoLanguageCode2["Herero"] = "hz"; IsoLanguageCode2["Hindi"] = "hi"; IsoLanguageCode2["HiriMotu"] = "ho"; IsoLanguageCode2["Hungarian"] = "hu"; IsoLanguageCode2["Icelandic"] = "is"; IsoLanguageCode2["Ido"] = "io"; IsoLanguageCode2["Igbo"] = "ig"; IsoLanguageCode2["Indonesian"] = "id"; IsoLanguageCode2["Interlingua"] = "ia"; IsoLanguageCode2["Interlingue"] = "ie"; IsoLanguageCode2["Inuktitut"] = "iu"; IsoLanguageCode2["Inupiaq"] = "ik"; IsoLanguageCode2["Irish"] = "ga"; IsoLanguageCode2["Italian"] = "it"; IsoLanguageCode2["Japanese"] = "ja"; IsoLanguageCode2["Javanese"] = "jv"; IsoLanguageCode2["Kannada"] = "kn"; IsoLanguageCode2["Kanuri"] = "kr"; IsoLanguageCode2["Kashmiri"] = "ks"; IsoLanguageCode2["Kazakh"] = "kk"; IsoLanguageCode2["CentralKhmer"] = "km"; IsoLanguageCode2["Kikuyu"] = "ki"; IsoLanguageCode2["Kinyarwanda"] = "rw"; IsoLanguageCode2["Kyrgyz"] = "ky"; IsoLanguageCode2["Komi"] = "kv"; IsoLanguageCode2["Kongo"] = "kg"; IsoLanguageCode2["Korean"] = "ko"; IsoLanguageCode2["Kuanyama"] = "kj"; IsoLanguageCode2["Kurdish"] = "ku"; IsoLanguageCode2["Lao"] = "lo"; IsoLanguageCode2["Latin"] = "la"; IsoLanguageCode2["Latvian"] = "lv"; IsoLanguageCode2["Limburgan"] = "li"; IsoLanguageCode2["Lingala"] = "ln"; IsoLanguageCode2["Lithuanian"] = "lt"; IsoLanguageCode2["LubaKatanga"] = "lu"; IsoLanguageCode2["Luxembourgish"] = "lb"; IsoLanguageCode2["Macedonian"] = "mk"; IsoLanguageCode2["Malagasy"] = "mg"; IsoLanguageCode2["Malay"] = "ms"; IsoLanguageCode2["Malayalam"] = "ml"; IsoLanguageCode2["Maltese"] = "mt"; IsoLanguageCode2["Manx"] = "gv"; IsoLanguageCode2["Maori"] = "mi"; IsoLanguageCode2["Marathi"] = "mr"; IsoLanguageCode2["Marshallese"] = "mh"; IsoLanguageCode2["Mongolian"] = "mn"; IsoLanguageCode2["Nauru"] = "na"; IsoLanguageCode2["Navajo"] = "nv"; IsoLanguageCode2["NorthNdebele"] = "nd"; IsoLanguageCode2["SouthNdebele"] = "nr"; IsoLanguageCode2["Ndonga"] = "ng"; IsoLanguageCode2["Nepali"] = "ne"; IsoLanguageCode2["Norwegian"] = "no"; IsoLanguageCode2["NorwegianBokmal"] = "nb"; IsoLanguageCode2["NorwegianNynorsk"] = "nn"; IsoLanguageCode2["SichuanYi"] = "ii"; IsoLanguageCode2["Occitan"] = "oc"; IsoLanguageCode2["Ojibwa"] = "oj"; IsoLanguageCode2["Oriya"] = "or"; IsoLanguageCode2["Oromo"] = "om"; IsoLanguageCode2["Ossetian"] = "os"; IsoLanguageCode2["Pali"] = "pi"; IsoLanguageCode2["Pashto"] = "ps"; IsoLanguageCode2["Persian"] = "fa"; IsoLanguageCode2["Polish"] = "pl"; IsoLanguageCode2["Portuguese"] = "pt"; IsoLanguageCode2["Punjabi"] = "pa"; IsoLanguageCode2["Quechua"] = "qu"; IsoLanguageCode2["Romansh"] = "rm"; IsoLanguageCode2["Romanian"] = "ro"; IsoLanguageCode2["Rundi"] = "rn"; IsoLanguageCode2["Russian"] = "ru"; IsoLanguageCode2["Samoan"] = "sm"; IsoLanguageCode2["Sango"] = "sg"; IsoLanguageCode2["Sanskrit"] = "sa"; IsoLanguageCode2["Sardinian"] = "sc"; IsoLanguageCode2["Serbian"] = "sr"; IsoLanguageCode2["Shona"] = "sn"; IsoLanguageCode2["Sindhi"] = "sd"; IsoLanguageCode2["Sinhala"] = "si"; IsoLanguageCode2["Slovak"] = "sk"; IsoLanguageCode2["Slovenian"] = "sl"; IsoLanguageCode2["Somali"] = "so"; IsoLanguageCode2["SouthernSotho"] = "st"; IsoLanguageCode2["Spanish"] = "es"; IsoLanguageCode2["Sundanese"] = "su"; IsoLanguageCode2["Swahili"] = "sw"; IsoLanguageCode2["Swati"] = "ss"; IsoLanguageCode2["Swedish"] = "sv"; IsoLanguageCode2["Tagalog"] = "tl"; IsoLanguageCode2["Tahitian"] = "ty"; IsoLanguageCode2["Tajik"] = "tg"; IsoLanguageCode2["Tamil"] = "ta"; IsoLanguageCode2["Tatar"] = "tt"; IsoLanguageCode2["Telugu"] = "te"; IsoLanguageCode2["Thai"] = "th"; IsoLanguageCode2["Tibetan"] = "bo"; IsoLanguageCode2["Tigrinya"] = "ti"; IsoLanguageCode2["Tonga"] = "to"; IsoLanguageCode2["Tsonga"] = "ts"; IsoLanguageCode2["Tswana"] = "tn"; IsoLanguageCode2["Turkish"] = "tr"; IsoLanguageCode2["Turkmen"] = "tk"; IsoLanguageCode2["Twi"] = "tw"; IsoLanguageCode2["Uighur"] = "ug"; IsoLanguageCode2["Ukrainian"] = "uk"; IsoLanguageCode2["Urdu"] = "ur"; IsoLanguageCode2["Uzbek"] = "uz"; IsoLanguageCode2["Venda"] = "ve"; IsoLanguageCode2["Vietnamese"] = "vi"; IsoLanguageCode2["Volapuk"] = "vo"; IsoLanguageCode2["Walloon"] = "wa"; IsoLanguageCode2["Welsh"] = "cy"; IsoLanguageCode2["Wolof"] = "wo"; IsoLanguageCode2["Xhosa"] = "xh"; IsoLanguageCode2["Yiddish"] = "yi"; IsoLanguageCode2["Yoruba"] = "yo"; IsoLanguageCode2["Zhuang"] = "za"; IsoLanguageCode2["Zulu"] = "zu"; return IsoLanguageCode2; })(IsoLanguageCode || {}); var ISO_LANGUAGE_SET = new Set( Object.values(IsoLanguageCode) ); function isIsoLanguageCode(value) { return typeof value === "string" && ISO_LANGUAGE_SET.has(value.toLowerCase()); } function isRegionSubtag(value) { return /^[A-Z]{2}$/.test(value) || /^\d{3}$/.test(value); } function isScriptSubtag(value) { return /^[A-Z][a-z]{3}$/.test(value); } function isVariantSubtag(value) { return /^([0-9][A-Za-z0-9]{3}|[A-Za-z0-9]{5,8})$/.test(value); } function isExtensionSingleton(value) { return /^[0-9A-WY-Za-wy-z]$/.test(value); } function isExtensionSubtag(value) { return /^[A-Za-z0-9]{2,8}$/.test(value); } function isPrivateUseSingleton(value) { return value.toLowerCase() === "x"; } function isPrivateUseSubtag(value) { return /^[A-Za-z0-9]{1,8}$/.test(value); } function validateLanguage(value) { if (typeof value !== "string" || value.length === 0) return false; const parts = value.split("-"); let i = 0; const lang = parts[i]; if (!lang || !isIsoLanguageCode(lang)) return false; i += 1; if (i < parts.length && isScriptSubtag(parts[i])) { i += 1; } if (i < parts.length && isRegionSubtag(parts[i].toUpperCase())) { i += 1; } while (i < parts.length && isVariantSubtag(parts[i])) { i += 1; } while (i < parts.length && isExtensionSingleton(parts[i])) { i += 1; if (!(i < parts.length && isExtensionSubtag(parts[i]))) return false; while (i < parts.length && isExtensionSubtag(parts[i])) { i += 1; } } if (i < parts.length && isPrivateUseSingleton(parts[i])) { i += 1; if (!(i < parts.length && isPrivateUseSubtag(parts[i]))) return false; while (i < parts.length && isPrivateUseSubtag(parts[i])) { i += 1; } } return i === parts.length; } // src/field.ts var field = { string: () => new FieldBuilder("string"), number: () => new FieldBuilder("number"), boolean: () => new FieldBuilder("boolean"), object: (fields) => new FieldBuilder("object", { shape: fields }), array: (itemType) => new FieldBuilder("array", { itemType }), ref: (refType) => new FieldBuilder("ref", { refType }), email: () => new FieldBuilder("string").validator(validateEmail).PID({ classification: "high", action: "encrypt", logHandling: "redact", purpose: "an email address" }).description("An email address"), phone: () => new FieldBuilder("string").validator(validatePhone).PID({ classification: "high", action: "encrypt", logHandling: "redact", purpose: "a phone number" }).description("A phone number"), url: () => new FieldBuilder("string").validator(validateUrl).PID({ classification: "low", action: "hash", logHandling: "pseudonym", purpose: "a URL" }).description("A URL"), uuid: () => new FieldBuilder("string").PID({ classification: "low", action: "hash", logHandling: "pseudonym", purpose: "a UUID" }).validator(validateUUID).description("A UUID"), dateTimeISO: () => new FieldBuilder("string").PID({ classification: "none", action: "none", logHandling: "plain", purpose: "a date string" }).validator(validateDateTimeISO).description("A date string in ISO 8601 format"), dateISO: () => new FieldBuilder("string").PID({ classification: "none", action: "none", logHandling: "plain", purpose: "a date string" }).validator((s) => validateDateTimeISO(s, { mode: "date" })).description("A date string in ISO 8601 format (date only)"), timeISO: () => new FieldBuilder("string").PID({ classification: "none", action: "none", logHandling: "plain", purpose: "a time string" }).validator((s) => validateDateTimeISO(s, { mode: "time" })).description("A time string in ISO 8601 format (time only)"), richText: () => new FieldBuilder("string").PID({ classification: "low", action: "clear", logHandling: "omit", purpose: "rich text content" }).validator(validateRichText).description("Rich text content, may include basic HTML formatting"), generalText: () => new FieldBuilder("string").PID({ classification: "none", action: "none", logHandling: "plain", purpose: "Plain text content" }).validator(validateSafeText).description("Standard text content, no HTML allowed"), latitude: () => new FieldBuilder("number").PID({ classification: "low", action: "clear", logHandling: "omit", purpose: "Latitude in decimal degrees, WGS 84 (ISO 6709)" }).min(-90).max(90).description("Latitude in decimal degrees, WGS 84 (ISO 6709)"), longitude: () => new FieldBuilder("number").PID({ classification: "low", action: "clear", logHandling: "omit", purpose: "Longitude in decimal degrees, WGS 84 (ISO 6709)" }).min(-180).max(180).description("Longitude in decimal degrees, WGS 84 (ISO 6709)"), version: () => new FieldBuilder("string").validator(validateSemVer).description("A semantic version string, e.g. '1.0.0'"), countryCode: () => new FieldBuilder("string").validator(validateCountryCode).description("An ISO 3166 country code, e.g. 'US', 'GB', 'FR'"), languageCode: () => new FieldBuilder("string").validator(validateLanguage).description( "An BCP 47 structured language code, primarily ISO 639-1 and optionally with ISO 3166-1 alpha-2 country code, e.g. 'en', 'en-US', 'fr', 'fr-FR'" ) }; // src/pii.ts function enforcePIIField(parentKey, key, value, def, enforcement = "none", errors, logger) { const path = parentKey ? `${parentKey}.${key}` : key; if (def?._pii?.classification === "high" && (def?.isRequired ?? true)) { const missing = value === void 0 || value === null || value === ""; if (missing) { const msg = `High PII field must not be empty: ${path}`; if (enforcement === "strict") { errors?.push(msg); return { shortCircuit: true }; } if (enforcement === "warn") { logger?.warn?.(`WARN (PII Enforcement): ${msg}`); } } } return { shortCircuit: false }; } function prepareForStorage(shape, input, encryptFn, hashFn) { const result = {}; for (const key in shape) { const def = shape[key]; if (!def) continue; const value = input[key]; if (def._pii?.action === "encrypt") { result[key + "Encrypted"] = encryptFn(value); } else if (def._pii?.action === "hash") { result[key + "Hash"] = hashFn(value); } else { result[key] = value; } } return result; } function prepareForRead(shape, stored, decryptFn) { const result = {}; for (const key in shape) { const def = shape[key]; if (!def) continue; if (def._pii?.action === "encrypt") { result[key] = decryptFn(stored[key + "Encrypted"]); } else { result[key] = stored[key]; } } return result; } function sanitizeForLog(shape, data, pseudonymFn) { const output = {}; for (const key in shape) { const def = shape[key]; if (!def) continue; const value = data[key]; const handling = def._pii?.logHandling; if (handling === "omit") continue; if (handling === "redact") { output[key] = "[REDACTED]"; } else if (handling === "pseudonym") { output[key] = pseudonymFn(value); } else { output[key] = value; } } return output; } function getPiiAudit(shape) { const piiFields = []; for (const key in shape) { const def = shape[key]; if (!def) continue; if (def._pii && def._pii.classification !== "none") { piiFields.push({ field: key, classification: def._pii.classification, action: def._pii.action, logHandling: def._pii.logHandling, purpose: def._pii.purpose }); } } return piiFields; } function scrubPiiForDelete(shape, stored) { const result = { ...stored }; for (const key in shape) { const def = shape[key]; if (!def) continue; if (def._pii?.action === "encrypt") { result[key + "Encrypted"] = null; } else if (def._pii?.action === "hash") { result[key + "Hash"] = null; } else if (def._pii?.action === "clear") { result[key] = null; } } return result; } // src/schema.ts var globalSchemaRegistry = /* @__PURE__ */ new Map(); function cmpSemver(a, b) { const pa = a.split(".").map((n) => parseInt(n, 10)); const pb = b.split(".").map((n) => parseInt(n, 10)); for (let i = 0; i < 3; i++) { const ai = pa[i] ?? 0; const bi = pb[i] ?? 0; if (ai > bi) return 1; if (ai < bi) return -1; } return 0; } function applySchemaUpgrade(spec, input, ctx) { if (typeof spec === "function") { return spec(input, ctx); } const steps = [...spec].sort((s1, s2) => cmpSemver(s1.to, s2.to)); let working = { ...input }; let fromVersion = ctx.from; for (const step of steps) { if (cmpSemver(fromVersion, step.to) < 0 && cmpSemver(step.to, ctx.to) <= 0) { ctx.log?.(`Upgrading entity from v${fromVersion} \u2192 v${step.to}`); const res = step.run(working, { ...ctx, from: fromVersion, to: step.to }); if (!res || res.ok !== true || !res.value) { return { ok: false, errors: res?.errors?.length ? res.errors : [`Failed to upgrade entity from v${fromVersion} to v${step.to}`] }; } working = res.value; fromVersion = step.to; } } return { ok: true, value: working }; } function validateEnum(parentKey, value, enumValues) { if (!enumValues) return; const values = Array.isArray(enumValues) ? enumValues : Array.from(enumValues); if (Array.isArray(value)) { const invalid = value.filter((v) => !values.includes(v)); if (invalid.length > 0) { return `Field ${parentKey} contains invalid enum values: ${invalid.join( ", " )}`; } } else { if (!values.includes(value)) { return `Field ${parentKey} must be one of: ${values.join(", ")}`; } } } function getEnumValues(def) { const src = Array.isArray(def?.enum) ? def.enum : Array.isArray(def?.enumValues) ? def.enumValues : Array.isArray(def?._enum) ? def._enum : Array.isArray(def?._enumValues) ? def._enumValues : def?._enumSet instanceof Set ? Array.from(def._enumSet) : void 0; if (!src) return void 0; const ok = src.every( (v) => typeof v === "string" || typeof v === "number" ); return ok ? src : void 0; } function isOptional(def) { return (def?.isRequired ?? false) === false; } function getValidator(def) { return def?._validator ?? void 0; } function getShape(def) { return def?._shape ?? def?.shape ?? void 0; } function checkMissingRequired(parentKey, key, value, def, errors) { if (value === void 0 || value === null) { const path = parentKey ? `${parentKey}.${key}` : key; if (!isOptional(def)) { errors.push(`Missing required field: ${path}`); } return { missing: true }; } return { missing: false }; } function checkImmutable(parentKey, key, value, def, existing, errors) { if (def.isImmutable && existing && existing[key] !== void 0 && value !== existing[key]) { const path = parentKey ? `${parentKey}.${key}` : key; errors.push(`Field is immutable: ${path}`); return { immutableViolation: true }; } return { immutableViolation: false }; } function runCustomValidator(parentKey, key, value, def, errors) { const validator = getValidator(def); if (validator && value !== void 0 && value !== null) { const valid = validator(value); if (!valid) { const path = parentKey ? `${parentKey}.${key}` : key; errors.push(`Invalid value for field: ${path}`); return { invalid: true }; } } return { invalid: false }; } function validateStringField(parentKey, key, value, def, errors) { const path = parentKey ? `${parentKey}.${key}` : key; if (typeof value !== "string") { errors.push(`Field ${path} must be string`); return; } const enumValues = getEnumValues(def); if (Array.isArray(enumValues)) { const enumError = validateEnum(path, value, enumValues); if (enumError) { errors.push(enumError); } } } function validateNumberField(parentKey, key, value, _def, errors) { const enumPath = parentKey ? `${parentKey}.${key}` : key; if (typeof value !== "number") { errors.push(`Field ${enumPath} must be number`); } } function validateBooleanField(parentKey, key, value, _def, errors) { const path = parentKey ? `${parentKey}.${key}` : key; if (typeof value !== "boolean") { errors.push(`Field ${path} must be boolean`); } } function validateObjectChildren(parentKey, obj, shape, errors) { for (const [childKey, childDef] of Object.entries(shape)) { const childValue = obj[childKey]; const { missing } = checkMissingRequired( parentKey, childKey, childValue, childDef, errors ); if (missing) continue; const { invalid } = runCustomValidator( parentKey, childKey, childValue, childDef, errors ); if (invalid) continue; validateByType(parentKey, childKey, childValue, childDef, errors); } } function validateObjectField(parentKey, key, value, def, errors) { const path = parentKey ? `${parentKey}.${key}` : key; if (typeof value !== "object" || value === null || Array.isArray(value)) { errors.push(`Field ${path} must be object`); return; } const objShape = getShape(def); if (objShape) validateObjectChildren(path, value, objShape, errors); } function validateArrayOfStrings(parentKey, key, arr, itemDef, errors) { const path = parentKey ? `${parentKey}.${key}` : key; if (!arr.every((v) => typeof v === "string")) { errors.push(`Field ${path} must be string[]`); return; } const enumValues = getEnumValues(itemDef); if (Array.isArray(enumValues)) { const enumError = validateEnum(path, arr, enumValues); if (enumError) { errors.push(enumError); } } } function validateArrayOfNumbers(parentKey, key, arr, itemDef, errors) { const path = parentKey ? `${parentKey}.${key}` : key; if (!arr.every((v) => typeof v === "number")) { errors.push(`Field ${path} must be number[]`); } const enumValues = getEnumValues(itemDef); if (Array.isArray(enumValues)) { const enumError = validateEnum(path, arr, enumValues); if (enumError) { errors.push(enumError); } } } function validateArrayOfBooleans(parentKey, key, arr, _itemDef, errors) { const path = parentKey ? `${parentKey}.${key}` : key; if (!arr.every((v) => typeof v === "boolean")) { errors.push(`Field ${path} must be boolean[]`); } } function validateArrayOfObjects(parentKey, key, arr, itemDef, errors) { const path = parentKey ? `${parentKey}.${key}` : key; if (!Array.isArray(arr) || !arr.every((v) => typeof v === "object" && v !== null && !Array.isArray(v))) { errors.push(`Field ${path} must be object[]`); return; } const itemShape = getShape(itemDef); if (!itemShape) return; arr.forEach((item, idx) => { const itemParent = `${path}[${idx}]`; for (const [childKey, childDef] of Object.entries(itemShape)) { const childValue = item[childKey]; const { missing } = checkMissingRequired( itemParent, childKey, childValue, childDef, errors ); if (missing) continue; const { invalid } = runCustomValidator( itemParent, childKey, childValue, childDef, errors ); if (invalid) continue; validateByType(itemParent, childKey, childValue, childDef, errors); } }); } function validateArrayField(parentKey, key, value, def, errors) { const path = parentKey ? `${parentKey}.${key}` : key; if (!Array.isArray(value)) { errors.push(`Field ${key} must be an array`); return; } const itemType = def.itemType?.type; if (itemType === "string") return validateArrayOfStrings(parentKey, key, value, def.itemType, errors); if (itemType === "number") return validateArrayOfNumbers(parentKey, key, value, def.itemType, errors); if (itemType === "boolean") return validateArrayOfBooleans(parentKey, key, value, def.itemType, errors); if (itemType === "object") return validateArrayOfObjects(parentKey, key, value, def.itemType, errors); if (itemType === "ref") { const expectedType = def.itemType.refType; value.forEach((ref, idx) => { if (!ref || typeof ref !== "object" || ref === null || typeof ref.type !== "string" || typeof ref.id !== "string" || expectedType && ref.type !== expectedType) { errors.push( `Field ${path}[${idx}] must be a reference object with type: ${expectedType}` ); } }); const refShape = getShape(def.itemType); if (refShape) { value.forEach((ref, idx) => { if (ref && typeof ref === "object" && ref !== null) { for (const [childKey, childDef] of Object.entries(refShape)) { const childValue = ref[childKey]; if ((childValue === void 0 || childValue === null) && !isOptional(childDef)) { errors.push( `Missing required field: ${path}[${idx}].${childKey}` ); continue; } const childValidator = getValidator(childDef); if (childValidator && childValue !== void 0 && childValue !== null) { const valid = childValidator(childValue); if (!valid) errors.push( `Invalid value for field: ${path}[${idx}].${childKey}` ); } } } }); } return; } errors.push(`Field ${path} has unsupported array item type`); } function validateRefField(parentKey, key, value, _def, errors) { if (typeof value !== "object" || value === null || typeof value.type !== "string" || typeof value.id !== "string") { const path = parentKey ? `${parentKey}.${key}` : key; errors.push(`Field ${path} must be { type: string; id: string }`); } } function validateByType(parentKey, key, value, def, errors) { const path = parentKey ? `${parentKey}.${key}` : key; switch (def.type) { case "string": return validateStringField(parentKey, key, value, def, errors); case "number": return validateNumberField(parentKey, key, value, def, errors); case "boolean": return validateBooleanField(parentKey, key, value, def, errors); case "object": return validateObjectField(parentKey, key, value, def, errors); case "array": return validateArrayField(parentKey, key, value, def, errors); case "ref": return validateRefField(parentKey, key, value, def, errors); default: errors.push(`Unknown type for field ${path}: ${def.type}`); } } function createSchema(_shape, entityType, options = { version: "1.0.0", table: "", schemaValidator: () => true, piiEnforcement: "none", schemaUpgrade: void 0 }) { const systemFields = { type: field.string().immutable().system(), version: field.string().immutable().system().validator(validateSemVer) }; const version = options.version || "1.0.0"; const store = options.table || ""; const schemaUpgrade = options.schemaUpgrade; const schema = { // 🔗 Define the schema shape _shape: { ...systemFields, ..._shape }, // 🔗 Metadata about the schema meta: { entityType, version }, // 🔗 Validate input against the schema validate(input, existing) { const errors = []; const result = {}; if (typeof input !== "object" || input === null) { return { valid: false, errors: ["Input must be an object"] }; } const working = { ...input }; if (working.type == null) working.type = entityType; if (working.version == null) working.version = version; const fromVersion = String(working.version ?? "0.0.0"); const toVersion = String(version); if (schemaUpgrade && cmpSemver(fromVersion, toVersion) < 0) { const upgradeRes = applySchemaUpgrade(schemaUpgrade, { ...working }, { from: fromVersion, to: toVersion, entityType, describe: () => schema.describe() }); if (!upgradeRes || upgradeRes.ok !== true || !upgradeRes.value) { const errs = upgradeRes?.errors?.length ? upgradeRes.errors : [`Failed to upgrade entity from v${fromVersion} to v${toVersion}`]; errors.push(...errs); return { valid: false, errors }; } for (const k of Object.keys(working)) delete working[k]; Object.assign(working, upgradeRes.value); working.type = entityType; working.version = toVersion; } for (const key in schema._shape) { const def = schema._shape[key]; const value = working[key]; if (!def) { errors.push(`Field definition missing for: ${key}`); continue; } const { missing } = checkMissingRequired("", key, value, def, errors); if (missing) continue; const { immutableViolation } = checkImmutable( "", key, value, def, existing, errors ); if (immutableViolation) continue; const { shortCircuit } = enforcePIIField( "", key, value, def, options.piiEnforcement ?? "none", errors, console ); if (shortCircuit) continue; const validateField = (val) => { const localErrors = []; const { invalid } = runCustomValidator( "", key, val, def, localErrors ); if (!invalid) { validateByType("", key, val, def, localErrors); } return localErrors; }; let fieldValue = value; let fieldErrors = validateField(fieldValue); const entityFrom = String(working.version ?? "0.0.0"); const entityTo = String(version); const fieldTo = String(def._version ?? entityTo); const hasUpgrader = typeof def._upgrade === "function"; if (fieldErrors.length > 0 && hasUpgrader && cmpSemver(entityFrom, entityTo) < 0) { const up = def._upgrade; const res = up(fieldValue, { entityFrom, entityTo, fieldTo, fieldName: key }); if (res && res.ok) { fieldValue = res.value; fieldErrors = validateField(fieldValue); } else { fieldErrors.push( res?.error || `Failed to upgrade field ${key} from v${entityFrom} to v${entityTo}` ); } } result[key] = fieldValue; if (fieldErrors.length !== 0) { errors.push(...fieldErrors); continue; } } if (errors.length === 0 && options.schemaValidator) { const castValue = result; if (!options.schemaValidator(castValue)) { errors.push("Schema-level validation failed."); } } return { valid: errors.length === 0, value: result, errors }; }, // specific validator for a schema to allow conditional validation schemaValidator: options.schemaValidator, // <== expose it here! /** * Runs the optional schema-level upgrade function once, without validating. * Useful for offline migrations or testing migration logic. */ upgrade(input, log) { const fromVersion = String(input?.version ?? "0.0.0"); const toVersion = String(version); if (!schemaUpgrade || cmpSemver(fromVersion, toVersion) >= 0) { return { ok: true, value: { ...input, type: entityType, version: toVersion } }; } const res = applySchemaUpgrade( schemaUpgrade, { ...input }, { from: fromVersion, to: toVersion, entityType, describe: () => schema.describe(), log } ); if (res && res.ok && res.value) { const out = { ...res.value, type: entityType, version: toVersion }; return { ok: true, value: out }; } return { ok: false, errors: res?.errors?.length ? res.errors : [`Failed to upgrade entity from v${fromVersion} to v${toVersion}`] }; }, /** * Recursively validates entity references defined in this schema. * * Traverses fields of type `ref` and arrays of `ref` and resolves each target * entity using the provided `resolveEntity` function. When `autoValidate` is * enabled (default) and the field's `refPolicy` is `eager`, the referenced * entity's schema is fetched and validated via `validateComposition` up to * `maxDepth` levels. * * Skips fields not listed in `onlyFields` when provided. Prevents cycles via * a `visited` set in `validatorContext`. * * @param entity The root entity to validate (must include `type` and `id`). * @param options Options controlling traversal and resolution behavior. * @param options.resolveEntity Function to resolve a referenced entity by type and id. * @param options.validatorContext Internal context (visited set) to prevent cycles. * @param options.maxDepth Maximum depth for recursive validation (default: 5). * @param options.onlyFields Optional whitelist of field names to validate. * @param options.log Optional logger for traversal/debug output. * * @throws Error if a broken reference is encountered (target cannot be resolved). */ async validateComposition(entity, options2) { const { resolveEntity, validatorContext = { visited: /* @__PURE__ */ new Set() }, maxDepth = 5, log } = options2; const entityKey = `${entity.type}:${entity.id}`; if (validatorContext.visited.has(entityKey)) { log?.(`Skipping already visited entity ${entityKey}`); return; } validatorContext.visited.add(entityKey); log?.(`Validating composition for entity ${entityKey}`); for (const [key, def] of Object.entries(schema._shape)) { if (options2.onlyFields && !options2.onlyFields.includes(key)) { log?.(`Skipping field ${key} (not in onlyFields)`); continue; } const refType = def.refType; const autoValidate = def.autoValidate !== false; const refPolicy = def.refPolicy ?? "eager"; const value = entity[key]; if (!value) continue; if (def.type === "ref") { const ref = value; const target = await options2.resolveEntity(refType, ref.id); if (!target) throw new Error( `Broken reference: ${refType} ${ref.id} in field ${key}` ); log?.(`Resolved ${refType} ${ref.id} from field ${key}`); if (autoValidate && refPolicy === "eager") { const targetSchema = getSchemaForType(refType); if (options2.maxDepth > 0 && targetSchema) { await targetSchema.validateComposition(target, { ...options2, maxDepth: options2.maxDepth - 1 }); } } } else if (def.type === "array" && def.itemType?.type === "ref") { const refs = value; for (const ref of refs) { const target = await options2.resolveEntity(refType, ref.id); if (!target) throw new Error( `Broken reference: ${refType} ${ref.id} in field ${key}` ); log?.(`Resolved ${refType} ${ref.id} from field ${key}`); if (autoValidate && refPolicy === "eager") { const targetSchema = getSchemaForType(refType); if (options2.maxDepth > 0 && targetSchema) { await targetSchema.validateComposition(target, { ...options2, maxDepth: options2.maxDepth - 1 }); } } } } } }, /** * Returns the configured table name for this schema. * * @throws Error if no store/table name has been defined for this schema. */ tableName() { if (!store || store === "") { throw new Error("Store is not defined for this schema"); } return store; }, /** * Transforms an input object for persistence by applying PII protection * according to field annotations (e.g., encryption and hashing). * * @param input The raw entity data. * @param encryptFn Function used to encrypt sensitive values. * @param hashFn Function used to hash sensitive values. * @returns A new object safe to store. */ prepareForStorage(input, encryptFn, hashFn) { return prepareForStorage(_shape, input, encryptFn, hashFn); }, /** * Reverses storage transformations for read paths (e.g., decrypts values) * according to PII annotations, returning a consumer-friendly object. * * @param stored Data retrieved from storage. * @param decryptFn Functio