๐ 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