@colyseus/schema 5.0.14 → 5.0.20

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 (54) hide show
  1. package/README.md +11 -5
  2. package/build/Metadata.d.ts +10 -1
  3. package/build/annotations.d.ts +6 -5
  4. package/build/codegen/api.d.ts +2 -0
  5. package/build/codegen/cli.cjs +322 -31
  6. package/build/codegen/cli.cjs.map +1 -1
  7. package/build/codegen/parser.d.ts +6 -1
  8. package/build/codegen/resolve.d.ts +25 -0
  9. package/build/codegen/types.d.ts +2 -0
  10. package/build/decoder/strategy/Callbacks.d.ts +7 -8
  11. package/build/decoder/strategy/getDecoderStateCallbacks.d.ts +2 -2
  12. package/build/encoder/ChangeTree.d.ts +40 -12
  13. package/build/encoder/Encoder.d.ts +1 -1
  14. package/build/encoder/Root.d.ts +9 -0
  15. package/build/encoder/StateView.d.ts +38 -1
  16. package/build/encoder/changeTree/inheritedFlags.d.ts +13 -19
  17. package/build/encoder/changeTree/liveIteration.d.ts +8 -0
  18. package/build/encoder/changeTree/parentChain.d.ts +30 -8
  19. package/build/encoder/streaming.d.ts +1 -1
  20. package/build/index.cjs +3232 -2830
  21. package/build/index.cjs.map +1 -1
  22. package/build/index.js +3228 -2826
  23. package/build/index.mjs +3232 -2830
  24. package/build/index.mjs.map +1 -1
  25. package/build/types/HelperTypes.d.ts +24 -14
  26. package/build/types/TypeContext.d.ts +0 -17
  27. package/build/types/builder.d.ts +1 -5
  28. package/build/types/symbols.d.ts +1 -0
  29. package/package.json +9 -8
  30. package/src/Metadata.ts +59 -77
  31. package/src/Reflection.ts +9 -5
  32. package/src/annotations.ts +28 -18
  33. package/src/codegen/api.ts +3 -1
  34. package/src/codegen/cli.ts +5 -2
  35. package/src/codegen/parser.ts +69 -31
  36. package/src/codegen/resolve.ts +322 -0
  37. package/src/codegen/types.ts +4 -1
  38. package/src/decoder/DecodeOperation.ts +13 -2
  39. package/src/decoder/strategy/Callbacks.ts +7 -8
  40. package/src/decoder/strategy/getDecoderStateCallbacks.ts +2 -2
  41. package/src/encoder/ChangeTree.ts +76 -25
  42. package/src/encoder/EncodeOperation.ts +10 -1
  43. package/src/encoder/Encoder.ts +52 -2
  44. package/src/encoder/Root.ts +28 -8
  45. package/src/encoder/StateView.ts +150 -66
  46. package/src/encoder/changeTree/inheritedFlags.ts +164 -45
  47. package/src/encoder/changeTree/liveIteration.ts +24 -3
  48. package/src/encoder/changeTree/parentChain.ts +72 -15
  49. package/src/encoder/streaming.ts +2 -1
  50. package/src/types/HelperTypes.ts +43 -34
  51. package/src/types/TypeContext.ts +5 -52
  52. package/src/types/builder.ts +14 -10
  53. package/src/types/custom/ArraySchema.ts +57 -14
  54. package/src/types/symbols.ts +3 -0
@@ -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;
@@ -17,10 +17,18 @@ type PrimitiveStringToType<T> =
17
17
  : T extends "boolean" ? boolean
18
18
  : T;
19
19
 
20
- export interface Collection<K = any, V = any, IT = V> {
20
+ /**
21
+ * What the decoder callbacks accept as "a collection": the public shape, which
22
+ * a plain array satisfies too — `@type([X]) items: X[]` is a common way to
23
+ * declare a field. {@link Collection} is the runtime contract on top of it.
24
+ */
25
+ export interface CollectionLike<K = any, V = any, IT = V> {
21
26
  [Symbol.iterator](): IterableIterator<IT>;
22
27
  forEach(callback: Function): void;
23
28
  entries(): IterableIterator<[K, V]>;
29
+ }
30
+
31
+ export interface Collection<K = any, V = any, IT = V> extends CollectionLike<K, V, IT> {
24
32
  /** See {@link $resyncPrune} — every collection kind must declare its resync-sweep semantics. */
25
33
  [$resyncPrune](
26
34
  visited: Set<number | string>,
@@ -90,13 +98,13 @@ export type InferValueType<T> =
90
98
  // Keys whose builder carries the `.optional()` brand. Reads the brand rather
91
99
  // than `undefined extends V`: the latter is true for EVERY V when the consumer
92
100
  // compiles with `strictNullChecks: false`, flipping all fields optional.
93
- type OptionalBuilderKeys<T> = {
94
- [K in keyof T]: T[K] extends FieldBuilder<unknown, boolean, infer O extends boolean>
95
- ? (O extends true ? K : never)
96
- : never
97
- }[keyof T];
101
+ type IsOptionalBuilderKey<T, K extends keyof T> =
102
+ T[K] extends FieldBuilder<unknown, boolean, infer O extends boolean> ? O : false;
98
103
 
99
- type RequiredBuilderKeys<T> = Exclude<keyof T, OptionalBuilderKeys<T>>;
104
+ // Per key, like `DataKey` below — an `Exclude<keyof T, …>` split defers every
105
+ // key once one field is typed by a bare type parameter.
106
+ type OptionalBuilderKeys<T> = { [K in keyof T]-?: IsOptionalBuilderKey<T, K> extends true ? K : never }[keyof T];
107
+ type RequiredBuilderKeys<T> = { [K in keyof T]-?: IsOptionalBuilderKey<T, K> extends true ? never : K }[keyof T];
100
108
 
101
109
  export type InferSchemaInstanceType<T> = {
102
110
  [K in RequiredBuilderKeys<T>]: T[K] extends FieldBuilder<any>
@@ -110,20 +118,20 @@ export type InferSchemaInstanceType<T> = {
110
118
  : never
111
119
  } & Schema;
112
120
 
113
- export type NonFunctionProps<T> = Omit<T, {
114
- [K in keyof T]: T[K] extends Function ? K : never;
115
- }[keyof T]>;
121
+ // Per-key filter, never `Omit`/`Exclude` over the union of method names: with
122
+ // one field typed by a bare type parameter that union defers EVERY key, and a
123
+ // mapped type with no resolvable keys has no members to relate — which is what
124
+ // stopped `SpecialNode<E>` from satisfying `extends NodeBase`.
125
+ // `keyof Schema` is dropped so `restore({ ... })` takes a plain literal.
126
+ type DataKey<T, K extends keyof T> =
127
+ K extends keyof Schema ? never
128
+ : T[K] extends Function ? never
129
+ : K;
116
130
 
117
- export type NonFunctionPropNames<T> = {
118
- [K in keyof T]: T[K] extends Function ? never : K
119
- }[keyof T];
131
+ export type NonFunctionPropNames<T> = { [K in keyof T]-?: DataKey<T, K> }[keyof T];
120
132
 
121
133
  export type NonFunctionNonPrimitivePropNames<T> = {
122
- [K in keyof T]: T[K] extends Function
123
- ? never
124
- : T[K] extends number | string | boolean
125
- ? never
126
- : K
134
+ [K in keyof T]-?: [DataKey<T, K>] extends [never] ? never : T[K] extends number | string | boolean ? never : K
127
135
  }[keyof T];
128
136
 
129
137
  // Helper to recursively convert Schema instances to their JSON representation
@@ -138,21 +146,23 @@ type ToJSONField<X> =
138
146
  : X extends Schema ? ToJSON<X>
139
147
  : X;
140
148
 
141
- // Keys whose value admits `undefined` runtime `toJSON()` omits those, so
142
- // they surface as `?:` on the JSON shape. Under `strictNullChecks: false`
143
- // (`undefined extends {}` detects it) `undefined extends T[K]` is true for
144
- // every key, so only the `?` modifier can signal optionality there.
145
- type ToJSONOptionalKeys<T> = {
146
- [K in keyof T]-?: undefined extends {}
147
- ? ({} extends Pick<T, K> ? K : never)
148
- : (undefined extends T[K] ? K : never)
149
- }[keyof T];
150
- type ToJSONRequiredKeys<T> = Exclude<keyof T, ToJSONOptionalKeys<T>>;
151
-
152
- export type ToJSON<T> = NonFunctionProps<
149
+ // Runtime `toJSON()` omits `undefined` values, so those keys surface as `?:`.
150
+ // Under `strictNullChecks: false` (`undefined extends {}` detects it)
151
+ // `undefined extends T[K]` is true for every key, so only the `?` modifier
152
+ // can signal optionality there.
153
+ type IsOptionalKey<T, K extends keyof T> = undefined extends {}
154
+ ? ({} extends Pick<T, K> ? true : false)
155
+ : (undefined extends T[K] ? true : false);
156
+
157
+ // `DataKey` first: machinery and method keys drop before the optionality probe.
158
+ type ToJSONRequiredKeys<T> = { [K in keyof T]-?: [DataKey<T, K>] extends [never] ? never : IsOptionalKey<T, K> extends true ? never : K }[keyof T];
159
+ type ToJSONOptionalKeys<T> = { [K in keyof T]-?: [DataKey<T, K>] extends [never] ? never : IsOptionalKey<T, K> extends true ? K : never }[keyof T];
160
+
161
+ // Keys are filtered before mapping: `ToJSONField` over the `this`-typed methods
162
+ // exceeds TypeScript 7's instantiation depth.
163
+ export type ToJSON<T> =
153
164
  & { [K in ToJSONRequiredKeys<T>]: ToJSONField<T[K]> }
154
- & { [K in ToJSONOptionalKeys<T>]?: ToJSONField<Exclude<T[K], undefined>> }
155
- >;
165
+ & { [K in ToJSONOptionalKeys<T>]?: ToJSONField<Exclude<T[K], undefined>> };
156
166
 
157
167
  /**
158
168
  * The plain DATA shape of a Schema instance type `T`: its synchronized fields
@@ -172,8 +182,7 @@ export type ToJSON<T> = NonFunctionProps<
172
182
  *
173
183
  * Unlike {@link ToJSON} (a recursive *serialization* shape), this is a flat
174
184
  * structural projection: nested Schema / collection fields keep their instance
175
- * types, and it does not retain the non-method `Schema` members that `ToJSON`'s
176
- * `NonFunctionProps` pass leaves behind.
185
+ * types.
177
186
  */
178
187
  export type Data<T> = Omit<T, keyof Schema>;
179
188
 
@@ -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
@@ -178,7 +178,7 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
178
178
  const proxy = new Proxy(this, ARRAY_PROXY_HANDLER);
179
179
 
180
180
  Object.defineProperty(this, $changes, {
181
- value: new ChangeTree(proxy),
181
+ value: new ChangeTree(proxy, this),
182
182
  enumerable: false,
183
183
  writable: true,
184
184
  });
@@ -486,7 +486,22 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
486
486
  // @ts-ignore
487
487
  reverse(): ArraySchema<V> {
488
488
  const self = this[$proxyTarget];
489
- self[$changes].operation(OPERATION.REVERSE);
489
+ const changeTree = self[$changes];
490
+
491
+ if (changeTree.has() || self.deletedIndexes.length > 0) {
492
+ //
493
+ // Ops recorded earlier this tick address the staged (pre-reverse)
494
+ // layout, and the encoder only resolves their values at encode
495
+ // time — a pure REVERSE would move that layout under them.
496
+ // Degrade to a full re-state: CLEAR + re-ADD in reversed order.
497
+ //
498
+ const reversed = self.items.slice().reverse();
499
+ this.clear(); // also drops staged holes (discard → $onEncodeEnd)
500
+ this.push(...reversed);
501
+ return this;
502
+ }
503
+
504
+ changeTree.operation(OPERATION.REVERSE);
490
505
  self.items.reverse();
491
506
  self.tmpItems.reverse();
492
507
  self.$reindexChildren(0);
@@ -602,13 +617,13 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
602
617
 
603
618
  // insert operations
604
619
  if (insertCount > 0) {
605
- if (insertCount > deleteCount) {
606
- console.error("Inserting more elements than deleting during ArraySchema#splice()");
607
- throw new Error("ArraySchema#splice(): insertCount must be equal or lower than deleteCount.");
608
- }
620
+ const base = indexes[start] ?? itemsLength;
609
621
 
610
- for (let i = 0; i < insertCount; i++) {
611
- const addIndex = (indexes[start] ?? itemsLength) + i;
622
+ // the first `reuse` items take over the wire slots just deleted
623
+ const reuse = Math.min(insertCount, deleteCount);
624
+
625
+ for (let i = 0; i < reuse; i++) {
626
+ const addIndex = base + i;
612
627
 
613
628
  changeTree.indexedOperation(
614
629
  addIndex,
@@ -617,9 +632,36 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
617
632
  : OPERATION.ADD
618
633
  );
619
634
 
635
+ // the slot is live again — the staged snapshot must carry the
636
+ // new value, or `$getByIndex` falls back to `items[addIndex]`
637
+ // and resolves an unrelated element once tmp/items diverge.
638
+ tmpItems[addIndex] = insertItems[i];
639
+ deletedIndexes[addIndex] = false;
640
+
620
641
  // set value's parent/root — use `this` (Proxy) as parent.
621
642
  insertItems[i][$changes]?.setParent(this, changeTree.root, addIndex);
622
643
  }
644
+
645
+ // ...the rest have no slot to take: widen the wire layout, same as
646
+ // unshift() but at `at` instead of 0.
647
+ const extra = insertCount - reuse;
648
+ if (extra > 0) {
649
+ const at = base + reuse;
650
+
651
+ changeTree.insertAt(at, extra);
652
+
653
+ for (let i = 0; i < extra; i++) {
654
+ insertItems[reuse + i][$changes]?.setParent(this, changeTree.root, at + i);
655
+ }
656
+
657
+ // keep staged-delete flags aligned with the inserted tmp slots
658
+ if (deletedIndexes.length > 0) {
659
+ deletedIndexes.splice(at, 0, ...new Array(extra).fill(false));
660
+ }
661
+
662
+ tmpItems.splice(at, 0, ...insertItems.slice(reuse));
663
+ self.$reindexChildren(at + extra); // survivors only — the loop above placed the new items
664
+ }
623
665
  }
624
666
 
625
667
  changeTree.root?.enqueueChangeTree(changeTree);
@@ -969,15 +1011,16 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
969
1011
  }
970
1012
 
971
1013
  protected [$onEncodeEnd]() {
972
- const self = this[$proxyTarget] ?? this;
973
- const staged = self.tmpItems;
974
- self.tmpItems = self.items.slice();
1014
+ // No unwrap: ChangeTree's gated sites are the only callers and they
1015
+ // invoke on `refTarget` (the raw target) already.
1016
+ const staged = this.tmpItems;
1017
+ this.tmpItems = this.items.slice();
975
1018
 
976
- if (self.deletedIndexes.length > 0) {
1019
+ if (this.deletedIndexes.length > 0) {
977
1020
  // compaction just closed the staged holes — everything above the
978
1021
  // lowest one slid down a slot
979
- self.$reindexChildren(0, staged);
980
- self.deletedIndexes.length = 0;
1022
+ this.$reindexChildren(0, staged);
1023
+ this.deletedIndexes.length = 0;
981
1024
  }
982
1025
  }
983
1026
 
@@ -131,6 +131,9 @@ export const $viewFieldIndexes = "~__viewFieldIndexes";
131
131
  export const $fieldIndexesByViewTag = "$__fieldIndexesByViewTag";
132
132
  export const $unreliableFieldIndexes = "~__unreliableFieldIndexes";
133
133
  export const $patchOnlyFieldIndexes = "~__patchOnlyFieldIndexes";
134
+ // @patchOnly ∪ @deprecated() — indexes the full-sync walk must not read (the
135
+ // deprecated accessor may throw). Maintained at decoration time.
136
+ export const $fullSyncSkipIndexes = "~__fullSyncSkipIndexes";
134
137
  export const $fullStateOnlyFieldIndexes = "~__fullStateOnlyFieldIndexes";
135
138
  export const $streamFieldIndexes = "~__streamFieldIndexes";
136
139
  export const $streamPriorities = "~__streamPriorities";