@colyseus/schema 5.0.3 → 5.0.5
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.
- package/build/codegen/cli.cjs +23 -0
- package/build/codegen/cli.cjs.map +1 -1
- package/build/decoder/strategy/Callbacks.d.ts +6 -6
- package/build/encoder/StateView.d.ts +17 -0
- package/build/index.cjs +184 -37
- package/build/index.cjs.map +1 -1
- package/build/index.js +184 -37
- package/build/index.mjs +184 -37
- package/build/index.mjs.map +1 -1
- package/build/input/index.cjs +28 -4
- package/build/input/index.cjs.map +1 -1
- package/build/input/index.mjs +28 -4
- package/build/input/index.mjs.map +1 -1
- package/build/types/builder.d.ts +83 -29
- package/package.json +6 -5
- package/src/annotations.ts +48 -18
- package/src/codegen/languages/csharp.ts +24 -0
- package/src/decoder/strategy/Callbacks.ts +16 -14
- package/src/encoder/Encoder.ts +13 -2
- package/src/encoder/StateView.ts +63 -2
- package/src/encoder/changeTree/inheritedFlags.ts +16 -2
- package/src/types/builder.ts +82 -20
package/build/types/builder.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface BuilderDefinition {
|
|
|
23
23
|
static?: boolean;
|
|
24
24
|
stream?: boolean;
|
|
25
25
|
optional?: boolean;
|
|
26
|
+
/** Local-only field: typed + initialized, but never registered for sync. */
|
|
27
|
+
noSync?: boolean;
|
|
26
28
|
/** Declaration-scope priority callback for `.stream()` fields. */
|
|
27
29
|
streamPriority?: (view: any, element: any) => number;
|
|
28
30
|
}
|
|
@@ -53,19 +55,20 @@ export type BuilderOf<T> = FieldBuilder<T>;
|
|
|
53
55
|
*/
|
|
54
56
|
export declare class FieldBuilder<T = unknown, HasDefault extends boolean = false, IsOptional extends boolean = false> {
|
|
55
57
|
readonly [$builder]: true;
|
|
56
|
-
_type
|
|
57
|
-
_default
|
|
58
|
-
_hasDefault
|
|
59
|
-
_view
|
|
60
|
-
_owned
|
|
61
|
-
_unreliable
|
|
62
|
-
_transient
|
|
63
|
-
_deprecated
|
|
64
|
-
_deprecatedThrows
|
|
65
|
-
_static
|
|
66
|
-
_stream
|
|
67
|
-
_optional
|
|
68
|
-
|
|
58
|
+
private _type;
|
|
59
|
+
private _default;
|
|
60
|
+
private _hasDefault;
|
|
61
|
+
private _view;
|
|
62
|
+
private _owned;
|
|
63
|
+
private _unreliable;
|
|
64
|
+
private _transient;
|
|
65
|
+
private _deprecated;
|
|
66
|
+
private _deprecatedThrows;
|
|
67
|
+
private _static;
|
|
68
|
+
private _stream;
|
|
69
|
+
private _optional;
|
|
70
|
+
private _noSync;
|
|
71
|
+
private _streamPriority;
|
|
69
72
|
constructor(type: DefinitionType);
|
|
70
73
|
/** Provide a default value for this field. */
|
|
71
74
|
default(value: T): FieldBuilder<T, true, IsOptional>;
|
|
@@ -93,6 +96,27 @@ export declare class FieldBuilder<T = unknown, HasDefault extends boolean = fals
|
|
|
93
96
|
* after add — post-add field mutations on elements become no-ops.
|
|
94
97
|
*/
|
|
95
98
|
static(): this;
|
|
99
|
+
/**
|
|
100
|
+
* Mark this field as **local-only** — it is typed and initialized on the
|
|
101
|
+
* instance (so `.default()` and the inferred instance type still apply),
|
|
102
|
+
* but is never registered for synchronization: it never enters change
|
|
103
|
+
* tracking, never goes over the wire, and decoders never receive it.
|
|
104
|
+
*
|
|
105
|
+
* Useful for server-side scratch state, per-peer UI state, or values you
|
|
106
|
+
* want on the class for typing convenience without paying any sync cost.
|
|
107
|
+
*
|
|
108
|
+
* Mutually exclusive with the sync-only modifiers (`.view()`, `.owned()`,
|
|
109
|
+
* `.unreliable()`, `.transient()`, `.static()`, `.stream()`) — combining
|
|
110
|
+
* them throws at `schema()` time.
|
|
111
|
+
*
|
|
112
|
+
* ```ts
|
|
113
|
+
* const Player = schema({
|
|
114
|
+
* hp: t.uint8().default(100), // synchronized
|
|
115
|
+
* lastInputTick: t.number().noSync(), // local-only
|
|
116
|
+
* }, 'Player');
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
noSync(): this;
|
|
96
120
|
/**
|
|
97
121
|
* Opt a collection field into priority-batched streaming delivery —
|
|
98
122
|
* ADDs drain at most `maxPerTick` per tick per view (or per broadcast
|
|
@@ -130,9 +154,39 @@ export declare class FieldBuilder<T = unknown, HasDefault extends boolean = fals
|
|
|
130
154
|
* defaults, so the field starts as `undefined` at runtime.
|
|
131
155
|
*/
|
|
132
156
|
optional(): FieldBuilder<T | undefined, HasDefault, true>;
|
|
133
|
-
|
|
157
|
+
/**
|
|
158
|
+
* @internal — snapshot of the builder's configuration consumed by
|
|
159
|
+
* `schema()`. `private` keeps it out of autocomplete; internal callers
|
|
160
|
+
* reach it via element access (`builder['toDefinition']()`).
|
|
161
|
+
*/
|
|
162
|
+
private toDefinition;
|
|
134
163
|
}
|
|
135
164
|
export declare function isBuilder(value: any): value is FieldBuilder<any>;
|
|
165
|
+
/**
|
|
166
|
+
* Primitive field factory. Calling it bare (`t.int8()`) yields the natural type
|
|
167
|
+
* for the wire codec (`number` for the int/float formats, plus `string` /
|
|
168
|
+
* `boolean` / `bigint`). Pass an explicit type argument to refine the inferred
|
|
169
|
+
* value at the TYPE level, while the wire encoding is unchanged:
|
|
170
|
+
*
|
|
171
|
+
* moveX: t.int8<-1 | 0 | 1>(), // typed -1|0|1, still encoded as int8
|
|
172
|
+
* team: t.string<"red" | "blue">(),
|
|
173
|
+
*
|
|
174
|
+
* Two call signatures, NOT a defaulted generic `<T extends TBase = TBase>`: the
|
|
175
|
+
* bare form must return a CONCRETE `FieldBuilder<TBase>` so `schema({ x:
|
|
176
|
+
* t.number() })` still infers `x: number`. A defaulted free type parameter gets
|
|
177
|
+
* captured as `any` during `schema()`'s self-referential field inference (and
|
|
178
|
+
* `undefined extends any` then flips every field optional).
|
|
179
|
+
*
|
|
180
|
+
* NOTE: the refinement is a TYPE-LEVEL assertion, not a runtime guarantee — the
|
|
181
|
+
* wire still carries the codec's full range and the DECODER writes whatever
|
|
182
|
+
* bytes arrive. Sound for server-authored state; for INPUT schemas the value
|
|
183
|
+
* comes from an untrusted client (the type reads `-1|0|1` while a peer can send
|
|
184
|
+
* any int8), so keep validating/clamping on the receiving side.
|
|
185
|
+
*/
|
|
186
|
+
interface PrimitiveFactory<TBase> {
|
|
187
|
+
(): FieldBuilder<TBase>;
|
|
188
|
+
<T extends TBase>(): FieldBuilder<T>;
|
|
189
|
+
}
|
|
136
190
|
export type ChildType = RawPrimitiveType | Constructor<Schema> | FieldBuilder<any>;
|
|
137
191
|
interface ArrayFactory {
|
|
138
192
|
<C extends Constructor<Schema>>(child: C): FieldBuilder<ArraySchema<InstanceType<C>>, true, false>;
|
|
@@ -166,21 +220,21 @@ interface RefFactory {
|
|
|
166
220
|
<C extends Constructor<Schema>>(ctor: C): FieldBuilder<InstanceType<C>, RefHasDefault<C>, false>;
|
|
167
221
|
}
|
|
168
222
|
export declare const t: Readonly<{
|
|
169
|
-
string:
|
|
170
|
-
number:
|
|
171
|
-
boolean:
|
|
172
|
-
int8:
|
|
173
|
-
uint8:
|
|
174
|
-
int16:
|
|
175
|
-
uint16:
|
|
176
|
-
int32:
|
|
177
|
-
uint32:
|
|
178
|
-
int64:
|
|
179
|
-
uint64:
|
|
180
|
-
float32:
|
|
181
|
-
float64:
|
|
182
|
-
bigint64:
|
|
183
|
-
biguint64:
|
|
223
|
+
string: PrimitiveFactory<string>;
|
|
224
|
+
number: PrimitiveFactory<number>;
|
|
225
|
+
boolean: PrimitiveFactory<boolean>;
|
|
226
|
+
int8: PrimitiveFactory<number>;
|
|
227
|
+
uint8: PrimitiveFactory<number>;
|
|
228
|
+
int16: PrimitiveFactory<number>;
|
|
229
|
+
uint16: PrimitiveFactory<number>;
|
|
230
|
+
int32: PrimitiveFactory<number>;
|
|
231
|
+
uint32: PrimitiveFactory<number>;
|
|
232
|
+
int64: PrimitiveFactory<number>;
|
|
233
|
+
uint64: PrimitiveFactory<number>;
|
|
234
|
+
float32: PrimitiveFactory<number>;
|
|
235
|
+
float64: PrimitiveFactory<number>;
|
|
236
|
+
bigint64: PrimitiveFactory<bigint>;
|
|
237
|
+
biguint64: PrimitiveFactory<bigint>;
|
|
184
238
|
/** Reference to a Schema subtype. `t.array(Item)` usually reads better, but this is available when a plain ref is needed. */
|
|
185
239
|
ref: RefFactory;
|
|
186
240
|
array: ArrayFactory;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colyseus/schema",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.5",
|
|
4
4
|
"description": "Binary state serializer with delta encoding for games",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"build": "tsc -p tsconfig.build.json && rollup -c rollup.config.mjs",
|
|
12
12
|
"watch": "tsc -p tsconfig.build.json -w",
|
|
13
|
-
"typecheck": "tsc -p tsconfig.build.json --noEmit",
|
|
14
|
-
"test": "tsx --tsconfig tsconfig.test.json ./node_modules
|
|
13
|
+
"typecheck:build": "tsc -p tsconfig.build.json --noEmit",
|
|
14
|
+
"test": "tsx --tsconfig tsconfig.test.json ./node_modules/mocha/bin/mocha.js \"test/*.test.ts\" \"test/**/*.test.ts\"",
|
|
15
|
+
"typecheck": "tsc -p tsconfig.test.json",
|
|
15
16
|
"coverage": "c8 npm run test",
|
|
16
17
|
"generate-test-1": "bin/schema-codegen test-external/PrimitiveTypes.ts --namespace SchemaTest.PrimitiveTypes --output ../colyseus-unity-sdk/Assets/Colyseus/Tests/Editor/ColyseusTests/Schema/PrimitiveTypes",
|
|
17
18
|
"generate-test-2": "bin/schema-codegen test-external/ChildSchemaTypes.ts --namespace SchemaTest.ChildSchemaTypes --output ../colyseus-unity-sdk/Assets/Colyseus/Tests/Editor/ColyseusTests/Schema/ChildSchemaTypes",
|
|
@@ -25,7 +26,7 @@
|
|
|
25
26
|
"generate-test-10": "bin/schema-codegen test-external/Callbacks.ts --namespace SchemaTest.Callbacks --output ../colyseus-unity-sdk/Assets/Colyseus/Tests/Editor/ColyseusTests/Schema/Callbacks",
|
|
26
27
|
"generate-test-11": "bin/schema-codegen test-external/MapSchemaMoveNullifyType.ts --namespace SchemaTest.MapSchemaMoveNullifyType --output ../colyseus-unity-sdk/Assets/Colyseus/Tests/Editor/ColyseusTests/Schema/MapSchemaMoveNullifyType",
|
|
27
28
|
"generate-test-12": "bin/schema-codegen test-external/ArraySchemaClear --namespace SchemaTest.ArraySchemaClear --output ../colyseus-unity-sdk/Assets/Colyseus/Tests/Editor/ColyseusTests/Schema/ArraySchemaClear",
|
|
28
|
-
"
|
|
29
|
+
"prepare": "npm run build"
|
|
29
30
|
},
|
|
30
31
|
"files": [
|
|
31
32
|
"src",
|
|
@@ -88,7 +89,7 @@
|
|
|
88
89
|
"typescript": "^5.9.3"
|
|
89
90
|
},
|
|
90
91
|
"peerDependencies": {
|
|
91
|
-
"typescript": "^5.
|
|
92
|
+
"typescript": "^5.0.0 || ^6.0.0"
|
|
92
93
|
},
|
|
93
94
|
"c8": {
|
|
94
95
|
"include": [
|
package/src/annotations.ts
CHANGED
|
@@ -619,6 +619,26 @@ export interface SchemaWithExtendsConstructor<
|
|
|
619
619
|
};
|
|
620
620
|
}
|
|
621
621
|
|
|
622
|
+
/**
|
|
623
|
+
* Produce the auto-instantiated construction default for a builder type
|
|
624
|
+
* (empty collection or zero-arg Schema ref), or `undefined` when the type
|
|
625
|
+
* has no auto-default. Shared by synced and `.noSync()` field handling.
|
|
626
|
+
*/
|
|
627
|
+
function autoInstantiateDefault(rawType: any): any {
|
|
628
|
+
if (rawType && typeof rawType === "object") {
|
|
629
|
+
if (rawType.array !== undefined) { return new ArraySchema(); }
|
|
630
|
+
if (rawType.map !== undefined) { return new MapSchema(); }
|
|
631
|
+
if (rawType.set !== undefined) { return new SetSchema(); }
|
|
632
|
+
if (rawType.collection !== undefined) { return new CollectionSchema(); }
|
|
633
|
+
if (rawType.stream !== undefined) { return new StreamSchema(); }
|
|
634
|
+
} else if (typeof rawType === "function" && Schema.is(rawType)) {
|
|
635
|
+
if (!rawType.prototype.initialize || rawType.prototype.initialize.length === 0) {
|
|
636
|
+
return new rawType();
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return undefined;
|
|
640
|
+
}
|
|
641
|
+
|
|
622
642
|
/**
|
|
623
643
|
* Define a Schema class declaratively.
|
|
624
644
|
*
|
|
@@ -664,7 +684,31 @@ export function schema<
|
|
|
664
684
|
const value: any = (fieldsAndMethods as any)[fieldName];
|
|
665
685
|
|
|
666
686
|
if (isBuilder(value)) {
|
|
667
|
-
const def = value
|
|
687
|
+
const def = value['toDefinition'](); // private; element access bypasses visibility
|
|
688
|
+
|
|
689
|
+
if (def.noSync) {
|
|
690
|
+
// Local-only field: skip metadata registration entirely so it is
|
|
691
|
+
// never encoded/decoded, but still seed its construction default
|
|
692
|
+
// (honoring `.default()` and collection/ref auto-instantiation).
|
|
693
|
+
if (def.view !== undefined || def.owned || def.unreliable ||
|
|
694
|
+
def.transient || def.static || def.stream) {
|
|
695
|
+
throw new Error(
|
|
696
|
+
`schema(${name ? `'${name}'` : ""}): field '${fieldName}' uses .noSync() ` +
|
|
697
|
+
`together with a sync-only modifier (.view/.owned/.unreliable/.transient/.static/.stream). ` +
|
|
698
|
+
`A local-only field cannot be synchronized.`
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
if (def.hasDefault) {
|
|
702
|
+
defaultValues[fieldName] = def.default;
|
|
703
|
+
} else if (!def.optional) {
|
|
704
|
+
const autoDefault = autoInstantiateDefault(def.type);
|
|
705
|
+
if (autoDefault !== undefined) {
|
|
706
|
+
defaultValues[fieldName] = autoDefault;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
|
|
668
712
|
fields[fieldName] = getNormalizedType(def.type);
|
|
669
713
|
|
|
670
714
|
if (def.view !== undefined) { viewTagFields[fieldName] = def.view; }
|
|
@@ -682,23 +726,9 @@ export function schema<
|
|
|
682
726
|
} else if (!def.optional) {
|
|
683
727
|
// Auto-instantiate collection/Schema defaults when none is provided.
|
|
684
728
|
// `.optional()` opts out — field starts as undefined.
|
|
685
|
-
const
|
|
686
|
-
if (
|
|
687
|
-
|
|
688
|
-
defaultValues[fieldName] = new ArraySchema();
|
|
689
|
-
} else if (rawType.map !== undefined) {
|
|
690
|
-
defaultValues[fieldName] = new MapSchema();
|
|
691
|
-
} else if (rawType.set !== undefined) {
|
|
692
|
-
defaultValues[fieldName] = new SetSchema();
|
|
693
|
-
} else if (rawType.collection !== undefined) {
|
|
694
|
-
defaultValues[fieldName] = new CollectionSchema();
|
|
695
|
-
} else if (rawType.stream !== undefined) {
|
|
696
|
-
defaultValues[fieldName] = new StreamSchema();
|
|
697
|
-
}
|
|
698
|
-
} else if (typeof rawType === "function" && Schema.is(rawType)) {
|
|
699
|
-
if (!rawType.prototype.initialize || rawType.prototype.initialize.length === 0) {
|
|
700
|
-
defaultValues[fieldName] = new rawType();
|
|
701
|
-
}
|
|
729
|
+
const autoDefault = autoInstantiateDefault(def.type);
|
|
730
|
+
if (autoDefault !== undefined) {
|
|
731
|
+
defaultValues[fieldName] = autoDefault;
|
|
702
732
|
}
|
|
703
733
|
}
|
|
704
734
|
|
|
@@ -122,10 +122,34 @@ ${namespace ? "}" : ""}
|
|
|
122
122
|
`;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Check if all enum members resolve to non-negative integers,
|
|
127
|
+
* allowing emission as a native C# `enum` (which only supports integral types).
|
|
128
|
+
*/
|
|
129
|
+
function canUseNativeEnum(_enum: Enum): boolean {
|
|
130
|
+
return _enum.properties.every((prop) => {
|
|
131
|
+
if (!prop.type) return true;
|
|
132
|
+
const n = Number(prop.type);
|
|
133
|
+
return Number.isInteger(n) && n >= 0;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
125
137
|
/**
|
|
126
138
|
* Generate just the enum body (without imports/namespace) for bundling
|
|
127
139
|
*/
|
|
128
140
|
function generateEnumBody(_enum: Enum, indent: string = ""): string {
|
|
141
|
+
if (canUseNativeEnum(_enum)) {
|
|
142
|
+
const members = _enum.properties
|
|
143
|
+
.map((prop, i) => {
|
|
144
|
+
const value = prop.type ? Number(prop.type) : i;
|
|
145
|
+
return `${indent}\t${prop.name} = ${value},`;
|
|
146
|
+
})
|
|
147
|
+
.join("\n");
|
|
148
|
+
return `${indent}public enum ${_enum.name} : int {
|
|
149
|
+
${members}
|
|
150
|
+
${indent}}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
129
153
|
return `${indent}public struct ${_enum.name} {
|
|
130
154
|
|
|
131
155
|
${_enum.properties
|
|
@@ -88,13 +88,13 @@ export class StateCallbackStrategy<TState extends IRef> {
|
|
|
88
88
|
if (!collection || collection[$refId] === undefined) {
|
|
89
89
|
let removePropertyCallback: () => void;
|
|
90
90
|
removePropertyCallback = this.addCallback(
|
|
91
|
-
instance[$refId]
|
|
91
|
+
instance[$refId]!,
|
|
92
92
|
propertyName,
|
|
93
93
|
(value: TReturn, _: TReturn) => {
|
|
94
94
|
if (value !== null && value !== undefined) {
|
|
95
95
|
// Remove the property listener now that collection is available
|
|
96
96
|
removePropertyCallback();
|
|
97
|
-
removeHandler = this.addCallback(value[$refId]
|
|
97
|
+
removeHandler = this.addCallback(value[$refId]!, operation, handler);
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
);
|
|
@@ -113,7 +113,7 @@ export class StateCallbackStrategy<TState extends IRef> {
|
|
|
113
113
|
});
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
return this.addCallback(collection[$refId]
|
|
116
|
+
return this.addCallback(collection[$refId]!, operation, handler);
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
|
|
@@ -129,7 +129,7 @@ export class StateCallbackStrategy<TState extends IRef> {
|
|
|
129
129
|
/**
|
|
130
130
|
* Listen to property changes on a nested instance.
|
|
131
131
|
*/
|
|
132
|
-
listen<TInstance
|
|
132
|
+
listen<TInstance, K extends PublicPropNames<TInstance>>(
|
|
133
133
|
instance: TInstance,
|
|
134
134
|
property: K,
|
|
135
135
|
handler: PropertyChangeCallback<TInstance[K]>,
|
|
@@ -162,13 +162,13 @@ export class StateCallbackStrategy<TState extends IRef> {
|
|
|
162
162
|
handler(currentValue, undefined as any);
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
return this.addCallback(instance[$refId]
|
|
165
|
+
return this.addCallback(instance[$refId]!, propertyName, handler);
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
/**
|
|
169
169
|
* Listen to any property change on an instance.
|
|
170
170
|
*/
|
|
171
|
-
onChange<TInstance extends
|
|
171
|
+
onChange<TInstance extends object>(
|
|
172
172
|
instance: TInstance,
|
|
173
173
|
handler: InstanceChangeCallback
|
|
174
174
|
): () => void;
|
|
@@ -184,7 +184,7 @@ export class StateCallbackStrategy<TState extends IRef> {
|
|
|
184
184
|
/**
|
|
185
185
|
* Listen to item changes in a nested collection.
|
|
186
186
|
*/
|
|
187
|
-
onChange<TInstance extends
|
|
187
|
+
onChange<TInstance extends object, K extends CollectionPropNames<TInstance>>(
|
|
188
188
|
instance: TInstance,
|
|
189
189
|
property: K,
|
|
190
190
|
handler: KeyValueCallback<CollectionKeyType<TInstance, K>, CollectionValueType<TInstance, K>>
|
|
@@ -195,7 +195,7 @@ export class StateCallbackStrategy<TState extends IRef> {
|
|
|
195
195
|
// onChange(instance, handler) - instance change
|
|
196
196
|
const instance = args[0] as Schema;
|
|
197
197
|
const handler = args[1] as InstanceChangeCallback;
|
|
198
|
-
return this.addCallback(instance[$refId]
|
|
198
|
+
return this.addCallback(instance[$refId]!, OPERATION.REPLACE, handler);
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
if (typeof args[0] === 'string') {
|
|
@@ -229,7 +229,7 @@ export class StateCallbackStrategy<TState extends IRef> {
|
|
|
229
229
|
/**
|
|
230
230
|
* Listen to items added to a nested collection.
|
|
231
231
|
*/
|
|
232
|
-
onAdd<TInstance
|
|
232
|
+
onAdd<TInstance, K extends CollectionPropNames<TInstance>>(
|
|
233
233
|
instance: TInstance,
|
|
234
234
|
property: K,
|
|
235
235
|
handler: ValueKeyCallback<CollectionValueType<TInstance, K>, CollectionKeyType<TInstance, K>>,
|
|
@@ -269,7 +269,7 @@ export class StateCallbackStrategy<TState extends IRef> {
|
|
|
269
269
|
/**
|
|
270
270
|
* Listen to items removed from a nested collection.
|
|
271
271
|
*/
|
|
272
|
-
onRemove<TInstance
|
|
272
|
+
onRemove<TInstance, K extends CollectionPropNames<TInstance>>(
|
|
273
273
|
instance: TInstance,
|
|
274
274
|
property: K,
|
|
275
275
|
handler: ValueKeyCallback<CollectionValueType<TInstance, K>, CollectionKeyType<TInstance, K>>
|
|
@@ -299,7 +299,7 @@ export class StateCallbackStrategy<TState extends IRef> {
|
|
|
299
299
|
* Bind properties from a Schema instance to a target object.
|
|
300
300
|
* Changes will be automatically reflected on the target object.
|
|
301
301
|
*/
|
|
302
|
-
bindTo<TInstance
|
|
302
|
+
bindTo<TInstance, TTarget>(
|
|
303
303
|
from: TInstance,
|
|
304
304
|
to: TTarget,
|
|
305
305
|
properties?: string[],
|
|
@@ -327,7 +327,7 @@ export class StateCallbackStrategy<TState extends IRef> {
|
|
|
327
327
|
action();
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
-
return this.addCallback(from[$refId]
|
|
330
|
+
return this.addCallback((from as IRef)[$refId]!, OPERATION.REPLACE, action);
|
|
331
331
|
}
|
|
332
332
|
|
|
333
333
|
protected triggerChanges(allChanges: DataChange[]): void {
|
|
@@ -357,7 +357,7 @@ export class StateCallbackStrategy<TState extends IRef> {
|
|
|
357
357
|
(change.op & OPERATION.DELETE) === OPERATION.DELETE &&
|
|
358
358
|
Schema.isSchema(change.previousValue)
|
|
359
359
|
) {
|
|
360
|
-
const childRefId = (change.previousValue as Ref)[$refId]
|
|
360
|
+
const childRefId = (change.previousValue as Ref)[$refId]!;
|
|
361
361
|
const deleteCallbacks = this.callbacks[childRefId]?.[OPERATION.DELETE];
|
|
362
362
|
if (deleteCallbacks) {
|
|
363
363
|
for (let j = deleteCallbacks.length - 1; j >= 0; j--) {
|
|
@@ -522,8 +522,10 @@ export const Callbacks = {
|
|
|
522
522
|
return getDecoderStateCallbacks(roomOrDecoder);
|
|
523
523
|
|
|
524
524
|
} else if ('decoder' in roomOrDecoder.serializer) {
|
|
525
|
-
return getDecoderStateCallbacks(roomOrDecoder.serializer.decoder);
|
|
525
|
+
return getDecoderStateCallbacks((roomOrDecoder.serializer as { decoder: Decoder<T> }).decoder);
|
|
526
526
|
}
|
|
527
|
+
|
|
528
|
+
throw new Error('Invalid room or decoder');
|
|
527
529
|
},
|
|
528
530
|
|
|
529
531
|
getRawChanges(decoder: Decoder, callback: (changes: DataChange[]) => void) {
|
package/src/encoder/Encoder.ts
CHANGED
|
@@ -442,8 +442,19 @@ export class Encoder<T extends Schema = any> {
|
|
|
442
442
|
// view.changes with the stream-link ADD + element-field ADDs.
|
|
443
443
|
this._emitStreamPriority(view);
|
|
444
444
|
|
|
445
|
-
//
|
|
446
|
-
|
|
445
|
+
//
|
|
446
|
+
// `view.changes` Map insertion order IS topological order:
|
|
447
|
+
// - `view.add` walks the parent chain to root via `addParentOf`
|
|
448
|
+
// (depth-first ancestor-first), inserting every ancestor's
|
|
449
|
+
// entry before the descendant's.
|
|
450
|
+
// - `view.remove` calls `_touchAncestorsOf` before its own
|
|
451
|
+
// write to insert any missing ancestors at the front of the
|
|
452
|
+
// chain — empty entries that get skipped by the size==0
|
|
453
|
+
// check below but establish Map position.
|
|
454
|
+
// No per-encode topo sort needed.
|
|
455
|
+
//
|
|
456
|
+
for (const refId of view.changes.keys()) {
|
|
457
|
+
const changes = view.changes.get(refId);
|
|
447
458
|
const changeTree: ChangeTree = this.root.changeTrees[refId];
|
|
448
459
|
|
|
449
460
|
if (changeTree === undefined) {
|
package/src/encoder/StateView.ts
CHANGED
|
@@ -498,13 +498,28 @@ export class StateView {
|
|
|
498
498
|
// view must have all "changeTree" parent tree
|
|
499
499
|
this.markVisible(changeTree);
|
|
500
500
|
|
|
501
|
-
//
|
|
501
|
+
// Recurse all the way to the root regardless of whether the
|
|
502
|
+
// parent is filtered. Walking the full chain is what makes
|
|
503
|
+
// `view.changes` topologically ordered by construction — any
|
|
504
|
+
// filtered ancestor up the chain is touched here, before the
|
|
505
|
+
// descendant's entry. The actual entry-write below is gated
|
|
506
|
+
// on `hasFilteredFields` so non-filtered ancestors don't
|
|
507
|
+
// emit redundant wire bytes (the decoder already knows them
|
|
508
|
+
// via the shared encode pass). Marking them visible is
|
|
509
|
+
// still useful: it makes this short-circuit fire on the next
|
|
510
|
+
// `view.add` instead of re-walking the chain.
|
|
502
511
|
const parentChangeTree: ChangeTree = changeTree.parent?.[$changes];
|
|
503
|
-
if (parentChangeTree
|
|
512
|
+
if (parentChangeTree) {
|
|
504
513
|
this.addParentOf(changeTree, tag);
|
|
505
514
|
}
|
|
506
515
|
}
|
|
507
516
|
|
|
517
|
+
// Skip the entry-write for non-filtered ancestors: their refIds
|
|
518
|
+
// are already known to the decoder through the shared pass, and
|
|
519
|
+
// an extra ADD on a non-filtered field's index would only emit
|
|
520
|
+
// bytes for a no-op (`value === previousValue` on the decoder).
|
|
521
|
+
if (!changeTree.hasFilteredFields) return;
|
|
522
|
+
|
|
508
523
|
// add parent's tag properties
|
|
509
524
|
if (changeTree.getChange(parentIndex) !== OPERATION.DELETE) {
|
|
510
525
|
let changes = this.changes.get(changeTree.ref[$refId]);
|
|
@@ -519,6 +534,46 @@ export class StateView {
|
|
|
519
534
|
}
|
|
520
535
|
}
|
|
521
536
|
|
|
537
|
+
/**
|
|
538
|
+
* Walk `tree`'s parent chain to root and insert an empty entry into
|
|
539
|
+
* `view.changes` for any ancestor not already present. Empty entries
|
|
540
|
+
* are skipped by `encodeView` (`changes.size === 0` continue), so no
|
|
541
|
+
* wire bytes are emitted — but the Map's insertion order now puts
|
|
542
|
+
* each ancestor BEFORE the descendant entry that the caller is about
|
|
543
|
+
* to write. Combined with `addParentOf`'s full-recursion walk on
|
|
544
|
+
* `view.add`, this preserves the global invariant that
|
|
545
|
+
* `view.changes` iteration order is topological.
|
|
546
|
+
*
|
|
547
|
+
* Iterative (not recursive) so the stack is bounded by tree depth
|
|
548
|
+
* regardless of call patterns. Stops the walk as soon as it hits an
|
|
549
|
+
* ancestor that's already in `view.changes` — at that point the
|
|
550
|
+
* remainder of the chain is guaranteed to also be present (invariant
|
|
551
|
+
* upheld by every prior caller).
|
|
552
|
+
*/
|
|
553
|
+
private _touchAncestorsOf(tree: ChangeTree): void {
|
|
554
|
+
let cursor = tree.parent?.[$changes] as ChangeTree | undefined;
|
|
555
|
+
if (cursor === undefined) return;
|
|
556
|
+
|
|
557
|
+
// Collect the missing prefix of the chain, deepest-first. Only
|
|
558
|
+
// FILTERED ancestors need entries — non-filtered ones never
|
|
559
|
+
// appear in `view.changes` (mirrors the addParentOf gate), so
|
|
560
|
+
// they don't need a Map slot reserved either.
|
|
561
|
+
const stack: ChangeTree[] = [];
|
|
562
|
+
while (cursor !== undefined) {
|
|
563
|
+
if (cursor.hasFilteredFields) {
|
|
564
|
+
const refId = cursor.ref[$refId];
|
|
565
|
+
if (this.changes.has(refId)) break;
|
|
566
|
+
stack.push(cursor);
|
|
567
|
+
}
|
|
568
|
+
cursor = cursor.parent?.[$changes] as ChangeTree | undefined;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Insert root-first so Map order is topological.
|
|
572
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
573
|
+
this.changes.set(stack[i].ref[$refId], new Map());
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
522
577
|
remove(obj: Ref, tag?: number): this; // hide _isClear parameter from public API
|
|
523
578
|
remove(obj: Ref, tag?: number, _isClear?: boolean): this;
|
|
524
579
|
remove(obj: Ref, tag: number = DEFAULT_VIEW_TAG, _isClear: boolean = false): this {
|
|
@@ -594,6 +649,12 @@ export class StateView {
|
|
|
594
649
|
|
|
595
650
|
const refId = ref[$refId];
|
|
596
651
|
|
|
652
|
+
// Pre-insert any missing ancestors into view.changes so the Map's
|
|
653
|
+
// iteration order stays topological — the entries we're about to
|
|
654
|
+
// write (either on this obj, or on its parent collection below)
|
|
655
|
+
// must come AFTER every ancestor in the chain on the wire.
|
|
656
|
+
this._touchAncestorsOf(changeTree);
|
|
657
|
+
|
|
597
658
|
let changes = this.changes.get(refId);
|
|
598
659
|
if (changes === undefined) {
|
|
599
660
|
changes = new Map<number, OPERATION>();
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* the parent field's annotation + the parent tree's own state.
|
|
5
5
|
*/
|
|
6
6
|
import { Metadata } from "../../Metadata.js";
|
|
7
|
+
import { DEFAULT_VIEW_TAG } from "../../annotations.js";
|
|
7
8
|
import {
|
|
8
9
|
$changes, $childType,
|
|
9
10
|
$staticFieldIndexes, $streamFieldIndexes,
|
|
@@ -206,12 +207,25 @@ export function checkInheritedFlags(tree: ChangeTree, parent: Ref, parentIndex:
|
|
|
206
207
|
const refType = Metadata.isValidInstance(tree.ref)
|
|
207
208
|
? tree.ref.constructor
|
|
208
209
|
: (tree.ref as any)[$childType];
|
|
210
|
+
// #218: nested Schema fields inherit visibility from a @view-gated
|
|
211
|
+
// parent regardless of whether the parent is a collection. The
|
|
212
|
+
// `parentIsCollection` constraint that used to live here blocked
|
|
213
|
+
// nested-Schema-field-of-@view-tagged-Schema from sharing visibility,
|
|
214
|
+
// forcing users to wrap the child in an ArraySchema as a workaround.
|
|
215
|
+
//
|
|
216
|
+
// #226 (4.0.25): items inside a non-default-tag `@view(N)` collection
|
|
217
|
+
// also inherit visibility from the parent collection, so items
|
|
218
|
+
// pushed/set after `view.add(state, N)` show up automatically.
|
|
219
|
+
// Default-tag `@view()` collections keep per-item gating —
|
|
220
|
+
// `view.add(item)` is still required to opt each one in.
|
|
221
|
+
// The `parentMetadata[parentIndex].tag` access is safe inside the
|
|
222
|
+
// `fieldHasViewTag` short-circuit (the metadata entry and its `tag`
|
|
223
|
+
// are guaranteed to exist when that flag is set).
|
|
209
224
|
tree.isVisibilitySharedWithParent = (
|
|
210
225
|
parentChangeTree.isFiltered
|
|
211
226
|
&& typeof refType !== "string"
|
|
212
|
-
&& !fieldHasViewTag
|
|
213
227
|
&& !fieldHasStream
|
|
214
|
-
&& parentIsCollection
|
|
228
|
+
&& (!fieldHasViewTag || (parentIsCollection && parentMetadata[parentIndex].tag !== DEFAULT_VIEW_TAG))
|
|
215
229
|
);
|
|
216
230
|
}
|
|
217
231
|
}
|