@carbonenginejs/runtime-utils 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -44,6 +44,49 @@ the ordinary settled event. Callers that need changed names use the
44
44
  may emit an immediate payload containing `properties`; that is not the normal
45
45
  settled-event shape.
46
46
 
47
+ ## Schema-backed child collections
48
+
49
+ Domain containers expose named methods such as `CreateAttachment`,
50
+ `AddAttachment`, `RemoveAttachment`, and `DeleteAttachment`. Those methods may
51
+ delegate to the programmatic static helpers `CjsModel.createChild(owner, ...)`,
52
+ `addChild`, `removeChild`, `deleteChild`, and `clearChildren`. Model instances do
53
+ not inherit generic property-string child methods; the named methods explicitly
54
+ defined by their domain class are their child-mutation API.
55
+
56
+ The helpers accept only schema `array` and `list` fields backed by ordinary
57
+ JavaScript arrays. They do not operate on typed arrays, maps, sets, or
58
+ undeclared properties. `createChild` hydrates one value using the collection's
59
+ declared item type before adding it.
60
+
61
+ A collection mutation follows the same state rules as `SetValues`:
62
+
63
+ - it marks the parent dirty unless `markDirty: false`;
64
+ - it adds that collection field's declared `@io.flag(...)` and
65
+ `@io.rebuild(...)` tokens unless `notify: false`;
66
+ - it settles the parent unless `skipUpdate: true`; and
67
+ - it suppresses child and modified events when `skipEvents: true`.
68
+
69
+ When the parent implements Carbon-shaped `OnListModified`, insertion and
70
+ removal callbacks receive the mutated list. Clearing sends unload-start while
71
+ the list is still populated, then empties it. Generic `childadded`,
72
+ `childremoved`, `childdeleted`, and `childrencleared` events carry the property
73
+ and affected child or count; named wrappers may also supply `onAdded`,
74
+ `onRemoved`, `onDeleted`, or `onCleared` callbacks.
75
+
76
+ Remove only detaches. Delete also emits the deletion event and may run an
77
+ explicit domain-owned `delete` callback; it never guesses a generic `Destroy`
78
+ operation. JavaScript lifetime management remains ordinary garbage collection
79
+ when no teardown callback is supplied.
80
+
81
+ Child-owned flags and rebuild tokens are deliberately not interpreted by these
82
+ helpers. A child property may declare a token such as a deferred deletion
83
+ request, but the current runtime context decides whether and when to consume
84
+ it and which named parent method to call. That context must retain the exact
85
+ relationship it owns: the same child may be reached through multiple parents,
86
+ properties, or nested contexts, and graph traversal does not make those
87
+ contexts interchangeable. No global deletion queue or child-management
88
+ decorator is implied.
89
+
47
90
  ## Initialization
48
91
 
49
92
  `CjsModel.from()` constructs or imports the graph, resolves references, then
@@ -187,6 +187,18 @@ They return the same changed-set, boolean, or `false` result as `SetValues`.
187
187
  `Copy`/`copy` instead require an instantiated
188
188
  `CjsModel` source and forward the supplied `SetValues` options.
189
189
 
190
+ Schema-backed containers expose domain-named child methods and delegate their
191
+ ordinary `array` or `list` mutations to the programmatic static
192
+ `CjsModel.createChild(owner, ...)`, `addChild`, `removeChild`, `deleteChild`, and
193
+ `clearChildren` helpers. Instances expose only child methods explicitly defined
194
+ by their domain class. The static helpers hydrate declared item types, preserve
195
+ Carbon `OnListModified` notifications, and apply the collection field's normal
196
+ flag/rebuild tokens. They do not manage typed arrays or interpret tokens owned
197
+ by a child; the active domain context remains responsible for consuming child
198
+ work requests.
199
+ See [Model lifecycle](../concepts/model-lifecycle.md#schema-backed-child-collections)
200
+ for removal, deletion, event, and nested-context rules.
201
+
190
202
  ### Enum-backed fields
191
203
 
192
204
  Enum metadata resolves lazily from the concrete model constructor's PascalCase
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carbonenginejs/runtime-utils",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Browser-safe shared utilities, math, constants, Carbon types, schemas, documents, and runtime model primitives.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -4,7 +4,13 @@ import { getRuntimeState } from "../runtime/CjsRuntimeState.js";
4
4
  import { CjsModelState } from "./CjsModelState.js";
5
5
  import { CjsEventEmitter } from "./CjsEventEmitter.js";
6
6
 
7
- const MAX_UPDATE_PASSES = 32;
7
+ const MAX_UPDATE_PASSES = 32;
8
+ const CHILD_COLLECTION_KINDS = new Set([ "array", "list" ]);
9
+ const CHILD_LIST_EVENT = {
10
+ UNLOAD_START: 0x07,
11
+ INSERTED: 0x08,
12
+ REMOVED: 0x09
13
+ };
8
14
 
9
15
  /**
10
16
  * Shared base for schema-backed CarbonEngineJS runtime classes.
@@ -92,13 +98,180 @@ export class CjsModel extends CjsEventEmitter
92
98
  * @param {object} [options={}]
93
99
  * @returns {Set<string>|boolean} The result returned by {@link CjsModel.set}.
94
100
  */
95
- Merge(values = [], options = {})
96
- {
97
- return CjsModel.merge(this, values, options);
98
- }
99
-
100
- /**
101
- * Applies pending changes: drives the OnModified hook until the model
101
+ Merge(values = [], options = {})
102
+ {
103
+ return CjsModel.merge(this, values, options);
104
+ }
105
+
106
+ /**
107
+ * Constructs one item from a schema-backed child collection's declared
108
+ * item type, then adds it through the ordinary child-mutation path.
109
+ *
110
+ * Domain classes expose named factories such as `CreateAttachment`; this
111
+ * programmatic helper keeps property-string mutation out of their instance
112
+ * API.
113
+ *
114
+ * @param {CjsModel} target Owning model instance.
115
+ * @param {string} property Schema `array` or `list` field name.
116
+ * @param {object} [values={}] Plain child values.
117
+ * @param {object} [options={}] Hydration and mutation options.
118
+ * @returns {*} The constructed and added child.
119
+ */
120
+ static createChild(target, property, values = {}, options = {})
121
+ {
122
+ const { field } = getChildCollection(target, property);
123
+ const imported = importSourceValue([ values ], field, {
124
+ ...options,
125
+ ownerConstructor: target.constructor
126
+ });
127
+ const child = imported[0];
128
+
129
+ assertChildObject(child, field.name);
130
+ CjsModel.addChild(target, field.name, child, options);
131
+ return child;
132
+ }
133
+
134
+ /**
135
+ * Appends an existing object to a schema-backed child collection.
136
+ *
137
+ * The mutation invokes Carbon-shaped `OnListModified` when present,
138
+ * records the field's declared flag/rebuild consequences, emits one
139
+ * `childadded` event, and settles the parent unless suppressed by options.
140
+ *
141
+ * @param {CjsModel} target Owning model instance.
142
+ * @param {string} property Schema `array` or `list` field name.
143
+ * @param {object} child Existing child object.
144
+ * @param {object} [options={}]
145
+ * @returns {object} The appended child.
146
+ */
147
+ static addChild(target, property, child, options = {})
148
+ {
149
+ const { field, collection } = getChildCollection(target, property);
150
+
151
+ assertChildObject(child, field.name);
152
+ assertChildCallback(options.onAdded, "onAdded");
153
+
154
+ const index = collection.length;
155
+ collection.push(child);
156
+ recordChildMutation(target, field, options);
157
+ notifyListModified(target, CHILD_LIST_EVENT.INSERTED, index, 0, child, collection);
158
+
159
+ const payload = createChildEventPayload(target, field.name, child, index, options);
160
+ invokeChildCallback(options.onAdded, target, payload, "onAdded");
161
+ emitChildEvent(target, "childadded", payload, options);
162
+ settleChildMutation(target, field, options);
163
+ return child;
164
+ }
165
+
166
+ /**
167
+ * Detaches the first matching object from a schema-backed child collection.
168
+ * Removal never destroys the child.
169
+ *
170
+ * @param {CjsModel} target Owning model instance.
171
+ * @param {string} property Schema `array` or `list` field name.
172
+ * @param {object} child Existing child object.
173
+ * @param {object} [options={}]
174
+ * @returns {boolean} Whether the child was present and removed.
175
+ */
176
+ static removeChild(target, property, child, options = {})
177
+ {
178
+ const { field, collection } = getChildCollection(target, property);
179
+ const index = collection.indexOf(child);
180
+
181
+ if (index === -1) return false;
182
+ assertChildCallback(options.onRemoved, "onRemoved");
183
+
184
+ collection.splice(index, 1);
185
+ recordChildMutation(target, field, options);
186
+ notifyListModified(target, CHILD_LIST_EVENT.REMOVED, index, 0, child, collection);
187
+
188
+ const payload = createChildEventPayload(target, field.name, child, index, options);
189
+ invokeChildCallback(options.onRemoved, target, payload, "onRemoved");
190
+ emitChildEvent(target, "childremoved", payload, options);
191
+ settleChildMutation(target, field, options);
192
+ return true;
193
+ }
194
+
195
+ /**
196
+ * Removes a child and then performs an explicit deletion action.
197
+ *
198
+ * `options.delete` owns domain-specific teardown when supplied. Without
199
+ * that explicit hook the child is detached and left to ordinary
200
+ * JavaScript lifetime management. Deletion emits both `childremoved` and
201
+ * `childdeleted`.
202
+ *
203
+ * @param {CjsModel} target Owning model instance.
204
+ * @param {string} property Schema `array` or `list` field name.
205
+ * @param {object} child Existing child object.
206
+ * @param {object} [options={}]
207
+ * @param {Function} [options.delete] Explicit child teardown callback.
208
+ * @returns {boolean} Whether the child was present and deleted.
209
+ */
210
+ static deleteChild(target, property, child, options = {})
211
+ {
212
+ const { field, collection } = getChildCollection(target, property);
213
+ const index = collection.indexOf(child);
214
+
215
+ if (index === -1) return false;
216
+
217
+ if (options.delete !== undefined && typeof options.delete !== "function")
218
+ {
219
+ throw new TypeError("CjsModel child delete option must be a function.");
220
+ }
221
+ assertChildCallback(options.onDeleted, "onDeleted");
222
+
223
+ CjsModel.removeChild(target, field.name, child, { ...options, skipUpdate: true });
224
+
225
+ if (typeof options.delete === "function")
226
+ {
227
+ options.delete.call(target, child, options);
228
+ }
229
+
230
+ const payload = createChildEventPayload(target, field.name, child, index, options);
231
+ invokeChildCallback(options.onDeleted, target, payload, "onDeleted");
232
+ emitChildEvent(target, "childdeleted", payload, options);
233
+ settleChildMutation(target, field, options);
234
+ return true;
235
+ }
236
+
237
+ /**
238
+ * Detaches every object from a schema-backed child collection without
239
+ * destroying the children.
240
+ *
241
+ * Carbon-shaped `OnListModified` receives its unload-start callback while
242
+ * the collection is still populated. The public domain method decides
243
+ * whether clearing or per-child deletion is appropriate.
244
+ *
245
+ * @param {CjsModel} target Owning model instance.
246
+ * @param {string} property Schema `array` or `list` field name.
247
+ * @param {object} [options={}]
248
+ * @returns {boolean} Whether any children were cleared.
249
+ */
250
+ static clearChildren(target, property, options = {})
251
+ {
252
+ const { field, collection } = getChildCollection(target, property);
253
+ const count = collection.length;
254
+
255
+ if (!count) return false;
256
+ assertChildCallback(options.onCleared, "onCleared");
257
+
258
+ recordChildMutation(target, field, options);
259
+ notifyListModified(target, CHILD_LIST_EVENT.UNLOAD_START, 0, 0, null, collection);
260
+ collection.length = 0;
261
+
262
+ const payload = {
263
+ property: field.name,
264
+ count,
265
+ source: options.source ?? target
266
+ };
267
+ invokeChildCallback(options.onCleared, target, payload, "onCleared");
268
+ emitChildEvent(target, "childrencleared", payload, options);
269
+ settleChildMutation(target, field, options);
270
+ return true;
271
+ }
272
+
273
+ /**
274
+ * Applies pending changes: drives the OnModified hook until the model
102
275
  * settles, clears the dirty mark, and emits one final modified event.
103
276
  *
104
277
  * Calling this IS the "I made changes, apply please" contract: it always
@@ -862,15 +1035,123 @@ function findIncomingKey(values, field)
862
1035
  return null;
863
1036
  }
864
1037
 
865
- function incomingKeyCandidates(field)
866
- {
1038
+ function incomingKeyCandidates(field)
1039
+ {
867
1040
  const aliases = field.aliases === undefined
868
1041
  ? field.alias === undefined ? [] : [field.alias]
869
1042
  : Array.isArray(field.aliases) ? field.aliases : [field.aliases];
870
- return [field.name, ...aliases].filter(value => typeof value === "string" && value.length);
871
- }
872
-
873
- // Adds one field's declared @io.flag / @io.rebuild tokens to their stores.
1043
+ return [field.name, ...aliases].filter(value => typeof value === "string" && value.length);
1044
+ }
1045
+
1046
+ function getChildCollection(target, property)
1047
+ {
1048
+ if (!(target instanceof CjsModel))
1049
+ {
1050
+ throw new TypeError("CjsModel child collection target must be a CjsModel instance.");
1051
+ }
1052
+
1053
+ if (typeof property !== "string" || !property)
1054
+ {
1055
+ throw new TypeError("CjsModel child collection property must be a non-empty string.");
1056
+ }
1057
+
1058
+ const field = CjsSchema.getField(target.constructor, property);
1059
+ if (!field)
1060
+ {
1061
+ throw new TypeError(`${CjsSchema.getClassName(target.constructor)} has no schema field named ${JSON.stringify(property)}.`);
1062
+ }
1063
+
1064
+ const fieldType = field.type || field.jsType;
1065
+ if (!CHILD_COLLECTION_KINDS.has(fieldType?.kind))
1066
+ {
1067
+ throw new TypeError(`${field.name} must be a schema array or list child collection.`);
1068
+ }
1069
+
1070
+ const collection = target[field.name];
1071
+ if (!Array.isArray(collection))
1072
+ {
1073
+ throw new TypeError(`${field.name} must contain an ordinary JavaScript Array.`);
1074
+ }
1075
+
1076
+ return { field, collection };
1077
+ }
1078
+
1079
+ function assertChildObject(child, property)
1080
+ {
1081
+ if (!child || typeof child !== "object" || Array.isArray(child) || ArrayBuffer.isView(child))
1082
+ {
1083
+ throw new TypeError(`${property} requires a non-null child object.`);
1084
+ }
1085
+ }
1086
+
1087
+ function assertChildCallback(callback, optionName)
1088
+ {
1089
+ if (callback !== undefined && callback !== null && typeof callback !== "function")
1090
+ {
1091
+ throw new TypeError(`CjsModel child ${optionName} option must be a function.`);
1092
+ }
1093
+ }
1094
+
1095
+ function recordChildMutation(target, field, options)
1096
+ {
1097
+ if (options.markDirty === false) return;
1098
+ target.__state.dirty = true;
1099
+ if (options.notify !== false) addDeclaredFieldTokens(target, field);
1100
+ }
1101
+
1102
+ function notifyListModified(target, event, index, secondIndex, child, collection)
1103
+ {
1104
+ if (typeof target.OnListModified === "function")
1105
+ {
1106
+ target.OnListModified(event, index, secondIndex, child, collection);
1107
+ }
1108
+ }
1109
+
1110
+ function createChildEventPayload(target, property, child, index, options)
1111
+ {
1112
+ return {
1113
+ property,
1114
+ child,
1115
+ index,
1116
+ source: options.source ?? target
1117
+ };
1118
+ }
1119
+
1120
+ function invokeChildCallback(callback, target, payload, optionName)
1121
+ {
1122
+ if (callback === undefined || callback === null) return;
1123
+ assertChildCallback(callback, optionName);
1124
+ callback.call(target, payload);
1125
+ }
1126
+
1127
+ function emitChildEvent(target, eventName, payload, options)
1128
+ {
1129
+ if (options.skipEvents !== true && target.__state.suppressEvents === 0)
1130
+ {
1131
+ target.EmitEvent(eventName, target, payload);
1132
+ }
1133
+ }
1134
+
1135
+ function settleChildMutation(target, field, options)
1136
+ {
1137
+ if (options.skipUpdate === true) return;
1138
+
1139
+ if (options.markDirty === false)
1140
+ {
1141
+ if (options.skipEvents !== true && target.__state.suppressEvents === 0)
1142
+ {
1143
+ target.EmitEvent("modified", target, createModifiedPayload(
1144
+ new Set([ field.name ]),
1145
+ options.source ?? target
1146
+ ));
1147
+ }
1148
+ return;
1149
+ }
1150
+
1151
+ if (!target.__state.updating) target.UpdateValues(options);
1152
+ }
1153
+
1154
+ // Adds one field's declared @io.flag / @io.rebuild tokens to their stores.
874
1155
  // Duplicate adds are no-ops (Sets). Nothing in the model layer ever clears
875
1156
  // these stores - getters clear flags, work methods clear rebuild tokens.
876
1157
  function addDeclaredFieldTokens(target, field)