๐Ÿ“„ src/models/orm.ts
D-OPEN SOVEREIGN

Documentation & Insights

Warn once per unmapped language: falling back to langId 0 silently would
 make e.g. Amharic and Ge'ez patches collide in the lang-agnostic slot.
Reads a per-language value: exact lang -> lang-agnostic -> single-value fallback.
lang -> instance array (BookInstanceData structs, not ORM objects).
lang -> propId -> provenance code (2-bit: unset/AI/human/consensus).
Raw in-memory relation mutation: no patch is emitted. Used by the
generic inverse propagation in patch() and by generated in-side
relation helpers. Idempotent (Set semantics).
Bulk-restores the inverse (`in`) edges implied by this record's forward
(`has`) relations onto their target records. The sidecar serializes only
the `has` direction (ยง3.7.1); `mergeExtended` fills `_has`, but the inverse
side (`Person.authorOf`, `Tag.inTags`, โ€ฆ) stays empty until this runs.

No patch emitted, no reactivity fired (bulk boot path). Idempotent (Set
add). Targets must already be materialized โ€” call after a full
`hydrateAll` pass (see `rebuildInverseRelations`).
Applies a V2 patch to the in-memory state. Triggers reactivity.
Direct state write โ€” no patch emitted (used by merge methods).
Direct state write โ€” no patch emitted (used by mergeExtended/mergeSlim).
1/*
2 * Copyright (c) 2026 DataPond D-Library Pty Ltd. ("The Sanctuary")
3 * Non-Profit Public Library โ€” The World Library
4 * 
5 * D-SAFE Certification issued by POND Enterprise.
6 * Published under the D-Open Code Sovereign Licence v1.0 framework
7 * DataPond D-Library All exclusive Right reserved on modifiying the code - CC Attribution to data pond, Non modifiable, Non Commercial.
8 * https://registry.world.bibliotech.com/licence
9 * Source code donated to DataPond D-Library by it's author: data pond.
10 * 
11 * Technical Guardian ("The Shield"): Pond Enterprise Pty Ltd. (ACN 694 747 987)
12 * All liability claims about D-SAFE certification issues coming from the published D-Safe direction must be addressed with Pond Enterprise Pty Ltd.
13 * Code Modification automatically voids the certified liability protection provided by Pond Enterprise.
14 */
15import {
16    PatchOperation, PatchAction,
17    LANG_ID_ENCODE, LANG_ID_DECODE,
18    type AnyPatch,
19    type EditPropertyStringObj, type EditPropertyNumberObj,
20    type EditInstanceFieldStringObj, type EditInstanceFieldNumberObj,
21    type EditRelationObj,
22    type CreateRecordObj, type AddInstanceObj,
23} from "./binary.ts";
24import { LifecycleStatus } from "./lifecycle.ts";
25import {
26    LANG_AGNOSTIC,
27    type CommonOrmJson,
28    type SerializedCommonOrmJson,
29    type InstanceData,
30    type Lang,
31    type uid
32} from "./interfaces.ts";
33import { Stats } from "./stats.ts";
34import { track } from "./versioning.ts";
35import { isUserCreated, type UidFactory } from "./uidFactory.ts";
36import { LruCache, getRecord, addRecord, pinRecord } from "./db.ts";
37import { localRecordTable } from "./localRecordTable.ts";
38import { unpinRecord } from "./db.ts";
39import { PatchInstance } from "./patcher.ts";
40import { colName, capitalize } from "./serializer.ts";
41import { Registry, relationColName, type ModelDefinition } from "./registry.ts";
42import { dbReactivity, hasRecord } from "./db.ts";
43
44let _userLang: Lang | null = null;
45let _commonLang: Lang = 'eng';
46let _defaultProvenance: number | undefined = undefined;
47
48export const Config = {
49    get userLang(): Lang {
50        if (_userLang === null) {
51            throw new Error("[models_v2] Default language not set! Call setDefaultLanguage() before using models.");
52        }
53        return _userLang;
54    },
55    get commonLang(): Lang {
56        return _commonLang;
57    },
58    get defaultProvenance(): number | undefined {
59        return _defaultProvenance;
60    }
61};
62
63export function setDefaultLanguage(lang: Lang) {
64    _userLang = lang;
65}
66
67export function setDefaultProvenance(provenance: number) {
68    _defaultProvenance = provenance;
69}
70
71// Provenance codes (2-bit: 0=unset, 1=AI, 2=Human/Local, 3=Consensus)
72export const PROVENANCE_UNSET = 0;
73export const PROVENANCE_AI = 1;
74export const PROVENANCE_MANUAL_LOCAL = 2;
75export const PROVENANCE_CONSENSUS = 3;
76
77// Base prop IDs โ€” frozen in @the_library/db_schemas dist/binary.json
78export const TITLE_PROP = 0;
79export const DESCRIPTION_PROP = 1;
80export const ORIGINAL_LANG_PROP = 2;
81export const AGE_GRADE_BAND_PROP = 3;
82export const PREVIEW_TX_ID_PROP = 4;
83export const SAFE_PROP = 5;
84export const DELETED_PROP = 6;
85export const LIFECYCLE_STATUS_PROP = 7;
86
87export { LifecycleStatus };
88
89// โ”€โ”€โ”€ (De)serialization helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
90
91export function serializeMapOfSets(map: Map<number, Set<number>>): Array<[number, Array<number>]> {
92    return Array.from(map.entries()).map(([key, value]) => [key, Array.from(value.values())]);
93}
94
95export function serializeMapOfMaps<V>(mapOfMaps: Map<number, Map<Lang, V>>): Array<[number, Array<[Lang, V]>]> {
96    return Array.from(mapOfMaps, ([key, innerMap]) => [key, Array.from(innerMap.entries())]);
97}
98
99export const inflateOrmJson = (v2: SerializedCommonOrmJson): CommonOrmJson => {
100    const [id, hasArr, inArr, numArr, boolArr, strArr, instArr] = v2;
101    return [
102        id,
103        new Map(hasArr.map(([rid, set]) => [rid, new Set(set)])),
104        new Map(inArr.map(([rid, set]) => [rid, new Set(set)])),
105        new Map(numArr.map(([pid, langs]) => [pid, new Map(langs)])),
106        new Map(boolArr.map(([pid, langs]) => [pid, new Map(langs)])),
107        new Map(strArr.map(([pid, langs]) => [pid, new Map(langs)])),
108        new Map(instArr),
109    ];
110}
111
112/** Warn once per unmapped language: falling back to langId 0 silently would
113 *  make e.g. Amharic and Ge'ez patches collide in the lang-agnostic slot. */
114const warnedLangs = new Set<string>();
115
116/** Reads a per-language value: exact lang -> lang-agnostic -> single-value fallback. */
117const resolveLangValue = <V>(values: Map<Lang, V> | undefined, lang: Lang): V | undefined => {
118    if (!values) return undefined;
119    if (values.has(lang)) return values.get(lang);
120    if (values.has(LANG_AGNOSTIC)) return values.get(LANG_AGNOSTIC);
121    if (values.size === 1) return values.values().next().value;
122    return undefined;
123}
124
125export class Orm extends Stats {
126    readonly _id: uid
127
128    protected _boolValues = new Map<number, Map<Lang, boolean>>();
129    protected _stringValues = new Map<number, Map<Lang, string>>();
130    protected _numberValues = new Map<number, Map<Lang, number>>();
131
132    protected _has = new Map<number, Set<number>>();
133    protected _in = new Map<number, Set<number>>();
134
135    /** lang -> instance array (BookInstanceData structs, not ORM objects). */
136    protected _instances = new Map<Lang, InstanceData[]>();
137
138    /** lang -> propId -> provenance code (2-bit: unset/AI/human/consensus). */
139    protected _provenance = new Map<Lang, Map<number, number>>();
140
141    schema: ModelDefinition
142
143    static createNewRequest(id: uid): CommonOrmJson {
144        return [
145            id,
146            new Map<number, Set<number>>(),
147            new Map<number, Set<number>>(),
148            new Map<number, Map<Lang, number>>(),
149            new Map<number, Map<Lang, boolean>>(),
150            new Map<number, Map<Lang, string>>(),
151            new Map<Lang, InstanceData[]>(),
152        ];
153    }
154
155    static async createNewRecord<T extends Orm>(
156        this: { new (json: any, creatorUsername?: string): T; type: number },
157        uidFactory: UidFactory,
158        creatorUsername: string
159    ): Promise<T> {
160        const { wireId, localId } = await uidFactory.next();
161
162        // 1. Emit patch
163        const op: CreateRecordObj = {
164            operation: PatchOperation.CreateRecord,
165            objectType: this.type,
166            objectId: wireId,
167            langId: 0,
168        };
169        PatchInstance.addPatch(op);
170
171        // 2. Hydrate empty record (id, has, in, numbers, bools, strings, instances)
172        const emptyJson: CommonOrmJson = [
173            wireId,
174            new Map(), new Map(), new Map(), new Map(), new Map(), new Map()
175        ];
176        const record = new this(emptyJson, creatorUsername);
177
178        // 3. Put in LRU Cache
179        addRecord(this.type, record, creatorUsername);
180        pinRecord(this.type, wireId, creatorUsername);
181
182        // 4. Update LocalRecordTable
183        await localRecordTable.putIfAbsent({
184            wireId,
185            creatorUsername,
186            objectType: this.type,
187            localId,
188            createdAt: Date.now(),
189            status: 'active'
190        });
191
192        // No hooks registry in models_v2 currently.
193
194        return record;
195    }
196
197    protected _creatorUsername?: string;
198    get creatorUsername(): string {
199        if (!isUserCreated(this.id)) return 'canonical';
200        return this._creatorUsername ?? '';
201    }
202
203    constructor(objectType: number, json: CommonOrmJson | SerializedCommonOrmJson, creatorUsername?: string) {
204        const hydrated: CommonOrmJson = (json[1] instanceof Map)
205            ? json as CommonOrmJson
206            : inflateOrmJson(json as SerializedCommonOrmJson);
207
208        super(hydrated[0], objectType);
209        this._id = hydrated[0]
210        this._creatorUsername = creatorUsername;
211        this._has = hydrated[1]
212        this._in = hydrated[2]
213        this._numberValues = hydrated[3]
214        this._boolValues = hydrated[4]
215        this._stringValues = hydrated[5]
216        this._instances = hydrated[6] ?? new Map()
217
218        this.schema = Registry.get(objectType);
219
220        // Ensure all schema relations are initialized
221        for (const { relationId, direction } of this.schema.relations ?? []) {
222            const target = direction === 'has' ? this._has : this._in;
223            if (!target.has(relationId)) {
224                target.set(relationId, new Set());
225            }
226        }
227
228        // Base string props
229        for (const pid of [TITLE_PROP, DESCRIPTION_PROP, ORIGINAL_LANG_PROP, PREVIEW_TX_ID_PROP]) {
230            if (!this._stringValues.has(pid)) {
231                this._stringValues.set(pid, new Map())
232            }
233        }
234        // Base boolean props. `safe` defaults true (only explicit moderation or
235        // a corpus sidecar's mergeSlim() should mark a record unsafe) โ€” unlike
236        // `deleted`, an unreviewed record must not read as trashed (trash.ts).
237        if (!this._boolValues.has(SAFE_PROP)) {
238            this._boolValues.set(SAFE_PROP, new Map([[LANG_AGNOSTIC, true]]))
239        }
240        if (!this._boolValues.has(DELETED_PROP)) {
241            this._boolValues.set(DELETED_PROP, new Map([[LANG_AGNOSTIC, false]]))
242        }
243    }
244
245    toJSON(): SerializedCommonOrmJson {
246        return [
247            this._id,
248            serializeMapOfSets(this._has),
249            serializeMapOfSets(this._in),
250            serializeMapOfMaps(this._numberValues),
251            serializeMapOfMaps(this._boolValues),
252            serializeMapOfMaps(this._stringValues),
253            Array.from(this._instances.entries()),
254        ];
255    }
256
257    get id(): number {
258        return this._id
259    }
260
261    // โ”€โ”€โ”€ Language helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
262
263    protected langOf(langId: number): Lang {
264        return LANG_ID_DECODE[langId] ?? LANG_AGNOSTIC;
265    }
266
267    protected langIdOf(lang?: Lang): number {
268        const l = lang ?? Config.userLang;
269        const id = LANG_ID_ENCODE[l];
270        if (id === undefined) {
271            if (l !== LANG_AGNOSTIC && !warnedLangs.has(l)) {
272                warnedLangs.add(l);
273                console.warn(
274                    `[models_v2] Unknown language "${l}" โ€” patches will land in the lang-agnostic slot (langId 0) ` +
275                    `and may collide with other unmapped languages. Append "${l}" to LANG_ID_ENCODE in db_schema_v2.`,
276                );
277            }
278            return 0;
279        }
280        return id;
281    }
282
283    // โ”€โ”€โ”€ Base props โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
284
285    get title(): string { return this.getProp(TITLE_PROP) ?? ""; }
286    set title(value: string) { this.dispatchString(TITLE_PROP, value); }
287    getTitle(lang: Lang): string { return this.getProp(TITLE_PROP, lang) ?? ""; }
288    setTitle(value: string, lang: Lang) { this.dispatchString(TITLE_PROP, value, lang); }
289
290    get description(): string { return this.getProp(DESCRIPTION_PROP) ?? ""; }
291    set description(value: string) { this.dispatchString(DESCRIPTION_PROP, value); }
292    getDescription(lang: Lang): string { return this.getProp(DESCRIPTION_PROP, lang) ?? ""; }
293    setDescription(value: string, lang: Lang) { this.dispatchString(DESCRIPTION_PROP, value, lang); }
294
295    get originalLang(): string { return this.getProp(ORIGINAL_LANG_PROP) ?? ""; }
296    set originalLang(value: string) { this.dispatchString(ORIGINAL_LANG_PROP, value); }
297    getOriginalLang(lang: Lang): string { return this.getProp(ORIGINAL_LANG_PROP, lang) ?? ""; }
298    setOriginalLang(value: string, lang: Lang) { this.dispatchString(ORIGINAL_LANG_PROP, value, lang); }
299
300    get ageGradeBand(): number { return this.getProp(AGE_GRADE_BAND_PROP) ?? 0; }
301    set ageGradeBand(value: number) { this.dispatchNumber(AGE_GRADE_BAND_PROP, value); }
302
303    get previewTxId(): string { return this.getProp(PREVIEW_TX_ID_PROP) ?? ""; }
304    set previewTxId(value: string) { this.dispatchString(PREVIEW_TX_ID_PROP, value); }
305
306    get safe(): boolean { return this.getProp(SAFE_PROP) ?? true; }
307    set safe(value: boolean) { this.dispatchNumber(SAFE_PROP, value ? 1 : 0); }
308
309    get deleted(): boolean { return this.getProp(DELETED_PROP) ?? false; }
310    set deleted(value: boolean) { this.dispatchNumber(DELETED_PROP, value ? 1 : 0); }
311
312    get lifecycleStatus(): LifecycleStatus { return this.getProp(LIFECYCLE_STATUS_PROP) ?? LifecycleStatus.Provisional; }
313    set lifecycleStatus(value: LifecycleStatus) {
314        if (!isUserCreated(this.id)) {
315            throw new Error('lifecycleStatus can only be set on user-created provisional records');
316        }
317        if (!this._creatorUsername) throw new Error('Missing creatorUsername on provisional record');
318        if (value < this.lifecycleStatus) {
319            throw new Error(`lifecycleStatus is forward-only: cannot transition from ${this.lifecycleStatus} to ${value}`);
320        }
321        // dispatchNumber() -> patch()'s EditPropertyNumber case already updates
322        // localRecordTable's status (and unpins on Canonical) โ€” don't duplicate it here.
323        this.dispatchNumber(LIFECYCLE_STATUS_PROP, value, LANG_AGNOSTIC);
324    }
325
326    // โ”€โ”€โ”€ Patch dispatch (mutation entry points used by generated setters) โ”€โ”€โ”€โ”€
327
328    protected dispatchString(propertyId: number, value: string, lang?: Lang) {
329        const op: EditPropertyStringObj = {
330            operation: PatchOperation.EditPropertyString,
331            objectType: this.objectType,
332            objectId: this.id,
333            langId: this.langIdOf(lang),
334            propertyId,
335            value,
336        }
337        this.patch(op)
338        const targetLang = lang ?? Config.userLang;
339        const prov = Config.defaultProvenance;
340        if (prov !== undefined && this.getProvenance(propertyId, targetLang) !== prov) {
341            this.setProvenanceRaw(targetLang, propertyId, prov);
342        }
343        PatchInstance.addPatch(op)
344    }
345
346    protected dispatchNumber(propertyId: number, value: number, lang?: Lang) {
347        const op: EditPropertyNumberObj = {
348            operation: PatchOperation.EditPropertyNumber,
349            objectType: this.objectType,
350            objectId: this.id,
351            langId: this.langIdOf(lang),
352            propertyId,
353            value,
354        }
355        this.patch(op)
356        const targetLang = lang ?? Config.userLang;
357        const prov = Config.defaultProvenance;
358        if (prov !== undefined && this.getProvenance(propertyId, targetLang) !== prov) {
359            this.setProvenanceRaw(targetLang, propertyId, prov);
360        }
361        PatchInstance.addPatch(op)
362    }
363
364    protected dispatchRelation(relationId: number, action: PatchAction, targetId: number) {
365        const op: EditRelationObj = {
366            operation: PatchOperation.EditRelation,
367            objectType: this.objectType,
368            objectId: this.id,
369            langId: 0,
370            relationId,
371            action,
372            targetId,
373        }
374        this.patch(op)
375        PatchInstance.addPatch(op)
376    }
377
378    /**
379     * Raw in-memory relation mutation: no patch is emitted. Used by the
380     * generic inverse propagation in patch() and by generated in-side
381     * relation helpers. Idempotent (Set semantics).
382     */
383    protected applyRelationRaw(direction: 'has' | 'in', relationId: number, action: PatchAction, targetId: number) {
384        const map = direction === 'has' ? this._has : this._in;
385        if (!map.has(relationId)) map.set(relationId, new Set());
386        if (action === PatchAction.Add) {
387            map.get(relationId)!.add(targetId)
388        } else {
389            map.get(relationId)!.delete(targetId)
390        }
391        dbReactivity.trigger(this)
392    }
393
394    /**
395     * Bulk-restores the inverse (`in`) edges implied by this record's forward
396     * (`has`) relations onto their target records. The sidecar serializes only
397     * the `has` direction (ยง3.7.1); `mergeExtended` fills `_has`, but the inverse
398     * side (`Person.authorOf`, `Tag.inTags`, โ€ฆ) stays empty until this runs.
399     *
400     * No patch emitted, no reactivity fired (bulk boot path). Idempotent (Set
401     * add). Targets must already be materialized โ€” call after a full
402     * `hydrateAll` pass (see `rebuildInverseRelations`).
403     */
404    restoreInverses(): void {
405        for (const [relationId, targets] of this._has) {
406            const rel = this.schema.relations?.find(r => r.relationId === relationId)
407            if (!rel?.inverted) continue
408            const targetSchema = Registry.getByName(rel.targetType)
409            const invRel = targetSchema?.relations.find(r => r.name === rel.inverted)
410            if (!targetSchema || !invRel) continue
411            for (const targetId of targets) {
412                if (!hasRecord(targetSchema.objectId, targetId, this.creatorUsername)) continue
413                const target = getRecord(targetSchema.objectId, targetId, this.creatorUsername) as Orm
414                let set = target._in.get(invRel.relationId)
415                if (!set) { set = new Set(); target._in.set(invRel.relationId, set) }
416                set.add(this._id)
417            }
418        }
419    }
420
421    /**
422     * Applies a V2 patch to the in-memory state. Triggers reactivity.
423     */
424    patch(patch: AnyPatch, _isMine = true) {
425        track(patch)
426        switch (patch.operation) {
427            case PatchOperation.EditPropertyString: {
428                const p = patch as EditPropertyStringObj
429                if (!this._stringValues.has(p.propertyId)) {
430                    this._stringValues.set(p.propertyId, new Map())
431                }
432                this._stringValues.get(p.propertyId)!.set(this.langOf(p.langId), p.value)
433                break;
434            }
435            case PatchOperation.EditPropertyNumber: {
436                const p = patch as EditPropertyNumberObj
437                if (p.propertyId === LIFECYCLE_STATUS_PROP) {
438                    const statusStr = p.value === LifecycleStatus.Submitted ? 'submitted' : 
439                                      p.value === LifecycleStatus.CommunityPending ? 'community_pending' : 
440                                      p.value === LifecycleStatus.Canonical ? 'canonical' : 'active';
441                    
442                    localRecordTable.setStatus(this.creatorUsername, this.id, statusStr).catch(err => {
443                        if (!err.message?.includes('not found')) {
444                            console.error('[Orm] Failed to update LocalRecordTable status:', err);
445                        }
446                    });
447                    if (p.value === LifecycleStatus.Canonical) {
448                        unpinRecord(this.objectType, this.id, this.creatorUsername);
449                    }
450                }
451                const lang = this.langOf(p.langId)
452                if (this.getPropType(p.propertyId) === 'boolean') {
453                    if (!this._boolValues.has(p.propertyId)) {
454                        this._boolValues.set(p.propertyId, new Map())
455                    }
456                    this._boolValues.get(p.propertyId)!.set(lang, !!p.value)
457                } else {
458                    if (!this._numberValues.has(p.propertyId)) {
459                        this._numberValues.set(p.propertyId, new Map())
460                    }
461                    this._numberValues.get(p.propertyId)!.set(lang, p.value)
462                }
463                break;
464            }
465            case PatchOperation.EditInstanceFieldString:
466            case PatchOperation.EditInstanceFieldNumber: {
467                const p = patch as EditInstanceFieldStringObj | EditInstanceFieldNumberObj
468                this.applyInstancePatch(p)
469                break;
470            }
471            case PatchOperation.EditRelation: {
472                const p = patch as EditRelationObj
473                const rel = this.schema.relations?.find(r => r.relationId === p.relationId)
474                // Schema-driven store selection; heuristic fallback for
475                // relations unknown to the schema (forward compatibility).
476                const direction = rel?.direction
477                    ?? (this._in.has(p.relationId) && !this._has.has(p.relationId) ? 'in' : 'has')
478                this.applyRelationRaw(direction, p.relationId, p.action, p.targetId)
479
480                // Generic in-memory inverse propagation: a single canonical
481                // patch keeps both sides of the edge coherent. Applies to
482                // live edits AND blockchain replays. No second patch is
483                // emitted โ€” the wire carries one EditRelation per edge.
484                if (rel?.inverted) {
485                    const targetSchema = Registry.getByName(rel.targetType)
486                    const invRel = targetSchema?.relations.find(r => r.name === rel.inverted)
487                    if (targetSchema && invRel && hasRecord(targetSchema.objectId, p.targetId, this.creatorUsername)) {
488                        const target = getRecord(targetSchema.objectId, p.targetId, this.creatorUsername) as Orm
489                        target.applyRelationRaw(invRel.direction, invRel.relationId, p.action, this._id)
490                    }
491                }
492                break;
493            }
494            case PatchOperation.SetAlias:
495                // Aliasing is resolved at compile time via the FORWARDING_TABLE;
496                // nothing to apply on a live object.
497                break;
498            case PatchOperation.AddInstance: {
499                const p = patch as AddInstanceObj;
500                const lang = this.langOf(p.langId);
501                let arr = this._instances.get(lang);
502                if (!arr) {
503                    arr = [];
504                    this._instances.set(lang, arr);
505                }
506                while (arr.length <= p.instanceIndex) arr.push({});
507                break;
508            }
509            case PatchOperation.CreateRecord:
510                // CreateRecord establishes existence; handled outside object mutation.
511                break;
512            default:
513                throw new Error(`unhandled patch operation ${(patch as AnyPatch).operation}`)
514        }
515        dbReactivity.trigger(this)
516    }
517
518    protected applyInstancePatch(p: EditInstanceFieldStringObj | EditInstanceFieldNumberObj) {
519        const lang = this.langOf(p.langId)
520        let arr = this._instances.get(lang)
521        if (!arr) {
522            arr = []
523            this._instances.set(lang, arr)
524        }
525        while (arr.length <= p.instanceIndex) arr.push({})
526        const field = this.schema.instanceFields?.find(f => f.propId === p.propertyId)
527        const name = field?.name ?? `field${p.propertyId}`
528        let value: string | number | boolean = p.value
529        if (field?.type === 'boolean' && typeof value === 'number') {
530            value = !!value
531        }
532        arr[p.instanceIndex][name] = value
533    }
534
535    // โ”€โ”€โ”€ Typed property access โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
536
537    protected getPropType(propId: number): 'string' | 'number' | 'boolean' {
538        switch (propId) {
539            case TITLE_PROP:
540            case DESCRIPTION_PROP:
541            case ORIGINAL_LANG_PROP:
542            case PREVIEW_TX_ID_PROP:
543                return "string"
544            case AGE_GRADE_BAND_PROP:
545        case LIFECYCLE_STATUS_PROP:
546                return "number"
547            case SAFE_PROP:
548            case DELETED_PROP:
549                return "boolean"
550        }
551        const propDefinition = this.schema.props.find(prop => prop.propId === propId)
552        if (typeof propDefinition === 'undefined') {
553            throw new Error(`unknown prop ${propId} in schema ${this.schema.name}`)
554        }
555        // enums are stored as numbers
556        return propDefinition.type === 'enum' ? 'number' : propDefinition.type
557    }
558
559    getProp(propId: number, lang?: Lang): any {
560        dbReactivity.track(this)
561        const l = lang ?? Config.userLang
562        switch (this.getPropType(propId)) {
563            case 'string':
564                return resolveLangValue(this._stringValues.get(propId), l) ?? ""
565            case 'number':
566                return resolveLangValue(this._numberValues.get(propId), l)
567            case 'boolean':
568                return resolveLangValue(this._boolValues.get(propId), l)
569        }
570    }
571
572    /** Direct state write โ€” no patch emitted (used by merge methods). */
573    protected setRaw(propId: number, value: any, lang: Lang = LANG_AGNOSTIC) {
574        switch (this.getPropType(propId)) {
575            case 'string': {
576                if (!this._stringValues.has(propId)) this._stringValues.set(propId, new Map())
577                this._stringValues.get(propId)!.set(lang, value)
578                break;
579            }
580            case 'number': {
581                if (!this._numberValues.has(propId)) this._numberValues.set(propId, new Map())
582                this._numberValues.get(propId)!.set(lang, value)
583                break;
584            }
585            case 'boolean': {
586                if (!this._boolValues.has(propId)) this._boolValues.set(propId, new Map())
587                this._boolValues.get(propId)!.set(lang, !!value)
588                break;
589            }
590        }
591    }
592
593    // โ”€โ”€โ”€ Instances โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
594
595    getInstancesData(lang?: Lang): InstanceData[] {
596        dbReactivity.track(this)
597        return this._instances.get(lang ?? Config.userLang) ?? []
598    }
599
600    getDefaultInstanceData(lang?: Lang): InstanceData | undefined {
601        const all = this.getInstancesData(lang)
602        return all.find(i => i['isDefault'] === true) ?? all[0]
603    }
604
605    /** Direct state write โ€” no patch emitted (used by mergeExtended/mergeSlim). */
606    setInstancesRaw(lang: Lang, instances: InstanceData[]) {
607        this._instances.set(lang, instances)
608    }
609
610    growInstances(lang: Lang, toIndex: number) {
611        let arr = this._instances.get(lang);
612        if (!arr) {
613            arr = [];
614            this._instances.set(lang, arr);
615        }
616        while (arr.length <= toIndex) {
617            arr.push({} as InstanceData);
618        }
619    }
620
621    addInstance(lang?: Lang): number {
622        if (!this.schema.multipleInstances) {
623            throw new Error(`Model ${this.constructor.name} does not support multiple instances.`);
624        }
625        const targetLang = lang ?? Config.userLang;
626        const instances = this.getInstancesData(targetLang);
627        const index = instances.length;
628        this.growInstances(targetLang, index);
629
630        const op: AddInstanceObj = {
631            operation: PatchOperation.AddInstance,
632            objectType: this.objectType,
633            objectId: this.id,
634            langId: this.langIdOf(targetLang),
635            instanceIndex: index,
636        };
637        this.patch(op);
638        PatchInstance.addPatch(op);
639        return index;
640    }
641
642    // โ”€โ”€โ”€ Provenance โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
643
644    getProvenance(propId: number, lang?: Lang): number {
645        dbReactivity.track(this)
646        return this._provenance.get(lang ?? Config.userLang)?.get(propId) ?? 0
647    }
648
649    setProvenanceRaw(lang: Lang, propId: number, value: number) {
650        if (!this._provenance.has(lang)) this._provenance.set(lang, new Map())
651        this._provenance.get(lang)!.set(propId, value)
652    }
653
654    // โ”€โ”€โ”€ Generic Merge โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
655
656    mergeSlim(cols: any, index: number, lang: Lang = Config.userLang) {
657        // Base props
658        this.setRaw(0, cols.titles?.[index] ?? "", lang);
659        this.setRaw(2, cols.originalLangs?.[index] ?? "", lang);
660        this.setRaw(4, cols.previewTxIds?.[index] ?? "", lang);
661        this.setRaw(3, cols.ageGradeBands?.[index], lang);
662        this.setRaw(5, cols.safes?.[index] === 1, lang);
663        this.setRaw(6, cols.deleteds?.[index] === 1, lang);
664
665        for (const prop of this.schema.props) {
666            if (prop.source !== 'slim') continue;
667            const name = colName(prop.name, prop.type);
668            const val = cols[name]?.[index];
669            if (prop.type === 'string') {
670                this.setRaw(prop.propId, val ?? "", lang);
671            } else if (prop.type === 'boolean') {
672                this.setRaw(prop.propId, val === 1, lang);
673            } else {
674                this.setRaw(prop.propId, val, lang);
675            }
676        }
677
678        if (this.schema.multipleInstances) {
679            const firstStrField = this.schema.instanceFields?.find((f: any) => f.type === 'string');
680            let hasDefault = true;
681            if (firstStrField) {
682                const name = `default${colName(capitalize(firstStrField.name), firstStrField.type)}`;
683                if (cols[name]?.[index] == null) {
684                    hasDefault = false;
685                }
686            }
687
688            if (hasDefault && this.getInstancesData(lang).length === 0) {
689                const inst: any = { isDefault: true };
690                for (const field of this.schema.instanceFields || []) {
691                    const name = `default${colName(capitalize(field.name), field.type)}`;
692                    const val = cols[name]?.[index];
693                    if (field.type === 'boolean') {
694                        inst[field.name] = val === 1;
695                    } else {
696                        inst[field.name] = val ?? null;
697                    }
698                }
699                this.setInstancesRaw(lang, [inst as InstanceData]);
700            }
701        }
702    }
703
704    mergeExtended(cols: any, index: number, lang: Lang = Config.userLang) {
705        // Base props
706        this.setRaw(1, cols.descriptions?.[index] ?? "", lang);
707
708        for (const prop of this.schema.props) {
709            if (prop.source !== 'extended') continue;
710            const name = colName(prop.name, prop.type);
711            const val = cols[name]?.[index];
712            if (prop.type === 'string') {
713                this.setRaw(prop.propId, val ?? "", lang);
714            } else if (prop.type === 'boolean') {
715                this.setRaw(prop.propId, val === 1, lang);
716            } else {
717                this.setRaw(prop.propId, val, lang);
718            }
719        }
720
721        for (const rel of this.schema.relations || []) {
722            if (rel.direction !== 'has') continue;
723            const field = relationColName(rel);
724            if (cols[field]) {
725                const set = this._has.get(rel.relationId)!;
726                for (const targetId of cols[field][index] ?? []) {
727                    set.add(targetId);
728                }
729            }
730        }
731
732        if (this.schema.multipleInstances) {
733            if (cols.instances) {
734                this.setInstancesRaw(lang, cols.instances[index] ?? []);
735            }
736        }
737    }
738
739    mergeProv(cols: any, index: number, lang: Lang = Config.userLang) {
740        const BASE_PROP_IDS: Record<string, number> = {
741            title: 0, description: 1, originalLang: 2, ageGradeBand: 3, 
742            previewTxId: 4, safe: 5, deleted: 6, lifecycleStatus: 7
743        };
744        for (const [name, arr] of Object.entries(cols.fields || {})) {
745            let propId = BASE_PROP_IDS[name];
746            if (propId === undefined) {
747                const prop = this.schema.props.find((p: any) => p.name === name);
748                if (prop) {
749                    propId = prop.propId;
750                } else if (this.schema.instanceFields) {
751                    const field = this.schema.instanceFields.find((f: any) => f.name === name);
752                    if (field) propId = field.propId;
753                }
754            }
755            if (propId !== undefined) {
756                this.setProvenanceRaw(lang, propId, (arr as any)[index]);
757            }
758        }
759    }
760}
761