Created
September 4, 2025 02:30
-
-
Save fanannan/ac09d9d423f33e22bf22118347a764d6 to your computer and use it in GitHub Desktop.
Self-contained immutable dictionary with embedded schema
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /*! | |
| * schema-model.js | |
| * (c) Gugen Koubou 2025 / MIT License | |
| * | |
| * Self-contained immutable dictionary with embedded schema (Strict & Clojure-inspired) | |
| * Plain JavaScript / Zero dependencies / Single file / UMD / Browser & Node.js | |
| * | |
| * ────────────────────────────────────────────────────────────────────────────── | |
| * Purpose | |
| * - Embeds "hidden schema" in a dictionary for strict validation on all updates | |
| * - Detects unknown keys / Immediate errors on type violations / Consistent req/opt operation | |
| * - No automatic initialization. getIn throws exception on missing keys without default | |
| * - Clojure-style: assocIn/updateIn/dissocIn/mergeIn for immutable updates (non-destructive) | |
| * | |
| * ────────────────────────────────────────────────────────────────────────────── | |
| * Exposed API / 露出 API | |
| * Type/Modifiers (★ defaults specified via constructor args): | |
| * num(dft?) // e.g., num(0) | |
| * str(dft?) // e.g., str("en") | |
| * bool(dft?) // e.g., bool(false) | |
| * obj(dft?) // e.g., obj({}) ※ Only checks for plain object (no internal key validation) | |
| * vec(item, dft?) // e.g., vec(User, []) | |
| * dict(fields, dft?) // e.g., dict({ theme: req(str("light")), ... }, { theme:"light" }) | |
| * req(schema) / opt(schema) | |
| * | |
| * Model Creation/Conversion: | |
| * instantiateSchema(schema, [initialPatch]) // Merges defaults + initial patch with strict schema validation | |
| * isModel(value) | |
| * toDict(model) // Pure POJO with hidden metadata removed | |
| * | |
| * Path Utilities: | |
| * makePath() // Safe path generation via template literals | |
| * | |
| * Retrieval: | |
| * getIn(model, path, [defaultValue]) // Throws on missing keys without default | |
| * | |
| * Immutable Updates (dict/array): | |
| * assocIn(model, path, value) | |
| * updateIn(model, path, updaterFn) | |
| * dissocIn(model, path) // Cannot remove required fields | |
| * mergeIn(model, path, patch) // Shallow merge (unknown dict keys forbidden) | |
| * ensureIn(model, path, initialValue) // Sets value only if undefined (explicit initialization) | |
| * | |
| * Array Helpers (STRICT: target must be existing array): | |
| * pushIn, unshiftIn, insertIn, removeAtIn, removeWhereIn, updateAtIn, | |
| * moveIn, swapIn, mapIn, filterIn, sortByIn, uniqByIn, | |
| * toggleIn, setAddIn, setRemoveIn, upsertByIn | |
| * | |
| * Transaction: | |
| * transaction(model, tx => { tx.assocIn(...).updateIn(...).pushIn(...); }) | |
| * | |
| * ────────────────────────────────────────────────────────────────────────────── | |
| * Specification Summary / 仕様の要点 | |
| * - Schema types: num | str | bool | obj | dict(fields) | vec(item) | any | |
| * ・dict: No additional keys allowed (unknown keys always error). Values validated per field schema | |
| * ・vec : All elements validated against item schema | |
| * ・obj : Only requires "plain object" (no internal key validation) | |
| * ・any : Complete pass-through (escape hatch, use sparingly) | |
| * - req/opt: | |
| * ・req(schema): Required. undefined assignment/deletion causes error. Must be satisfied on creation | |
| * ・opt(schema): Optional. Type-validated if present. May be absent | |
| * ・dict fields default to required unless wrapped in opt() | |
| * - Defaults: | |
| * ・Constructor arg 'dft' stored as node property | |
| * ・instantiateSchema merges schema defaults + initialPatch with full validation | |
| * ・With dft, req can be satisfied without initialPatch. Without dft, must provide in initialPatch | |
| * | |
| * ────────────────────────────────────────────────────────────────────────────── | |
| * Usage Example / 使用例 | |
| * const { num, str, bool, obj, dict, vec, any, req, opt, | |
| * instantiateSchema, toDict, makePath, | |
| * getIn, assocIn, updateIn, pushIn, upsertByIn, dissocIn, mergeIn } = SchemaModel; | |
| * | |
| * // 1) Define schema with defaults via constructors | |
| * const User = dict({ | |
| * id: req(str()), // No default → required at creation | |
| * name: req(str("Taro")), // Required + default | |
| * note: opt(str()) // Optional | |
| * }); | |
| * | |
| * const Settings = dict({ | |
| * theme: req(str("light")), // Required + default | |
| * lang: req(str()), // Required (no default → must provide at creation) | |
| * dark: opt(bool(false)), // Optional + default false | |
| * extras: opt(obj({})) // Optional + default {} | |
| * }); | |
| * | |
| * const Root = dict({ | |
| * user: req(dict({ | |
| * settings: req(Settings), | |
| * profile: req(dict({ | |
| * age: opt(num()), // Optional | |
| * tags: req(vec(str(), [])) // Required + default [] | |
| * })) | |
| * })), | |
| * users: req(vec(User, [])), // Required + default [] | |
| * payload: opt(any()) | |
| * }); | |
| * | |
| * // 2) Create model: lang has no default, must provide in initialPatch | |
| * let state = instantiateSchema(Root, { user: { settings: { lang: 'ja' } } }); | |
| * | |
| * // 3) Update (always schema-validated, unknown keys throw error) | |
| * state = assocIn(state, ['user','settings','theme'], 'dark'); // OK | |
| * state = updateIn(state, ['user','profile','tags'], t => t.concat('js')); // OK | |
| * state = pushIn(state, ['users'], { id:'u1', name:'Hanako' }); // OK | |
| * // state = assocIn(state, ['user','settings','lagn'], 'en'); // NG: Unknown key | |
| * | |
| * // 4) Merge (shallow) | |
| * state = mergeIn(state, ['user','settings'], { dark:true }); | |
| * | |
| * // 5) Delete (cannot remove required fields) | |
| * // state = dissocIn(state, ['user','settings']); // NG | |
| * | |
| * // 6) Serialize (removes hidden schema) | |
| * const plain = toDict(state); | |
| */ | |
| ;(function (global, factory) { | |
| if (typeof module === 'object' && typeof module.exports === 'object') { | |
| module.exports = factory(); | |
| } else { | |
| global.SchemaModel = factory(); | |
| } | |
| })(typeof window !== 'undefined' ? window : globalThis, function () { | |
| 'use strict'; | |
| // ───────── Internal Utilities ───────── | |
| const SYM_SCHEMA = Symbol('@@schema'); | |
| const FORBIDDEN = Object.freeze({ __proto__:1, constructor:1, prototype:1 }); | |
| const hasOwn = (o,k)=>Object.prototype.hasOwnProperty.call(o,k); | |
| const isObj = x => x !== null && typeof x === 'object'; | |
| const isPlain= x => Object.prototype.toString.call(x) === '[object Object]'; | |
| const cloneShallow = x => Array.isArray(x) ? x.slice() : Object.assign({}, x); | |
| const pstr = keys => keys.join('.'); | |
| function normPath(p){ | |
| if (Array.isArray(p)) return p.slice(); | |
| if (typeof p !== 'string') throw new TypeError('path must be array or string'); | |
| const parts = p.replace(/\[(\d+)\]/g, '.$1').split('.').filter(Boolean); | |
| return parts.map(s => String(+s)===s ? +s : s); | |
| } | |
| function assertSafePath(keys){ | |
| for (const k of keys) if (typeof k==='string' && FORBIDDEN[k]) throw new Error(`Forbidden path segment: "${k}"`); | |
| } | |
| function assertArrIndex(i,len,ps){ | |
| if (!Number.isInteger(i) || i<0 || i>=len) throw new RangeError(`Invalid array index at "${ps}": ${i}`); | |
| } | |
| // ───────── Schema Definitions ───────── | |
| // Node: { kind: 'num'|'str'|'bool'|'obj'|'dict'|'vec'|'any', req?:boolean, dft?:any } | |
| // dict : { kind:'dict', fields: { key: Schema, ... }, req?, dft? } | |
| // vec : { kind:'vec', item: Schema, req?, dft? } | |
| const KINDS = new Set(['num','str','bool','obj','dict','vec','any']); | |
| /** | |
| * Creates an 'any' type schema that accepts any value without validation | |
| * @returns {Object} Schema node with kind='any' | |
| */ | |
| const any = () => ({ kind:'any' }); // Complete pass-through (escape hatch) | |
| /** | |
| * Creates a number type schema | |
| * @param {number} [dft] - Optional default value | |
| * @returns {Object} Schema node with kind='num' | |
| * @throws {TypeError} If default is not a valid number | |
| */ | |
| const num = (dft) => { | |
| const s = { kind:'num' }; | |
| if (arguments.length) { | |
| if (typeof dft !== 'number' || Number.isNaN(dft)) throw new TypeError('num(dft): dft must be number'); | |
| s.dft = dft; | |
| } | |
| return s; | |
| }; | |
| /** | |
| * Creates a string type schema | |
| * @param {string} [dft] - Optional default value | |
| * @returns {Object} Schema node with kind='str' | |
| * @throws {TypeError} If default is not a string | |
| */ | |
| const str = (dft) => { | |
| const s = { kind:'str' }; | |
| if (arguments.length) { | |
| if (typeof dft !== 'string') throw new TypeError('str(dft): dft must be string'); | |
| s.dft = dft; | |
| } | |
| return s; | |
| }; | |
| /** | |
| * Creates a boolean type schema | |
| * @param {boolean} [dft] - Optional default value | |
| * @returns {Object} Schema node with kind='bool' | |
| * @throws {TypeError} If default is not a boolean | |
| */ | |
| const bool = (dft) => { | |
| const s = { kind:'bool' }; | |
| if (arguments.length) { | |
| if (typeof dft !== 'boolean') throw new TypeError('bool(dft): dft must be boolean'); | |
| s.dft = dft; | |
| } | |
| return s; | |
| }; | |
| /** | |
| * Creates a plain object type schema (no internal key validation) | |
| * @param {Object} [dft] - Optional default value | |
| * @returns {Object} Schema node with kind='obj' | |
| * @throws {TypeError} If default is not a plain object | |
| */ | |
| const obj = (dft) => { | |
| const s = { kind:'obj' }; // plain object requirement (no internal key validation) | |
| if (arguments.length) { | |
| if (!isPlain(dft)) throw new TypeError('obj(dft): dft must be plain object'); | |
| s.dft = dft; | |
| } | |
| return s; | |
| }; | |
| /** | |
| * Creates an array/vector type schema with typed elements | |
| * @param {Object} itemSchema - Schema for array elements | |
| * @param {Array} [dft] - Optional default array value | |
| * @returns {Object} Schema node with kind='vec' | |
| * @throws {TypeError} If itemSchema is invalid or default is not an array | |
| */ | |
| const vec = (itemSchema, dft) => { | |
| if (!isPlain(itemSchema) || !KINDS.has(itemSchema.kind)) throw new TypeError('vec(item): invalid schema'); | |
| const s = { kind:'vec', item: itemSchema }; | |
| if (arguments.length >= 2) { | |
| if (!Array.isArray(dft)) throw new TypeError('vec(item, dft): dft must be array'); | |
| for (let i=0;i<dft.length;i++) validateValue(dft[i], itemSchema, `<vec-dft>[${i}]`); | |
| s.dft = dft; | |
| } | |
| return s; | |
| }; | |
| /** | |
| * Creates a dictionary/object type schema with specific field schemas | |
| * @param {Object} fields - Object mapping field names to their schemas | |
| * @param {Object} [dft] - Optional default object value | |
| * @returns {Object} Schema node with kind='dict' | |
| * @throws {TypeError} If fields is not a plain object or contains invalid schemas | |
| * @throws {Error} If forbidden keys are used or unknown keys in default | |
| */ | |
| const dict = (fields, dft) => { | |
| if (!isPlain(fields)) throw new TypeError('dict(fields): fields must be plain object'); | |
| const out = {}; | |
| for (const k in fields){ | |
| if (!hasOwn(fields,k)) continue; | |
| if (FORBIDDEN[k]) throw new Error(`Forbidden key "${k}" in dict schema`); | |
| const sch = fields[k]; | |
| if (!isPlain(sch) || !KINDS.has(sch.kind)) throw new TypeError(`dict field "${k}" must be a schema`); | |
| out[k] = sch; | |
| } | |
| const s = { kind:'dict', fields: out }; | |
| if (arguments.length >= 2) { | |
| if (!isPlain(dft)) throw new TypeError('dict(fields, dft): dft must be plain object'); | |
| for (const k in dft){ | |
| if (!hasOwn(dft,k)) continue; | |
| if (!hasOwn(out,k)) throw new Error(`Unknown key "${k}" in dict default`); | |
| validateValue(dft[k], out[k], `<dict-dft>.${k}`); | |
| } | |
| s.dft = dft; | |
| } | |
| return s; | |
| }; | |
| /** | |
| * Marks a schema as required | |
| * @param {Object} schema - Schema to mark as required | |
| * @returns {Object} Schema with req=true | |
| */ | |
| const req = (schema) => Object.assign({}, schema, { req:true }); | |
| /** | |
| * Marks a schema as optional | |
| * @param {Object} schema - Schema to mark as optional | |
| * @returns {Object} Schema with req=false | |
| */ | |
| const opt = (schema) => Object.assign({}, schema, { req:false }); | |
| const isRequired = s => s.req === true; | |
| const hasDefault = s => ('dft' in s); | |
| function assertSchemaNode(s, where){ | |
| if (!isPlain(s) || !KINDS.has(s.kind)) throw new TypeError(`Invalid schema at ${where||'<root>'}`); | |
| if (s.kind==='dict'){ | |
| for (const k in s.fields){ if (hasOwn(s.fields,k)) assertSchemaNode(s.fields[k], (where?where+'.':'')+k); } | |
| } else if (s.kind==='vec'){ | |
| assertSchemaNode(s.item, (where?where:'<vec-item>')); | |
| } | |
| } | |
| function validateValue(v, s, pathStr){ | |
| switch(s.kind){ | |
| case 'num': if (typeof v!=='number' || Number.isNaN(v)) throw new TypeError(`Expected number at "${pathStr}"`); return; | |
| case 'str': if (typeof v!=='string') throw new TypeError(`Expected string at "${pathStr}"`); return; | |
| case 'bool': if (typeof v!=='boolean') throw new TypeError(`Expected boolean at "${pathStr}"`); return; | |
| case 'obj': if (!isPlain(v)) throw new TypeError(`Expected plain object at "${pathStr}"`); return; | |
| case 'any': return; | |
| case 'dict': { | |
| if (!isPlain(v)) throw new TypeError(`Expected dict (plain object) at "${pathStr}"`); | |
| for (const k in v){ | |
| if (!hasOwn(v,k)) continue; | |
| if (FORBIDDEN[k]) throw new Error(`Forbidden key "${k}" at "${pathStr}"`); | |
| const f = s.fields[k]; | |
| if (!f) throw new Error(`Unknown key "${k}" at "${pathStr}"`); | |
| validateValue(v[k], f, pathStr ? `${pathStr}.${k}` : k); | |
| } | |
| for (const k in s.fields){ | |
| if (!hasOwn(s.fields,k)) continue; | |
| const f = s.fields[k]; | |
| if (isRequired(f) && !hasOwn(v,k)) throw new Error(`Missing required key "${k}" at "${pathStr}"`); | |
| if (hasOwn(v,k)) validateValue(v[k], f, pathStr ? `${pathStr}.${k}` : k); | |
| } | |
| return; | |
| } | |
| case 'vec': { | |
| if (!Array.isArray(v)) throw new TypeError(`Expected array at "${pathStr}"`); | |
| for (let i=0;i<v.length;i++){ | |
| const el = v[i]; | |
| if (el === undefined) throw new TypeError(`Undefined not allowed at "${pathStr}[${i}]"`); | |
| validateValue(el, s.item, `${pathStr}[${i}]`); | |
| } | |
| return; | |
| } | |
| default: throw new Error(`Unknown schema kind at "${pathStr}"`); | |
| } | |
| } | |
| function schemaAt(schema, keys){ | |
| let cur = schema; | |
| for (let i=0;i<keys.length;i++){ | |
| const seg = keys[i]; | |
| if (cur.kind==='dict'){ | |
| if (typeof seg!=='string' || !hasOwn(cur.fields, seg)) { | |
| throw new Error(`Missing key at "${pstr(keys.slice(0,i+1))}" (schema)`); | |
| } | |
| cur = cur.fields[seg]; | |
| } else if (cur.kind==='vec'){ | |
| if (typeof seg!=='number') throw new TypeError(`Expected array index at "${pstr(keys.slice(0,i+1))}"`); | |
| cur = cur.item; | |
| } else { | |
| throw new TypeError(`Cannot descend into non-structured node at "${pstr(keys.slice(0,i+1))}"`); | |
| } | |
| } | |
| return cur; | |
| } | |
| // Build defaults from schema 'dft' properties (only necessary parts) | |
| function defaultsFromSchema(s){ | |
| if (hasDefault(s)) return cloneDefaultValue(s.dft); | |
| switch(s.kind){ | |
| case 'dict': { | |
| let anyFilled = false; | |
| const o = {}; | |
| for (const k in s.fields){ | |
| if (!hasOwn(s.fields,k)) continue; | |
| const child = s.fields[k]; | |
| if (hasDefault(child)) { | |
| o[k] = cloneDefaultValue(child.dft); | |
| anyFilled = true; | |
| } else { | |
| const sub = defaultsFromSchema(child); | |
| if (sub !== undefined) { o[k] = sub; anyFilled = true; } | |
| } | |
| } | |
| return anyFilled ? o : undefined; | |
| } | |
| case 'vec': { | |
| return undefined; // vec without dft returns undefined (no generation) | |
| } | |
| default: | |
| return undefined; // num/str/bool/obj/any without dft returns undefined | |
| } | |
| } | |
| function cloneDefaultValue(v){ | |
| if (Array.isArray(v)) return v.map(cloneDefaultValue); | |
| if (isPlain(v)) { const o={}; for (const k in v){ if (hasOwn(v,k)) o[k]=cloneDefaultValue(v[k]); } return o; } | |
| return v; | |
| } | |
| // dict-specific: Deep merge following schema (defaults ← initialPatch) | |
| function deepMergeDictBySchema(schema, base, patch){ | |
| if (base !== undefined && !isPlain(base)) throw new TypeError('internal: base must be dict or undefined'); | |
| if (patch !== undefined && !isPlain(patch)) throw new TypeError('internal: patch must be dict or undefined'); | |
| if (base === undefined && patch === undefined) return undefined; | |
| const out = {}; | |
| for (const k in schema.fields){ | |
| if (!hasOwn(schema.fields,k)) continue; | |
| const childSch = schema.fields[k]; | |
| const baseV = (base && hasOwn(base,k)) ? base[k] : undefined; | |
| const patchHas = patch && hasOwn(patch,k); | |
| const patchV = patchHas ? patch[k] : undefined; | |
| let merged; | |
| if (childSch.kind === 'dict') { | |
| merged = deepMergeDictBySchema(childSch, baseV, patchHas ? patchV : undefined); | |
| } else if (childSch.kind === 'vec') { | |
| merged = (patchHas ? patchV : baseV); | |
| } else { | |
| merged = (patchHas ? patchV : baseV); | |
| } | |
| if (merged !== undefined) out[k] = merged; | |
| } | |
| if (patch) { | |
| for (const k in patch){ | |
| if (!hasOwn(patch,k)) continue; | |
| if (!hasOwn(schema.fields,k)) throw new Error(`Unknown key "${k}" at "<initial>"`); | |
| } | |
| } | |
| return out; | |
| } | |
| // ───────── Model Creation/Conversion ───────── | |
| function attachSchema(root, schema){ | |
| Object.defineProperty(root, SYM_SCHEMA, { value:schema, enumerable:false, writable:false, configurable:false }); | |
| return root; | |
| } | |
| function getSchema(root){ return root[SYM_SCHEMA]; } | |
| function isModel(x){ return Boolean(x && x[SYM_SCHEMA]); } | |
| /** | |
| * Creates a model instance from a schema with defaults and initial values | |
| * @param {Object} schema - Schema definition to instantiate | |
| * @param {Object} [initialPatch] - Initial values to merge with defaults | |
| * @returns {Object} Model instance with embedded schema | |
| * @throws {TypeError} If schema is invalid or values don't match schema | |
| * @throws {Error} If required fields are missing | |
| * | |
| * Process: | |
| * 1) Collects defaults from schema 'dft' properties | |
| * 2) Merges defaults with initialPatch following schema rules | |
| * 3) Validates final result strictly (throws on missing required fields) | |
| */ | |
| function instantiateSchema(schema, initialPatch){ | |
| assertSchemaNode(schema, '<root>'); | |
| const defaults = defaultsFromSchema(schema); | |
| let merged; | |
| if (schema.kind === 'dict') { | |
| merged = deepMergeDictBySchema(schema, defaults, initialPatch); | |
| } else if (schema.kind === 'vec') { | |
| if (initialPatch !== undefined) { | |
| if (!Array.isArray(initialPatch)) throw new TypeError('initialPatch for vec root must be array'); | |
| merged = initialPatch; | |
| } else { | |
| merged = (defaults !== undefined) ? defaults : undefined; | |
| } | |
| } else { | |
| merged = (initialPatch !== undefined) ? initialPatch | |
| : (defaults !== undefined ? defaults : undefined); | |
| } | |
| if (merged === undefined) merged = {}; // Validate as empty dict (adjust if needed) | |
| validateValue(merged, schema, '<root>'); | |
| const root = cloneShallow(merged); | |
| return attachSchema(root, schema); | |
| } | |
| /** | |
| * Converts a model to plain object by removing hidden schema metadata | |
| * @param {Object} model - Model with embedded schema | |
| * @returns {Object} Plain object without schema symbols | |
| */ | |
| function toDict(model){ | |
| function strip(x){ | |
| if (Array.isArray(x)) return x.map(strip); | |
| if (isPlain(x)){ const out={}; for (const k in x){ if (hasOwn(x,k)) out[k]=strip(x[k]); } return out; } | |
| return x; | |
| } | |
| return strip(model); | |
| } | |
| // ───────── Path Utilities ───────── | |
| /** | |
| * Creates a cached path parser using template literals | |
| * @returns {Function} Template tag function for path parsing | |
| * @example | |
| * const P = makePath(); | |
| * P`user.settings[0]` // Returns ['user', 'settings', 0] | |
| */ | |
| function makePath(){ | |
| const CACHE = new Map(); | |
| return (strings, ...vals) => { | |
| const raw = strings.reduce((s, p, i) => s + p + (i<vals.length ? String(vals[i]) : ''), ''); | |
| let cached = CACHE.get(raw); | |
| if (!cached){ cached = normPath(raw); CACHE.set(raw, cached); } | |
| return cached; | |
| }; | |
| } | |
| // ───────── Retrieval ───────── | |
| /** | |
| * Retrieves a value from nested structure using path | |
| * @param {Object} model - Model or plain object to query | |
| * @param {Array|string} path - Path to value (array or dot notation) | |
| * @param {*} [defaultValue] - Return value if path not found | |
| * @returns {*} Value at path or defaultValue | |
| * @throws {Error} If path not found and no defaultValue provided | |
| */ | |
| function getIn(model, path, defaultValue){ | |
| const keys = normPath(path); assertSafePath(keys); | |
| let cur = model; | |
| for (let i=0;i<keys.length;i++){ | |
| const k = keys[i]; | |
| if (!(cur && typeof cur==='object' && hasOwn(cur, k))) { | |
| if (arguments.length < 3) throw new Error(`Missing key at "${pstr(keys.slice(0,i+1))}"`); | |
| return defaultValue; | |
| } | |
| cur = cur[k]; | |
| } | |
| return cur; | |
| } | |
| // ───────── Immutable Updates ───────── | |
| /** | |
| * Immutably sets a value at path in model | |
| * @param {Object} model - Model with embedded schema | |
| * @param {Array|string} path - Path to set value at | |
| * @param {*} value - Value to set | |
| * @returns {Object} New model with updated value | |
| * @throws {Error} If path invalid, value violates schema, or setting undefined to required field | |
| */ | |
| function assocIn(model, path, value){ | |
| if (!isModel(model)) throw new Error('assocIn: model must be created by instantiateSchema'); | |
| const keys = normPath(path); assertSafePath(keys); | |
| if (keys.length === 0) { | |
| validateValue(value, getSchema(model), '<root>'); | |
| const nextRoot = isPlain(value) ? cloneShallow(value) : value; | |
| return attachSchema(nextRoot, getSchema(model)); | |
| } | |
| const sch = schemaAt(getSchema(model), keys); | |
| // Check parent existence in actual data (no automatic initialization) | |
| let cur = model; | |
| for (let i=0;i<keys.length-1;i++){ | |
| const seg = keys[i]; | |
| if (!(cur && typeof cur==='object' && hasOwn(cur, seg))) { | |
| throw new Error(`Missing key at "${pstr(keys.slice(0,i+1))}"`); | |
| } | |
| cur = cur[seg]; | |
| } | |
| const parent = cur, last = keys[keys.length-1]; | |
| if (!(parent && typeof parent==='object')) throw new TypeError(`Parent is not object/array at "${pstr(keys.slice(0,-1))}"`); | |
| if (isRequired(sch) && value === undefined) throw new Error(`Required value cannot be undefined at "${pstr(keys)}"`); | |
| validateValue(value, sch, pstr(keys)); | |
| const rootClone = cloneShallow(model); | |
| let node = rootClone, src = model; | |
| for (let i=0;i<keys.length-1;i++){ | |
| const s = keys[i]; | |
| const nextSrc = src[s]; | |
| const nextNode = Array.isArray(nextSrc) ? nextSrc.slice() | |
| : isPlain(nextSrc) ? Object.assign({}, nextSrc) | |
| : (()=>{ throw new TypeError(`non-object at "${pstr(keys.slice(0,i+1))}"`); })(); | |
| node[s] = nextNode; node = nextNode; src = nextSrc; | |
| } | |
| node[last] = value; | |
| return attachSchema(rootClone, getSchema(model)); | |
| } | |
| /** | |
| * Immutably updates a value at path using updater function | |
| * @param {Object} model - Model with embedded schema | |
| * @param {Array|string} path - Path to value to update | |
| * @param {Function} updater - Function that takes current value and returns new value | |
| * @returns {Object} New model with updated value | |
| * @throws {TypeError} If updater is not a function | |
| * @throws {Error} If updated value violates schema | |
| */ | |
| function updateIn(model, path, updater){ | |
| if (typeof updater!=='function') throw new TypeError('updateIn: updater must be function'); | |
| const keys = normPath(path); assertSafePath(keys); | |
| const current = getIn(model, keys); // Throws if missing | |
| const next = updater(current); | |
| const sch = schemaAt(getSchema(model), keys); | |
| if (isRequired(sch) && next === undefined) throw new Error(`Required value cannot be undefined at "${pstr(keys)}"`); | |
| validateValue(next, sch, pstr(keys)); | |
| if (current === next) return model; | |
| return assocIn(model, keys, next); | |
| } | |
| /** | |
| * Immutably removes a value at path from model | |
| * @param {Object} model - Model with embedded schema | |
| * @param {Array|string} path - Path to remove | |
| * @returns {Object} New model with value removed | |
| * @throws {Error} If trying to remove required field or root | |
| */ | |
| function dissocIn(model, path){ | |
| if (!isModel(model)) throw new Error('dissocIn: model must be created by instantiateSchema'); | |
| const keys = normPath(path); assertSafePath(keys); | |
| if (keys.length === 0) throw new Error('dissocIn: cannot remove root'); | |
| const sch = schemaAt(getSchema(model), keys); | |
| if (isRequired(sch)) throw new Error(`Cannot remove required key at "${pstr(keys)}"`); | |
| // Check actual data existence | |
| let cur = model; | |
| for (let i=0;i<keys.length-1;i++){ | |
| const seg = keys[i]; | |
| if (!(cur && typeof cur==='object' && hasOwn(cur, seg))) { | |
| throw new Error(`Missing key at "${pstr(keys.slice(0,i+1))}"`); | |
| } | |
| cur = cur[seg]; | |
| } | |
| const last = keys[keys.length-1]; | |
| if (!(cur && typeof cur==='object' && hasOwn(cur,last))) throw new Error(`Missing key at "${pstr(keys)}"`); | |
| const rootClone = cloneShallow(model); | |
| let node = rootClone, src = model; | |
| for (let i=0;i<keys.length-1;i++){ | |
| const s = keys[i]; | |
| const nextSrc = src[s]; | |
| const nextNode = Array.isArray(nextSrc) ? nextSrc.slice() | |
| : isPlain(nextSrc) ? Object.assign({}, nextSrc) | |
| : (()=>{ throw new TypeError(`non-object at "${pstr(keys.slice(0,i+1))}"`); })(); | |
| node[s] = nextNode; node = nextNode; src = nextSrc; | |
| } | |
| if (Array.isArray(node)) { | |
| if (typeof last !== 'number') throw new TypeError(`array index required at "${pstr(keys)}"`); | |
| assertArrIndex(last, node.length, pstr(keys)); | |
| node.splice(last,1); | |
| } else { | |
| delete node[last]; | |
| } | |
| return attachSchema(rootClone, getSchema(model)); | |
| } | |
| /** | |
| * Immutably merges an object patch at path (shallow merge) | |
| * @param {Object} model - Model with embedded schema | |
| * @param {Array|string} path - Path to object to merge into | |
| * @param {Object} patch - Object to merge | |
| * @returns {Object} New model with merged values | |
| * @throws {TypeError} If target or patch is not a plain object | |
| * @throws {Error} If patch contains unknown keys for dict schema | |
| */ | |
| function mergeIn(model, path, patch){ | |
| const keys = normPath(path); assertSafePath(keys); | |
| const target = getIn(model, keys); // Throws if missing | |
| if (!isPlain(target)) throw new TypeError(`Expected plain object at "${pstr(keys)}"`); | |
| if (!isPlain(patch)) throw new TypeError(`Expected plain object (patch) at "${pstr(keys)}"`); | |
| const sch = schemaAt(getSchema(model), keys); | |
| if (sch.kind === 'dict'){ | |
| for (const k in patch){ | |
| if (!hasOwn(patch,k)) continue; | |
| if (!hasOwn(sch.fields,k)) throw new Error(`Unknown key "${k}" at "${pstr(keys)}"`); | |
| } | |
| } | |
| const merged = Object.assign({}, target, patch); | |
| validateValue(merged, sch, pstr(keys)); | |
| return assocIn(model, keys, merged); | |
| } | |
| /** | |
| * Sets a value at path only if currently undefined | |
| * @param {Object} model - Model with embedded schema | |
| * @param {Array|string} path - Path to check and potentially set | |
| * @param {*} initialValue - Value to set if undefined | |
| * @returns {Object} Original model if value exists, new model with value otherwise | |
| * @throws {Error} If value violates schema | |
| */ | |
| function ensureIn(model, path, initialValue){ | |
| const keys = normPath(path); assertSafePath(keys); | |
| let cur = model; | |
| for (let i=0;i<keys.length-1;i++){ | |
| const seg = keys[i]; | |
| if (!(cur && typeof cur==='object' && hasOwn(cur, seg))) { | |
| throw new Error(`Missing key at "${pstr(keys.slice(0,i+1))}"`); | |
| } | |
| cur = cur[seg]; | |
| } | |
| const last = keys[keys.length-1]; | |
| if (hasOwn(cur, last)) return model; | |
| const sch = schemaAt(getSchema(model), keys); | |
| if (isRequired(sch) && initialValue === undefined) throw new Error(`Required value cannot be undefined at "${pstr(keys)}"`); | |
| validateValue(initialValue, sch, pstr(keys)); | |
| return assocIn(model, keys, initialValue); | |
| } | |
| // ───────── Array Helpers ───────── | |
| function requireVecSchema(model, keys){ | |
| const sch = schemaAt(getSchema(model), keys); | |
| if (sch.kind !== 'vec') throw new TypeError(`Expected vec at "${pstr(keys)}"`); | |
| return sch.item; | |
| } | |
| function pushIn(model, path, item){ | |
| const keys = normPath(path); assertSafePath(keys); | |
| const itemSch = requireVecSchema(model, keys); | |
| const arr = getIn(model, keys); | |
| if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`); | |
| validateValue(item, itemSch, `${pstr(keys)}[]`); | |
| const next = arr.slice(); next.push(item); | |
| return assocIn(model, keys, next); | |
| } | |
| function unshiftIn(model, path, item){ | |
| const keys = normPath(path); assertSafePath(keys); | |
| const itemSch = requireVecSchema(model, keys); | |
| const arr = getIn(model, keys); | |
| if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`); | |
| validateValue(item, itemSch, `${pstr(keys)}[]`); | |
| const next = arr.slice(); next.unshift(item); | |
| return assocIn(model, keys, next); | |
| } | |
| function insertIn(model, path, index, item){ | |
| const keys = normPath(path); assertSafePath(keys); | |
| const itemSch = requireVecSchema(model, keys); | |
| const arr = getIn(model, keys); | |
| if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`); | |
| if (!Number.isInteger(index) || index<0 || index>arr.length) { | |
| throw new RangeError(`Invalid insert index at "${pstr(keys)}": ${index}`); | |
| } | |
| validateValue(item, itemSch, `${pstr(keys)}[${index}]`); | |
| const next = arr.slice(); next.splice(index, 0, item); | |
| return assocIn(model, keys, next); | |
| } | |
| function removeAtIn(model, path, index){ | |
| const keys = normPath(path); assertSafePath(keys); | |
| const arr = getIn(model, keys); | |
| if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`); | |
| assertArrIndex(index, arr.length, pstr(keys)); | |
| const next = arr.slice(); next.splice(index,1); | |
| return assocIn(model, keys, next); | |
| } | |
| function removeWhereIn(model, path, predicate){ | |
| if (typeof predicate!=='function') throw new TypeError('predicate must be function'); | |
| const keys = normPath(path); assertSafePath(keys); | |
| const itemSch = requireVecSchema(model, keys); | |
| const arr = getIn(model, keys); | |
| if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`); | |
| const next = arr.filter(x => !predicate(x)); | |
| validateValue(next, { kind:'vec', item:itemSch }, pstr(keys)); | |
| return assocIn(model, keys, next); | |
| } | |
| function updateAtIn(model, path, index, updater){ | |
| if (typeof updater!=='function') throw new TypeError('updater must be function'); | |
| const keys = normPath(path); assertSafePath(keys); | |
| const itemSch = requireVecSchema(model, keys); | |
| const arr = getIn(model, keys); | |
| if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`); | |
| assertArrIndex(index, arr.length, pstr(keys)); | |
| const cur = arr[index]; | |
| const nxt = updater(cur); | |
| validateValue(nxt, itemSch, `${pstr(keys)}[${index}]`); | |
| if (cur === nxt) return model; | |
| const out = arr.slice(); out[index] = nxt; | |
| return assocIn(model, keys, out); | |
| } | |
| function moveIn(model, path, fromIndex, toIndex){ | |
| const keys = normPath(path); assertSafePath(keys); | |
| const arr = getIn(model, keys); | |
| if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`); | |
| assertArrIndex(fromIndex, arr.length, pstr(keys)); | |
| assertArrIndex(toIndex, arr.length, pstr(keys)); | |
| if (fromIndex === toIndex) return model; | |
| const out = arr.slice(); | |
| const item = out.splice(fromIndex,1)[0]; | |
| out.splice(toIndex,0,item); | |
| return assocIn(model, keys, out); | |
| } | |
| function swapIn(model, path, i, j){ | |
| const keys = normPath(path); assertSafePath(keys); | |
| const arr = getIn(model, keys); | |
| if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`); | |
| assertArrIndex(i, arr.length, pstr(keys)); | |
| assertArrIndex(j, arr.length, pstr(keys)); | |
| if (i===j) return model; | |
| const out = arr.slice(); const t = out[i]; out[i] = out[j]; out[j] = t; | |
| return assocIn(model, keys, out); | |
| } | |
| function mapIn(model, path, mapper){ | |
| if (typeof mapper!=='function') throw new TypeError('mapper must be function'); | |
| const keys = normPath(path); assertSafePath(keys); | |
| const itemSch = requireVecSchema(model, keys); | |
| const arr = getIn(model, keys); | |
| if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`); | |
| const out = arr.map(mapper); | |
| validateValue(out, { kind:'vec', item:itemSch }, pstr(keys)); | |
| return assocIn(model, keys, out); | |
| } | |
| function filterIn(model, path, predicate){ | |
| if (typeof predicate!=='function') throw new TypeError('predicate must be function'); | |
| const keys = normPath(path); assertSafePath(keys); | |
| const itemSch = requireVecSchema(model, keys); | |
| const arr = getIn(model, keys); | |
| if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`); | |
| const out = arr.filter(predicate); | |
| validateValue(out, { kind:'vec', item:itemSch }, pstr(keys)); | |
| return assocIn(model, keys, out); | |
| } | |
| function sortByIn(model, path, keyExtractor, compareFn){ | |
| const keys = normPath(path); assertSafePath(keys); | |
| const itemSch = requireVecSchema(model, keys); | |
| const arr = getIn(model, keys); | |
| if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`); | |
| const out = arr.slice(); | |
| if (typeof compareFn === 'function') out.sort(compareFn); | |
| else if (typeof keyExtractor === 'function') { | |
| out.sort((a,b)=> { | |
| const ka = keyExtractor(a), kb = keyExtractor(b); | |
| return ka<kb ? -1 : ka>kb ? 1 : 0; | |
| }); | |
| } else { | |
| out.sort(); | |
| } | |
| validateValue(out, { kind:'vec', item:itemSch }, pstr(keys)); | |
| return assocIn(model, keys, out); | |
| } | |
| function uniqByIn(model, path, keyExtractor){ | |
| if (typeof keyExtractor!=='function') throw new TypeError('keyExtractor must be function'); | |
| const keys = normPath(path); assertSafePath(keys); | |
| const itemSch = requireVecSchema(model, keys); | |
| const arr = getIn(model, keys); | |
| if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`); | |
| const seen = new Set(); const out = []; | |
| for (const el of arr){ const k = keyExtractor(el); if (!seen.has(k)){ seen.add(k); out.push(el); } } | |
| validateValue(out, { kind:'vec', item:itemSch }, pstr(keys)); | |
| return assocIn(model, keys, out); | |
| } | |
| function toggleIn(model, path, value){ | |
| const keys = normPath(path); assertSafePath(keys); | |
| const arr = getIn(model, keys); | |
| if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`); | |
| const idx = arr.indexOf(value); | |
| const out = arr.slice(); | |
| if (idx === -1) out.push(value); else out.splice(idx,1); | |
| return assocIn(model, keys, out); | |
| } | |
| function setAddIn(model, path, value){ | |
| const keys = normPath(path); assertSafePath(keys); | |
| const arr = getIn(model, keys); | |
| if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`); | |
| if (arr.indexOf(value) !== -1) return model; | |
| const out = arr.concat([value]); | |
| return assocIn(model, keys, out); | |
| } | |
| function setRemoveIn(model, path, value){ | |
| const keys = normPath(path); assertSafePath(keys); | |
| const arr = getIn(model, keys); | |
| if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`); | |
| const idx = arr.indexOf(value); if (idx<0) return model; | |
| const out = arr.slice(); out.splice(idx,1); | |
| return assocIn(model, keys, out); | |
| } | |
| function upsertByIn(model, path, keyFn, item){ | |
| if (typeof keyFn!=='function') throw new TypeError('keyFn must be function'); | |
| const keys = normPath(path); assertSafePath(keys); | |
| const itemSch = requireVecSchema(model, keys); | |
| const arr = getIn(model, keys); | |
| if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`); | |
| validateValue(item, itemSch, `${pstr(keys)}[]`); | |
| const k = keyFn(item); | |
| let idx = -1; | |
| for (let i=0;i<arr.length;i++){ if (keyFn(arr[i])===k){ idx=i; break; } } | |
| if (idx === -1) return pushIn(model, keys, item); | |
| return updateAtIn(model, keys, idx, () => item); | |
| } | |
| // ───────── Transaction ───────── | |
| /** | |
| * Performs multiple updates in a transaction-like manner | |
| * @param {Object} model - Model to update | |
| * @param {Function} fn - Function that receives transaction API | |
| * @returns {Object} Final updated model | |
| * @example | |
| * transaction(model, tx => { | |
| * tx.assocIn(['user', 'name'], 'Alice') | |
| * .updateIn(['count'], n => n + 1) | |
| * .pushIn(['items'], newItem); | |
| * }) | |
| */ | |
| function transaction(model, fn){ | |
| let draft = model; | |
| const api = { | |
| assocIn: (p,v) => (draft = assocIn(draft, p, v), api), | |
| updateIn: (p,f) => (draft = updateIn(draft, p, f), api), | |
| dissocIn: (p) => (draft = dissocIn(draft, p), api), | |
| mergeIn: (p,pat) => (draft = mergeIn(draft, p, pat), api), | |
| pushIn: (p,x) => (draft = pushIn(draft, p, x), api), | |
| insertIn: (p,i,x) => (draft = insertIn(draft, p, i, x), api), | |
| updateAtIn:(p,i,f) => (draft = updateAtIn(draft, p, i, f), api), | |
| removeAtIn:(p,i) => (draft = removeAtIn(draft, p, i), api), | |
| }; | |
| fn(api); | |
| return draft; | |
| } | |
| // ───────── Exports ───────── | |
| return Object.freeze({ | |
| // Type/Modifiers | |
| num, str, bool, obj, dict, vec, any, req, opt, | |
| // Model | |
| instantiateSchema, isModel, toDict, | |
| // Path | |
| makePath, | |
| // Retrieval | |
| getIn, | |
| // Updates | |
| assocIn, updateIn, dissocIn, mergeIn, ensureIn, | |
| // Arrays | |
| pushIn, unshiftIn, insertIn, removeAtIn, removeWhereIn, updateAtIn, | |
| moveIn, swapIn, mapIn, filterIn, sortByIn, uniqByIn, toggleIn, setAddIn, setRemoveIn, upsertByIn, | |
| // Transaction | |
| transaction, | |
| }); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment