@colyseus/schema 5.0.14 → 5.0.19

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.
Files changed (47) hide show
  1. package/README.md +7 -2
  2. package/build/Metadata.d.ts +10 -1
  3. package/build/codegen/api.d.ts +2 -0
  4. package/build/codegen/cli.cjs +322 -31
  5. package/build/codegen/cli.cjs.map +1 -1
  6. package/build/codegen/parser.d.ts +6 -1
  7. package/build/codegen/resolve.d.ts +25 -0
  8. package/build/codegen/types.d.ts +2 -0
  9. package/build/encoder/ChangeTree.d.ts +40 -12
  10. package/build/encoder/Encoder.d.ts +1 -1
  11. package/build/encoder/Root.d.ts +9 -0
  12. package/build/encoder/StateView.d.ts +38 -1
  13. package/build/encoder/changeTree/inheritedFlags.d.ts +13 -19
  14. package/build/encoder/changeTree/liveIteration.d.ts +8 -0
  15. package/build/encoder/changeTree/parentChain.d.ts +30 -8
  16. package/build/encoder/streaming.d.ts +1 -1
  17. package/build/index.cjs +3232 -2830
  18. package/build/index.cjs.map +1 -1
  19. package/build/index.js +3228 -2826
  20. package/build/index.mjs +3232 -2830
  21. package/build/index.mjs.map +1 -1
  22. package/build/types/TypeContext.d.ts +0 -17
  23. package/build/types/builder.d.ts +1 -5
  24. package/build/types/symbols.d.ts +1 -0
  25. package/package.json +1 -1
  26. package/src/Metadata.ts +59 -77
  27. package/src/Reflection.ts +9 -5
  28. package/src/annotations.ts +19 -13
  29. package/src/codegen/api.ts +3 -1
  30. package/src/codegen/cli.ts +5 -2
  31. package/src/codegen/parser.ts +69 -31
  32. package/src/codegen/resolve.ts +322 -0
  33. package/src/codegen/types.ts +4 -1
  34. package/src/decoder/DecodeOperation.ts +13 -2
  35. package/src/encoder/ChangeTree.ts +76 -25
  36. package/src/encoder/EncodeOperation.ts +10 -1
  37. package/src/encoder/Encoder.ts +52 -2
  38. package/src/encoder/Root.ts +28 -8
  39. package/src/encoder/StateView.ts +150 -66
  40. package/src/encoder/changeTree/inheritedFlags.ts +164 -45
  41. package/src/encoder/changeTree/liveIteration.ts +24 -3
  42. package/src/encoder/changeTree/parentChain.ts +72 -15
  43. package/src/encoder/streaming.ts +2 -1
  44. package/src/types/TypeContext.ts +5 -52
  45. package/src/types/builder.ts +14 -10
  46. package/src/types/custom/ArraySchema.ts +57 -14
  47. package/src/types/symbols.ts +3 -0
@@ -13,16 +13,17 @@ import {
13
13
  // (see INHERITABLE_FLAGS comment in ChangeTree.ts). Per-field unreliable
14
14
  // routing on primitive fields still uses it via `isFieldUnreliable()`.
15
15
  } from "../../types/symbols.js";
16
- import type { Schema } from "../../Schema.js";
17
16
  import {
18
- INHERITABLE_FLAGS, IS_FULL_STATE_ONLY, IS_PATCH_ONLY,
17
+ INHERITABLE_FLAGS, IS_FULL_STATE_ONLY, IS_PATCH_ONLY, PENDING_FILTER_REFRESH,
19
18
  // IS_UNRELIABLE — tree-level unreliable currently disabled; see
20
19
  // INHERITABLE_FLAGS comment in ChangeTree.ts.
21
20
  type ChangeTree, type Ref,
22
21
  } from "../ChangeTree.js";
23
22
  import type { ICollectionChangeRecorder } from "../ChangeRecorder.js";
24
- import type { Streamable } from "../Root.js";
23
+ import type { Root, Streamable } from "../Root.js";
25
24
  import { ensureStreamState } from "../streaming.js";
25
+ import { restageLiveCb } from "./liveIteration.js";
26
+ import { isEdgeLive } from "./parentChain.js";
26
27
 
27
28
  /**
28
29
  * Reconcile queue membership + inherited flags for a tree that just had
@@ -71,25 +72,13 @@ export function checkIsFiltered(
71
72
  * etc.) inherit these from the Schema field that holds them.
72
73
  *
73
74
  * The common case — fresh tree attached to a parent field that carries
74
- * none of the inheritable annotations — produces no flag change, no
75
- * queue update, and no `parentFiltered` hit. Two small structural
76
- * choices keep that case cheap without any precomputed descriptor
77
- * bitmask:
78
- *
79
- * 1) Flag inheritance is a single bitwise OR onto `tree.flags`. The
80
- * three per-annotation reads pack into `fieldBits`, the parent's
81
- * inherited bits come from `parentChangeTree.flags` directly; one
82
- * read-modify-write replaces three getter/setter cycles, and the
83
- * bit diff against `beforeFlags` gives us the "just became static /
84
- * unreliable" signal for the side-effect branches.
85
- *
86
- * 2) The `parentFiltered` string-key lookup is gated on
87
- * `types.hasParentFilteredEntries`, which is only flipped true when
88
- * `registerFilteredByParent` actually records an entry — i.e. when
89
- * some @view-tagged field reaches this (child, parent, index)
90
- * triple through the ancestry walk. Schemas with @view tags only on
91
- * sibling fields (not along any attachment chain) skip the string
92
- * concat + hash lookup entirely.
75
+ * none of the inheritable annotations — produces no flag change and no
76
+ * queue update. Flag inheritance is a single bitwise OR onto
77
+ * `tree.flags`: the per-annotation reads pack into `fieldBits`, the
78
+ * parent's inherited bits come from `parentChangeTree.flags` directly,
79
+ * and one read-modify-write replaces three getter/setter cycles. The bit
80
+ * diff against `beforeFlags` gives the "just became static / unreliable"
81
+ * signal for the side-effect branches.
93
82
  */
94
83
  export function checkInheritedFlags(tree: ChangeTree, parent: Ref, parentIndex: number): void {
95
84
  if (!parent) { return; }
@@ -97,15 +86,17 @@ export function checkInheritedFlags(tree: ChangeTree, parent: Ref, parentIndex:
97
86
  // Walk up a collection level so `parent` lands on the Schema that
98
87
  // owns the field at `parentIndex`. Field annotations live on Schema
99
88
  // metadata; collections have none.
100
- let parentChangeTree: ChangeTree = parent[$changes];
101
- const parentIsCollection = !Metadata.isValidInstance(parent);
89
+ const parentChangeTree: ChangeTree = parent[$changes];
90
+ const parentIsCollection = !parentChangeTree._isSchema;
91
+ let parentMetadata: any;
102
92
  if (parentIsCollection) {
103
93
  parent = parentChangeTree.parent;
104
94
  parentIndex = parentChangeTree.parentIndex;
95
+ parentMetadata = parent?.[$changes].metadata;
96
+ } else {
97
+ parentMetadata = parentChangeTree.metadata;
105
98
  }
106
99
 
107
- const parentMetadata: any = (parent as any)?.constructor?.[Symbol.metadata];
108
-
109
100
  // Flag inheritance — pack the patchOnly/static annotation checks into
110
101
  // flag bits alongside the parent's own transitive flags, then OR onto
111
102
  // `tree.flags` in one write. The bit diff tells us which flag just
@@ -159,21 +150,12 @@ export function checkInheritedFlags(tree: ChangeTree, parent: Ref, parentIndex:
159
150
  // pass is the only way elements become visible to a view.
160
151
  const fieldHasStream = parentMetadata?.[$streamFieldIndexes]?.includes(parentIndex) ?? false;
161
152
 
162
- // Skip the `parentFiltered` string-key lookup when no class has
163
- // actually registered filter inheritance via ancestry. The lookup
164
- // cannot hit in that state, so the string concat + hash lookup would
165
- // be wasted work every attach.
166
- let parentFiltered = false;
167
- const parentConstructor = (parent as any)?.constructor as typeof Schema | undefined;
168
- if (types.hasParentFilteredEntries && parentConstructor !== undefined) {
169
- const refType = Metadata.isValidInstance(tree.ref)
170
- ? tree.ref.constructor
171
- : (tree.ref as any)[$childType];
172
- const key = `${types.getTypeId(refType as typeof Schema)}-${types.schemas.get(parentConstructor)}-${parentIndex}`;
173
- parentFiltered = types.parentFiltered[key] ?? false;
174
- }
175
-
176
- const newFiltered = parentChangeTree.isFiltered || parentFiltered || fieldHasViewTag || fieldHasStream;
153
+ // Filtering is a property of the *attachment*, never of the child class:
154
+ // the same Schema class may sit under a @view field here and under a
155
+ // public field there (#204). `parentChangeTree.isFiltered` carries the
156
+ // ancestry `setRoot` derives it parent-first before recursing — so the
157
+ // field annotation only has to answer for this one edge.
158
+ const newFiltered = parentChangeTree.isFiltered || fieldHasViewTag || fieldHasStream;
177
159
  tree.isFiltered = newFiltered;
178
160
 
179
161
  // Flag collection trees attached to a `.stream()` field so the encoder
@@ -204,9 +186,7 @@ export function checkInheritedFlags(tree: ChangeTree, parent: Ref, parentIndex:
204
186
  }
205
187
 
206
188
  if (newFiltered) {
207
- const refType = Metadata.isValidInstance(tree.ref)
208
- ? tree.ref.constructor
209
- : (tree.ref as any)[$childType];
189
+ const sharesEligible = _sharesEligible(tree);
210
190
  // #218: nested Schema fields inherit visibility from a @view-gated
211
191
  // parent regardless of whether the parent is a collection. The
212
192
  // `parentIsCollection` constraint that used to live here blocked
@@ -223,9 +203,148 @@ export function checkInheritedFlags(tree: ChangeTree, parent: Ref, parentIndex:
223
203
  // are guaranteed to exist when that flag is set).
224
204
  tree.isVisibilitySharedWithParent = (
225
205
  parentChangeTree.isFiltered
226
- && typeof refType !== "string"
206
+ && sharesEligible
227
207
  && !fieldHasStream
228
208
  && (!fieldHasViewTag || (parentIsCollection && parentMetadata[parentIndex].tag !== DEFAULT_VIEW_TAG))
229
209
  );
230
210
  }
231
211
  }
212
+
213
+ // ────────────────────────────────────────────────────────────────────────
214
+ // Per-edge filter refresh — instance sharing across a @view boundary.
215
+ //
216
+ // `checkIsFiltered` classifies a tree from the edge it was FIRST attached
217
+ // through. A shared instance has N parent edges with different visibility,
218
+ // and the wire emits field data per-refId per-channel — so the tree-level
219
+ // invariant is:
220
+ //
221
+ // isFiltered ⇔ no fully-public root path reaches this tree
222
+ //
223
+ // Rather than reconciling eagerly at every attach/detach (whose ordering
224
+ // against the container's own storage mutation is fragile), edge events
225
+ // call `Root.enqueueFilterRefresh` and the encoder re-derives the flags at
226
+ // the top of the next encode — after every container mutation of the tick
227
+ // has settled — via `drainFilterRefresh`. `isFiltered` is only CONSUMED at
228
+ // encode time (recording is channel-agnostic), so the deferral is safe for
229
+ // wire routing; only same-tick StateView bootstrap reads see the stale
230
+ // flags, which at worst emits redundant (deduped) entries.
231
+ // ────────────────────────────────────────────────────────────────────────
232
+
233
+ /**
234
+ * Drain `root.pendingFilterRefresh`. Called by the encoder before any
235
+ * emission (per-tick channels and full-sync).
236
+ */
237
+ export function drainFilterRefresh(root: Root): void {
238
+ const list = root.pendingFilterRefresh;
239
+ for (let i = 0; i < list.length; i++) {
240
+ const tree = list[i];
241
+ // Already settled as another entry's parent, or detached/recycled
242
+ // since it was queued.
243
+ if ((tree.flags & PENDING_FILTER_REFRESH) === 0) continue;
244
+ refreshFilterState(tree);
245
+ }
246
+ list.length = 0;
247
+ }
248
+
249
+ /**
250
+ * Primitive-element collections never share visibility downward. One
251
+ * predicate for both derivations (`checkInheritedFlags` and
252
+ * `refreshFilterState`) — the InstanceSharing invariant test pins them
253
+ * together. `_isSchema` short-circuits the `$childType` probe for Schema
254
+ * trees (whose `$childType` is undefined and would pass anyway).
255
+ */
256
+ function _sharesEligible(tree: ChangeTree): boolean {
257
+ return tree._isSchema || typeof (tree.refTarget as any)[$childType] !== "string";
258
+ }
259
+
260
+ /**
261
+ * Re-derive `isFiltered` (AND over live edges) and
262
+ * `isVisibilitySharedWithParent` (OR over live edges) from the parent
263
+ * chain. On a filtered→public flip, live state is re-staged — it may have
264
+ * already drained to view channels only, and clients that hold it decode
265
+ * the duplicate ADDs as no-ops (StateView bootstrap re-adds rely on the
266
+ * same property). The public→filtered flip needs no re-stage: the public
267
+ * container's DELETE already ships on the shared channel.
268
+ *
269
+ * A flip cascades into children so classifications inherited through this
270
+ * tree follow it; re-derivation is idempotent and a child that does not
271
+ * flip does not recurse, so the walk terminates on cyclic instance graphs.
272
+ */
273
+ function refreshFilterState(tree: ChangeTree): void {
274
+ tree.flags &= ~PENDING_FILTER_REFRESH;
275
+ const root = tree.root;
276
+ if (root === undefined || tree.parentRef === undefined) return;
277
+
278
+ const sharesEligible = _sharesEligible(tree);
279
+
280
+ let bits = _edgeBits(tree, tree.parentRef, tree._parentIndex, sharesEligible);
281
+ // Saturated means no further edge can change the outcome.
282
+ for (let e = tree.extraParents; e !== undefined && bits !== EDGE_SATURATED; e = e.next) {
283
+ bits |= _edgeBits(tree, e.ref, e.index, sharesEligible);
284
+ }
285
+
286
+ // No live edge resolved (mid-detach churn) — keep the current
287
+ // classification rather than guess.
288
+ if (bits === 0) return;
289
+
290
+ tree.isVisibilitySharedWithParent = (bits & EDGE_SHARES) !== 0;
291
+
292
+ const newFiltered = (bits & EDGE_PUBLIC) === 0;
293
+ if (newFiltered === tree.isFiltered) return;
294
+ tree.isFiltered = newFiltered;
295
+
296
+ // Became public: clients that only ever had the view channel never saw
297
+ // this state. Static trees ship via structural walk instead.
298
+ if (!newFiltered && !tree.isFullStateOnly) {
299
+ tree.forEachLiveWithCtx(tree, restageLiveCb);
300
+ if (tree.has()) root.enqueueChangeTree(tree);
301
+ if (tree.unreliableRecorder?.has()) root.enqueueUnreliable(tree);
302
+ }
303
+
304
+ tree.forEachChildWithCtx(tree, _cascadeRefreshCb);
305
+ }
306
+
307
+ const EDGE_LIVE = 1, EDGE_PUBLIC = 2, EDGE_SHARES = 4;
308
+ const EDGE_SATURATED = EDGE_LIVE | EDGE_PUBLIC | EDGE_SHARES;
309
+
310
+ /**
311
+ * Classify one parent edge: is it live, does it make the tree publicly
312
+ * reachable, does view visibility flow through it.
313
+ */
314
+ function _edgeBits(tree: ChangeTree, parentRef: Ref, index: number, sharesEligible: boolean): number {
315
+ const parentTree: ChangeTree = parentRef[$changes];
316
+ if (parentTree.root !== tree.root || !isEdgeLive(tree, parentTree, index)) return 0;
317
+
318
+ // A queued parent must settle first — this edge reads its `isFiltered`.
319
+ // The flag-clear on entry terminates cycles, and a cascade re-entering
320
+ // `tree` is idempotent (same edges, same result — the outer pass then
321
+ // sees "no change").
322
+ if (parentTree.flags & PENDING_FILTER_REFRESH) refreshFilterState(parentTree);
323
+
324
+ let bits = EDGE_LIVE;
325
+ if (parentTree._isSchema) {
326
+ // A @view/stream-marked field stays filtered even under a public
327
+ // parent, and never shares visibility downward.
328
+ const marked = parentTree.encDescriptor.tags[index] !== undefined
329
+ || parentTree.isFieldStream(index);
330
+ if (!marked) {
331
+ if (!parentTree.isFiltered) bits |= EDGE_PUBLIC;
332
+ else if (sharesEligible) bits |= EDGE_SHARES;
333
+ }
334
+ } else if (!parentTree.isFiltered) {
335
+ // Collection edge: the collection's own classification already
336
+ // folds in the field that holds it.
337
+ bits |= EDGE_PUBLIC;
338
+ } else if (sharesEligible && !parentTree.isStreamCollection) {
339
+ // #226: default-tag @view() collections keep per-item gating;
340
+ // untagged and non-default-tag @view(N) ones share.
341
+ const gp = parentTree.parent?.[$changes];
342
+ const tag = gp?._isSchema ? gp.encDescriptor.tags[parentTree.parentIndex] : undefined;
343
+ if (tag !== DEFAULT_VIEW_TAG) bits |= EDGE_SHARES;
344
+ }
345
+ return bits;
346
+ }
347
+
348
+ const _cascadeRefreshCb = (_parentTree: ChangeTree, child: ChangeTree, _index: any): void => {
349
+ refreshFilterState(child);
350
+ };
@@ -6,10 +6,31 @@
6
6
  * Patch-only fields (`@patchOnly`) are skipped — they're delivered only on
7
7
  * tick patches and not persisted to snapshots. Collections whose parent
8
8
  * field is @patchOnly inherit the skip (`tree.isPatchOnly`).
9
+ *
10
+ * `@deprecated()` fields are skipped too: the decorator swaps the field's
11
+ * prototype accessor for a throwing getter, so `ref[name]` below would blow
12
+ * up full sync for the whole state. Both skips ride one decoration-time
13
+ * list (`$fullSyncSkipIndexes`) so the walk pays a single metadata lookup.
9
14
  */
10
- import { $childType, $numFields, $patchOnlyFieldIndexes } from "../../types/symbols.js";
15
+ import { OPERATION } from "../../encoding/spec.js";
16
+ import { $childType, $numFields, $fullSyncSkipIndexes } from "../../types/symbols.js";
11
17
  import type { ChangeTree } from "../ChangeTree.js";
12
18
 
19
+ /**
20
+ * Re-stage one live index as a fresh ADD on its channel. Shared by
21
+ * `Root.add` (refCount-0 / NEEDS_RESTAGE re-adds) and
22
+ * `inheritedFlags.refreshFilterState` (filtered→public flip) via
23
+ * `forEachLiveWithCtx(tree, restageLiveCb)` — one home for the
24
+ * unreliable-routing rule.
25
+ */
26
+ export const restageLiveCb = (tree: ChangeTree, fieldIndex: number): void => {
27
+ if (tree.isFieldUnreliable(fieldIndex)) {
28
+ tree.ensureUnreliableRecorder().record(fieldIndex, OPERATION.ADD);
29
+ } else {
30
+ tree.record(fieldIndex, OPERATION.ADD);
31
+ }
32
+ };
33
+
13
34
  // Adapter that lets `forEachLive(cb)` delegate to `forEachLiveWithCtx(cb, _invokeNoCtx)` —
14
35
  // keeps the no-ctx path closure-free and shares one walker implementation.
15
36
  const _invokeNoCtx = (cb: (index: number) => void, index: number) => cb(index);
@@ -65,12 +86,12 @@ export function forEachLiveWithCtx<C>(
65
86
  const metadata = tree.metadata;
66
87
  if (!metadata) return;
67
88
  const numFields = (metadata[$numFields] ?? -1) as number;
68
- const patchOnlyIndexes = metadata[$patchOnlyFieldIndexes];
89
+ const skipIndexes = metadata[$fullSyncSkipIndexes] as number[] | undefined;
69
90
  const names = tree.encDescriptor.names;
70
91
  for (let i = 0; i <= numFields; i++) {
71
92
  const name = names[i];
72
93
  if (name === undefined) continue;
73
- if (patchOnlyIndexes && patchOnlyIndexes.includes(i)) continue;
94
+ if (skipIndexes && skipIndexes.includes(i)) continue;
74
95
  const value = ref[name];
75
96
  if (value !== undefined && value !== null) cb(ctx, i);
76
97
  }
@@ -5,7 +5,7 @@
5
5
  * additional parents live in the `extraParents` linked list.
6
6
  */
7
7
  import { $changes } from "../../types/symbols.js";
8
- import type { ChangeTree, ParentChain, Ref } from "../ChangeTree.js";
8
+ import type { ChangeTree, ParentEntry, Ref } from "../ChangeTree.js";
9
9
 
10
10
  /**
11
11
  * Add a parent to the chain. If `parent` already exists anywhere in the
@@ -115,39 +115,69 @@ export function removeParent(tree: ChangeTree, parent: Ref): boolean {
115
115
  }
116
116
 
117
117
  /**
118
- * Find the first parent in the chain matching `predicate`.
118
+ * First parent matching `predicate`, as a detached `ParentEntry`. Never returns
119
+ * a live `ParentChain` node — the inline parent has no node to return in the
120
+ * first place, so handing out the real node for the `extraParents` case only
121
+ * would make writes land or vanish depending on which parent matched. Use
122
+ * `setParentIndex` to move an index and `indexInParent` to read one.
119
123
  */
120
124
  export function findParent(
121
125
  tree: ChangeTree,
122
126
  predicate: (parent: Ref, index: number) => boolean,
123
- ): ParentChain | undefined {
124
- // Check inline parent first
125
- if (tree.parentRef && predicate(tree.parentRef, tree._parentIndex)) {
127
+ ): ParentEntry | undefined {
128
+ if (tree.parentRef !== undefined && predicate(tree.parentRef, tree._parentIndex)) {
126
129
  return { ref: tree.parentRef, index: tree._parentIndex };
127
130
  }
128
-
129
- let current = tree.extraParents;
130
- while (current) {
131
- if (predicate(current.ref, current.index)) {
132
- return current;
131
+ for (let entry = tree.extraParents; entry !== undefined; entry = entry.next) {
132
+ if (predicate(entry.ref, entry.index)) {
133
+ return { ref: entry.ref, index: entry.index };
133
134
  }
134
- current = current.next;
135
135
  }
136
136
  return undefined;
137
137
  }
138
138
 
139
+ /** Walks in place — `addParent` calls this per shared-instance attach. */
139
140
  export function hasParent(
140
141
  tree: ChangeTree,
141
142
  predicate: (parent: Ref, index: number) => boolean,
142
143
  ): boolean {
143
- return findParent(tree, predicate) !== undefined;
144
+ if (tree.parentRef !== undefined && predicate(tree.parentRef, tree._parentIndex)) {
145
+ return true;
146
+ }
147
+ for (let entry = tree.extraParents; entry !== undefined; entry = entry.next) {
148
+ if (predicate(entry.ref, entry.index)) { return true; }
149
+ }
150
+ return false;
144
151
  }
145
152
 
146
153
  /**
147
- * Return all parents as an array (debug/test helper).
154
+ * Wire index `tree` holds inside `parent`, or undefined when `parent` is
155
+ * nowhere in the chain. Allocation-free variant of `findParent` for the
156
+ * encodeView drain, which resolves identity-keyed view entries per emission.
157
+ *
158
+ * A child detached from `parent` this tick usually still resolves: Root.remove
159
+ * leaves the child's own parent link dangling, and the staged snapshot keeps
160
+ * the child in `tmpItems` (so reindexes keep the index current) until
161
+ * `$onEncodeEnd` — which runs after the drain.
148
162
  */
149
- export function getAllParents(tree: ChangeTree): Array<{ ref: Ref, index: number }> {
150
- const parents: Array<{ ref: Ref, index: number }> = [];
163
+ export function indexInParent(tree: ChangeTree, parent: Ref): number | undefined {
164
+ // `$changes` comparison ArraySchema parents arrive proxied.
165
+ if (tree.parentRef && tree.parentRef[$changes] === parent[$changes]) {
166
+ return tree._parentIndex;
167
+ }
168
+ for (let entry = tree.extraParents; entry !== undefined; entry = entry.next) {
169
+ if (entry.ref[$changes] === parent[$changes]) {
170
+ return entry.index;
171
+ }
172
+ }
173
+ return undefined;
174
+ }
175
+
176
+ /**
177
+ * Return all parents as detached entries (debug/test helper).
178
+ */
179
+ export function getAllParents(tree: ChangeTree): ParentEntry[] {
180
+ const parents: ParentEntry[] = [];
151
181
  if (tree.parentRef) {
152
182
  parents.push({ ref: tree.parentRef, index: tree._parentIndex });
153
183
  }
@@ -158,3 +188,30 @@ export function getAllParents(tree: ChangeTree): Array<{ ref: Ref, index: number
158
188
  }
159
189
  return parents;
160
190
  }
191
+
192
+ /**
193
+ * True iff `parent` currently holds `tree`. Detached edges linger in the
194
+ * parent chain (load-bearing for same-tick view drains — see
195
+ * `indexInParent` above), so the chain alone cannot answer which edges
196
+ * are live. ArraySchema is probed by scanning `items`: the recorded slot
197
+ * can go stale after reorders, and `items` — unlike `$getByIndex`'s staged
198
+ * view — reflects the tick's completed mutations.
199
+ */
200
+ export function isEdgeLive(tree: ChangeTree, parentTree: ChangeTree, index: number): boolean {
201
+ const target = parentTree.refTarget as any;
202
+ if (parentTree.isArray) {
203
+ // Read `items` directly, not `$getByIndex` — the latter serves the
204
+ // staged (tmpItems) view, which can still hold a same-tick removal.
205
+ const items = target.items;
206
+ const at = items[index];
207
+ if (at !== undefined && at[$changes] === tree) return true;
208
+ // Recorded slot goes stale after reorders — scan before declaring dead.
209
+ for (let i = 0, len = items.length; i < len; i++) {
210
+ const v = items[i];
211
+ if (v !== undefined && v[$changes] === tree) return true;
212
+ }
213
+ return false;
214
+ }
215
+ const at = parentTree.getValue(index);
216
+ return at !== undefined && at[$changes] === tree;
217
+ }
@@ -204,7 +204,8 @@ export function streamDequeueForView(
204
204
  viewId: number,
205
205
  refId: number,
206
206
  index: number,
207
- viewChanges: Map<number, Map<number, number>>,
207
+ // widened key: StateView.changes carries identity-keyed array entries too
208
+ viewChanges: Map<number, Map<number | object, number>>,
208
209
  ): boolean {
209
210
  const st = s._stream;
210
211
  if (st === undefined) return false;
@@ -8,16 +8,6 @@ export class TypeContext {
8
8
  schemas = new Map<typeof Schema, number>();
9
9
 
10
10
  hasFilters: boolean = false;
11
- parentFiltered: {[typeIdAndParentIndex: string]: boolean} = {};
12
- /**
13
- * True iff `parentFiltered` has at least one entry. Flipped on by
14
- * `registerFilteredByParent` and read in `checkInheritedFlags` as a
15
- * cheap gate to skip the string-keyed `parentFiltered[key]` lookup
16
- * when no class has registered filter inheritance via ancestry — the
17
- * common case when @view tags exist only on sibling fields, not
18
- * along any attachment chain.
19
- */
20
- hasParentFilteredEntries: boolean = false;
21
11
 
22
12
  /**
23
13
  * For inheritance support
@@ -84,17 +74,13 @@ export class TypeContext {
84
74
  return this.schemas.get(klass);
85
75
  }
86
76
 
87
- private discoverTypes(klass: typeof Schema, parentType?: typeof Schema, parentIndex?: number, parentHasViewTag?: boolean) {
88
- if (parentHasViewTag) {
89
- this.registerFilteredByParent(klass, parentType, parentIndex);
90
- }
91
-
77
+ private discoverTypes(klass: typeof Schema) {
92
78
  // skip if already registered
93
79
  if (!this.add(klass)) { return; }
94
80
 
95
81
  // add classes inherited from this base class
96
82
  TypeContext.inheritedTypes.get(klass)?.forEach((child) => {
97
- this.discoverTypes(child, parentType, parentIndex, parentHasViewTag);
83
+ this.discoverTypes(child);
98
84
  });
99
85
 
100
86
  // add parent classes
@@ -120,7 +106,6 @@ export class TypeContext {
120
106
  const index = fieldIndex as any as number;
121
107
 
122
108
  const fieldType = metadata[index].type;
123
- const fieldHasViewTag = (metadata[index].tag !== undefined);
124
109
 
125
110
  if (typeof (fieldType) === "string") {
126
111
  continue;
@@ -133,7 +118,7 @@ export class TypeContext {
133
118
  }
134
119
 
135
120
  if (typeof (fieldType) === "function") {
136
- this.discoverTypes(fieldType as typeof Schema, klass, index, parentHasViewTag || fieldHasViewTag);
121
+ this.discoverTypes(fieldType as typeof Schema);
137
122
 
138
123
  } else {
139
124
  const type = Object.values(fieldType)[0];
@@ -143,47 +128,15 @@ export class TypeContext {
143
128
  continue;
144
129
  }
145
130
 
146
- this.discoverTypes(type as typeof Schema, klass, index, parentHasViewTag || fieldHasViewTag);
131
+ this.discoverTypes(type as typeof Schema);
147
132
  }
148
133
  }
149
134
  }
150
135
 
151
- /**
152
- * Keep track of which classes have filters applied.
153
- * Format: `${typeid}-${parentTypeid}-${parentIndex}`
154
- */
155
- private registerFilteredByParent(schema: typeof Schema, parentType?: typeof Schema, parentIndex?: number) {
156
- const typeid = this.schemas.get(schema) ?? this.schemas.size;
157
-
158
- let key = `${typeid}`;
159
- if (parentType) { key += `-${this.schemas.get(parentType)}`; }
160
-
161
- key += `-${parentIndex}`;
162
- this.parentFiltered[key] = true;
163
- this.hasParentFilteredEntries = true;
164
- }
165
-
166
136
  debug() {
167
- let parentFiltered = "";
168
-
169
- for (const key in this.parentFiltered) {
170
- const keys: number[] = key.split("-").map(Number);
171
- const fieldIndex = keys.pop();
172
-
173
- parentFiltered += `\n\t\t`;
174
- parentFiltered += `${key}: ${keys.reverse().map((id, i) => {
175
- const klass = this.types[id];
176
- const metadata: Metadata = klass[Symbol.metadata];
177
- let txt = klass.name;
178
- if (i === 0) { txt += `[${metadata[fieldIndex].name}]`; }
179
- return `${txt}`;
180
- }).join(" -> ")}`;
181
- }
182
-
183
137
  return `TypeContext ->\n` +
184
138
  `\tSchema types: ${this.schemas.size}\n` +
185
- `\thasFilters: ${this.hasFilters}\n` +
186
- `\tparentFiltered:${parentFiltered}`;
139
+ `\thasFilters: ${this.hasFilters}`;
187
140
  }
188
141
 
189
142
  }
@@ -307,16 +307,24 @@ function primitive<TBase>(name: RawPrimitiveType): PrimitiveFactory<TBase> {
307
307
  return (() => new FieldBuilder<TBase>(name)) as PrimitiveFactory<TBase>;
308
308
  }
309
309
 
310
- // Accepts a Schema class, a primitive string, or another FieldBuilder as a child type.
310
+ // Collection element: a Schema class or a primitive type NAME (`"string"`).
311
311
  export type ChildType =
312
312
  | RawPrimitiveType
313
- | Constructor<Schema>
314
- | FieldBuilder<any>;
313
+ | Constructor<Schema>;
315
314
 
315
+ /**
316
+ * Guard against `t.array(t.string())`. A builder child looks like it should
317
+ * work — and its bare `_type` would — but every modifier on it (`.view()`,
318
+ * `.default()`, quantize options) would be silently dropped, since modifiers
319
+ * describe the FIELD, not the elements. Fail loudly instead.
320
+ */
316
321
  function resolveChild(child: ChildType): DefinitionType {
317
322
  if (isBuilder(child)) {
318
- // `_type` is private; element access bypasses the visibility check.
319
- return child['_type'];
323
+ const inner = child['_type']; // private; element access bypasses the check
324
+ const hint = (typeof inner === "string")
325
+ ? `use the type name instead: t.array("${inner}")`
326
+ : `collections accept a Schema class or a primitive type name ("string", "number", …)`;
327
+ throw new Error(`t.array/map/set/collection(): a t.* builder is not a valid element type — ${hint}.`);
320
328
  }
321
329
  return child as DefinitionType;
322
330
  }
@@ -326,28 +334,24 @@ function resolveChild(child: ChildType): DefinitionType {
326
334
  // ---------------------------------------------------------------------------
327
335
 
328
336
  // Overloaded factories for collections. Implementation lives in a single function;
329
- // overloads narrow the return type for Schema/primitive/builder children.
337
+ // overloads narrow the return type for Schema/primitive children.
330
338
  // All collection factories tag `HasDefault = true` because schema() auto-
331
339
  // instantiates an empty collection when no explicit default is given.
332
340
  interface ArrayFactory {
333
341
  <C extends Constructor<Schema>>(child: C): FieldBuilder<ArraySchema<InstanceType<C>>, true, false>;
334
342
  <P extends RawPrimitiveType>(child: P): FieldBuilder<ArraySchema<InferValueType<P>>, true, false>;
335
- <V>(child: FieldBuilder<V>): FieldBuilder<ArraySchema<V>, true, false>;
336
343
  }
337
344
  interface MapFactory {
338
345
  <C extends Constructor<Schema>>(child: C): FieldBuilder<MapSchema<InstanceType<C>>, true, false>;
339
346
  <P extends RawPrimitiveType>(child: P): FieldBuilder<MapSchema<InferValueType<P>>, true, false>;
340
- <V>(child: FieldBuilder<V>): FieldBuilder<MapSchema<V>, true, false>;
341
347
  }
342
348
  interface SetFactory {
343
349
  <C extends Constructor<Schema>>(child: C): FieldBuilder<SetSchema<InstanceType<C>>, true, false>;
344
350
  <P extends RawPrimitiveType>(child: P): FieldBuilder<SetSchema<InferValueType<P>>, true, false>;
345
- <V>(child: FieldBuilder<V>): FieldBuilder<SetSchema<V>, true, false>;
346
351
  }
347
352
  interface CollectionFactory {
348
353
  <C extends Constructor<Schema>>(child: C): FieldBuilder<CollectionSchema<InstanceType<C>>, true, false>;
349
354
  <P extends RawPrimitiveType>(child: P): FieldBuilder<CollectionSchema<InferValueType<P>>, true, false>;
350
- <V>(child: FieldBuilder<V>): FieldBuilder<CollectionSchema<V>, true, false>;
351
355
  }
352
356
  // t.stream(Entity) — priority-batched collection of Schema instances.
353
357
  // Element type is restricted to Schema subclasses (no primitives) because