@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.
- package/README.md +7 -2
- package/build/Metadata.d.ts +10 -1
- package/build/codegen/api.d.ts +2 -0
- package/build/codegen/cli.cjs +322 -31
- package/build/codegen/cli.cjs.map +1 -1
- package/build/codegen/parser.d.ts +6 -1
- package/build/codegen/resolve.d.ts +25 -0
- package/build/codegen/types.d.ts +2 -0
- package/build/encoder/ChangeTree.d.ts +40 -12
- package/build/encoder/Encoder.d.ts +1 -1
- package/build/encoder/Root.d.ts +9 -0
- package/build/encoder/StateView.d.ts +38 -1
- package/build/encoder/changeTree/inheritedFlags.d.ts +13 -19
- package/build/encoder/changeTree/liveIteration.d.ts +8 -0
- package/build/encoder/changeTree/parentChain.d.ts +30 -8
- package/build/encoder/streaming.d.ts +1 -1
- package/build/index.cjs +3232 -2830
- package/build/index.cjs.map +1 -1
- package/build/index.js +3228 -2826
- package/build/index.mjs +3232 -2830
- package/build/index.mjs.map +1 -1
- package/build/types/TypeContext.d.ts +0 -17
- package/build/types/builder.d.ts +1 -5
- package/build/types/symbols.d.ts +1 -0
- package/package.json +1 -1
- package/src/Metadata.ts +59 -77
- package/src/Reflection.ts +9 -5
- package/src/annotations.ts +19 -13
- package/src/codegen/api.ts +3 -1
- package/src/codegen/cli.ts +5 -2
- package/src/codegen/parser.ts +69 -31
- package/src/codegen/resolve.ts +322 -0
- package/src/codegen/types.ts +4 -1
- package/src/decoder/DecodeOperation.ts +13 -2
- package/src/encoder/ChangeTree.ts +76 -25
- package/src/encoder/EncodeOperation.ts +10 -1
- package/src/encoder/Encoder.ts +52 -2
- package/src/encoder/Root.ts +28 -8
- package/src/encoder/StateView.ts +150 -66
- package/src/encoder/changeTree/inheritedFlags.ts +164 -45
- package/src/encoder/changeTree/liveIteration.ts +24 -3
- package/src/encoder/changeTree/parentChain.ts +72 -15
- package/src/encoder/streaming.ts +2 -1
- package/src/types/TypeContext.ts +5 -52
- package/src/types/builder.ts +14 -10
- package/src/types/custom/ArraySchema.ts +57 -14
- package/src/types/symbols.ts +3 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
|
|
5
|
+
import { PACKAGE_ROOT } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export interface ResolveOptions {
|
|
8
|
+
/** Explicit `--tsconfig`. When set, nearest-config discovery is skipped. */
|
|
9
|
+
tsconfig?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ResolvedConfig {
|
|
13
|
+
configFilePath: string;
|
|
14
|
+
options: ts.CompilerOptions;
|
|
15
|
+
cache?: ts.ModuleResolutionCache;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface MatchedPattern {
|
|
19
|
+
substitutions: string[];
|
|
20
|
+
/** What `*` captured, so substitutions can splice it back in. */
|
|
21
|
+
matchedStar: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const CONFIG_NAMES = ["tsconfig.json", "jsconfig.json"];
|
|
25
|
+
|
|
26
|
+
/** `No inputs were found in config file` — expected, since readDirectory is stubbed. */
|
|
27
|
+
const NO_INPUTS_FOUND = 18003;
|
|
28
|
+
|
|
29
|
+
let configByDir: Map<string, ResolvedConfig | null>;
|
|
30
|
+
let override: ResolvedConfig | null | undefined;
|
|
31
|
+
let resolveOptions: ResolveOptions;
|
|
32
|
+
let warned: Set<string>;
|
|
33
|
+
|
|
34
|
+
reset();
|
|
35
|
+
|
|
36
|
+
function reset() {
|
|
37
|
+
configByDir = new Map();
|
|
38
|
+
override = undefined;
|
|
39
|
+
resolveOptions = {};
|
|
40
|
+
warned = new Set();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Drop every cached tsconfig lookup. Called once per top-level `parseFiles()`
|
|
45
|
+
* run so a long-lived process can generate for two different projects.
|
|
46
|
+
*/
|
|
47
|
+
export function resetResolver(options: ResolveOptions = {}) {
|
|
48
|
+
reset();
|
|
49
|
+
resolveOptions = options;
|
|
50
|
+
|
|
51
|
+
if (options.tsconfig && !fs.existsSync(options.tsconfig)) {
|
|
52
|
+
throw new Error(`--tsconfig: file not found: ${options.tsconfig}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function warnOnce(key: string, message: string) {
|
|
57
|
+
if (warned.has(key)) { return; }
|
|
58
|
+
warned.add(key);
|
|
59
|
+
console.warn(message);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `readDirectory` is stubbed on purpose: only `compilerOptions` is wanted here,
|
|
64
|
+
* and letting TypeScript glob the config's `include` set would stat the user's
|
|
65
|
+
* whole project on every config discovered.
|
|
66
|
+
*/
|
|
67
|
+
const parseConfigHost: ts.ParseConfigHost = {
|
|
68
|
+
useCaseSensitiveFileNames: ts.sys?.useCaseSensitiveFileNames ?? true,
|
|
69
|
+
readDirectory: () => [],
|
|
70
|
+
fileExists: (fileName) => fs.existsSync(fileName),
|
|
71
|
+
readFile: (fileName) => {
|
|
72
|
+
try {
|
|
73
|
+
return fs.readFileSync(fileName, "utf8");
|
|
74
|
+
} catch (e) {
|
|
75
|
+
if (!(e as any)?.code) { throw e; }
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function loadConfig(configFilePath: string): ResolvedConfig | null {
|
|
82
|
+
const { config, error } = ts.readConfigFile(configFilePath, parseConfigHost.readFile);
|
|
83
|
+
if (error) {
|
|
84
|
+
warnOnce(configFilePath,
|
|
85
|
+
`schema-codegen: could not read "${configFilePath}" ` +
|
|
86
|
+
`(${ts.flattenDiagnosticMessageText(error.messageText, " ")}) — ` +
|
|
87
|
+
`its import path aliases will be ignored.`);
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// parseJsonConfigFileContent (not convertCompilerOptionsFromJson) is what
|
|
92
|
+
// applies `extends` chains, `${configDir}` templates, and `pathsBasePath` —
|
|
93
|
+
// the directory of the config that DECLARED `paths`, which in a monorepo is
|
|
94
|
+
// not the directory of the config being loaded.
|
|
95
|
+
const parsed = ts.parseJsonConfigFileContent(
|
|
96
|
+
config,
|
|
97
|
+
parseConfigHost,
|
|
98
|
+
path.dirname(configFilePath),
|
|
99
|
+
undefined,
|
|
100
|
+
configFilePath,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const errors = parsed.errors.filter((d) =>
|
|
104
|
+
d.code !== NO_INPUTS_FOUND && d.category === ts.DiagnosticCategory.Error);
|
|
105
|
+
|
|
106
|
+
if (errors.length > 0) {
|
|
107
|
+
warnOnce(configFilePath,
|
|
108
|
+
`schema-codegen: "${configFilePath}" has errors — ` +
|
|
109
|
+
errors.map((d) => ts.flattenDiagnosticMessageText(d.messageText, " ")).join("; "));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const options = parsed.options;
|
|
113
|
+
if (!options.paths && !options.baseUrl) { return null; }
|
|
114
|
+
|
|
115
|
+
const getCanonicalFileName = parseConfigHost.useCaseSensitiveFileNames
|
|
116
|
+
? (f: string) => f
|
|
117
|
+
: (f: string) => f.toLowerCase();
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
configFilePath,
|
|
121
|
+
options,
|
|
122
|
+
cache: ts.createModuleResolutionCache(
|
|
123
|
+
path.dirname(configFilePath), getCanonicalFileName, options),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function getOverrideConfig(): ResolvedConfig | null {
|
|
128
|
+
if (override === undefined) {
|
|
129
|
+
override = loadConfig(path.resolve(resolveOptions.tsconfig));
|
|
130
|
+
if (override === null) {
|
|
131
|
+
warnOnce(`no-aliases:${resolveOptions.tsconfig}`,
|
|
132
|
+
`schema-codegen: "${resolveOptions.tsconfig}" declares no "paths" or ` +
|
|
133
|
+
`"baseUrl" — there are no import aliases to resolve.`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return override;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Nearest `tsconfig.json`/`jsconfig.json` above `containingFile`. Both names are
|
|
141
|
+
* checked at every level: a distant tsconfig.json must not win over an adjacent
|
|
142
|
+
* jsconfig.json. Stops at the first config found even when it declares no
|
|
143
|
+
* aliases — matching `tsc`, a parent project's `paths` do not leak into a child
|
|
144
|
+
* that does not `extends` it.
|
|
145
|
+
*/
|
|
146
|
+
function getConfigFor(containingFile: string): ResolvedConfig | null {
|
|
147
|
+
if (resolveOptions.tsconfig) { return getOverrideConfig(); }
|
|
148
|
+
|
|
149
|
+
const dir = path.dirname(containingFile);
|
|
150
|
+
if (configByDir.has(dir)) { return configByDir.get(dir); }
|
|
151
|
+
|
|
152
|
+
let config: ResolvedConfig | null = null;
|
|
153
|
+
const visited: string[] = [];
|
|
154
|
+
|
|
155
|
+
for (let current = dir, parent: string; ; current = parent) {
|
|
156
|
+
visited.push(current);
|
|
157
|
+
|
|
158
|
+
const found = CONFIG_NAMES
|
|
159
|
+
.map((name) => path.join(current, name))
|
|
160
|
+
.find((candidate) => fs.existsSync(candidate));
|
|
161
|
+
|
|
162
|
+
if (found) {
|
|
163
|
+
config = loadConfig(found);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
parent = path.dirname(current);
|
|
168
|
+
if (parent === current) { break; }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// memoize the whole walk, negatives included
|
|
172
|
+
visited.forEach((visitedDir) => configByDir.set(visitedDir, config));
|
|
173
|
+
|
|
174
|
+
return config;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Exact patterns win outright; among wildcards the longest prefix wins. */
|
|
178
|
+
function findBestPathPattern(specifier: string, paths: ts.MapLike<string[]>): MatchedPattern | undefined {
|
|
179
|
+
let best: MatchedPattern | undefined;
|
|
180
|
+
let bestPrefixLength = -1;
|
|
181
|
+
|
|
182
|
+
for (const pattern in paths) {
|
|
183
|
+
const star = pattern.indexOf("*");
|
|
184
|
+
|
|
185
|
+
if (star === -1) {
|
|
186
|
+
if (pattern === specifier) {
|
|
187
|
+
return { substitutions: paths[pattern], matchedStar: "" };
|
|
188
|
+
}
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const prefix = pattern.slice(0, star);
|
|
193
|
+
const suffix = pattern.slice(star + 1);
|
|
194
|
+
|
|
195
|
+
if (
|
|
196
|
+
specifier.length >= prefix.length + suffix.length &&
|
|
197
|
+
specifier.startsWith(prefix) &&
|
|
198
|
+
specifier.endsWith(suffix) &&
|
|
199
|
+
prefix.length > bestPrefixLength
|
|
200
|
+
) {
|
|
201
|
+
bestPrefixLength = prefix.length;
|
|
202
|
+
best = {
|
|
203
|
+
substitutions: paths[pattern],
|
|
204
|
+
matchedStar: specifier.slice(prefix.length, specifier.length - suffix.length),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return best;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function resolveViaPathsSubstitution(matched: MatchedPattern, options: ts.CompilerOptions): string | undefined {
|
|
213
|
+
// mirrors ts.getPathsBasePath(): `paths` may be declared without a baseUrl,
|
|
214
|
+
// in which case it anchors on the config that declared it
|
|
215
|
+
const base = options.baseUrl ?? (options as any).pathsBasePath ?? process.cwd();
|
|
216
|
+
|
|
217
|
+
for (const substitution of matched.substitutions) {
|
|
218
|
+
const resolved = resolveSourceFile(
|
|
219
|
+
path.resolve(base, substitution.replace("*", matched.matchedStar)));
|
|
220
|
+
if (resolved) { return resolved; }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const isDeclaration = (fileName: string) => /\.d\.[cm]?ts$/.test(fileName);
|
|
227
|
+
const isInNodeModules = (fileName: string) => fileName.replace(/\\/g, "/").includes("/node_modules/");
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Resolve a non-relative import (`@schemas/Player`, `shared/Player`) to a
|
|
231
|
+
* first-party source file through the tsconfig governing `containingFile`.
|
|
232
|
+
* Returns undefined for npm packages, declaration files, and specifiers no
|
|
233
|
+
* alias covers.
|
|
234
|
+
*/
|
|
235
|
+
export function resolveNonRelativeImport(specifier: string, containingFile: string): string | undefined {
|
|
236
|
+
const config = getConfigFor(containingFile);
|
|
237
|
+
if (!config) { return undefined; }
|
|
238
|
+
|
|
239
|
+
const { options } = config;
|
|
240
|
+
const matched = options.paths && findBestPathPattern(specifier, options.paths);
|
|
241
|
+
|
|
242
|
+
// no alias hit and no baseUrl: TypeScript could only find this under
|
|
243
|
+
// node_modules, which costs ~130 failed lookups to prove
|
|
244
|
+
if (!matched && !options.baseUrl) { return undefined; }
|
|
245
|
+
|
|
246
|
+
const resolved = ts.resolveModuleName(
|
|
247
|
+
specifier, containingFile, options, ts.sys, config.cache).resolvedModule;
|
|
248
|
+
|
|
249
|
+
if (resolved) {
|
|
250
|
+
// a deliberate package/typings hit — not ours to parse, and the
|
|
251
|
+
// substitution fallback must not second-guess it
|
|
252
|
+
return (
|
|
253
|
+
resolved.isExternalLibraryImport ||
|
|
254
|
+
isDeclaration(resolved.resolvedFileName) ||
|
|
255
|
+
isInNodeModules(resolved.resolvedFileName)
|
|
256
|
+
) ? undefined
|
|
257
|
+
: path.resolve(resolved.resolvedFileName);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// `.mjs` targets are unresolvable by ts.resolveModuleName in every
|
|
261
|
+
// moduleResolution mode, but schema-codegen parses them
|
|
262
|
+
const viaSubstitution = matched && resolveViaPathsSubstitution(matched, options);
|
|
263
|
+
if (viaSubstitution) { return viaSubstitution; }
|
|
264
|
+
|
|
265
|
+
if (matched) {
|
|
266
|
+
warnOnce(`unresolved:${specifier}`,
|
|
267
|
+
`schema-codegen: '${specifier}' matches a "paths" alias in ` +
|
|
268
|
+
`${config.configFilePath}, but no source file was found for it — ` +
|
|
269
|
+
`schemas it exports will be missing from the generated output.`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The extension alternatives parseFiles() probes, in order. Pure — no fs. */
|
|
276
|
+
export function sourceFileCandidates(fileName: string): string[] {
|
|
277
|
+
if (
|
|
278
|
+
!fileName.endsWith(".ts") &&
|
|
279
|
+
!fileName.endsWith(".js") &&
|
|
280
|
+
!fileName.endsWith(".mjs")
|
|
281
|
+
) {
|
|
282
|
+
return [`${fileName}.ts`, `${fileName}/index.ts`];
|
|
283
|
+
|
|
284
|
+
} else if (fileName.endsWith(".js")) {
|
|
285
|
+
// ESM imports often spell a .ts source with a .js extension
|
|
286
|
+
return [fileName, fileName.replace(/\.js$/, ".ts")];
|
|
287
|
+
|
|
288
|
+
} else {
|
|
289
|
+
return [fileName];
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Same probing as parseFiles(), answering "which candidate exists?". */
|
|
294
|
+
export function resolveSourceFile(fileName: string): string | undefined {
|
|
295
|
+
const candidates = sourceFileCandidates(fileName);
|
|
296
|
+
|
|
297
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
298
|
+
const candidate = path.resolve(candidates[i]);
|
|
299
|
+
try {
|
|
300
|
+
// statSync, not existsSync: a directory must fall through to the
|
|
301
|
+
// next candidate, the way readFileSync's EISDIR does
|
|
302
|
+
if (fs.statSync(candidate).isFile()) { return candidate; }
|
|
303
|
+
} catch (e) {
|
|
304
|
+
if (!(e as any)?.code) { throw e; }
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The serializer's own source declares wire-internal schemas (`Reflection`,
|
|
313
|
+
* `ReflectionField`, …) that must never reach generated client code.
|
|
314
|
+
*/
|
|
315
|
+
export function isOwnPackageSource(fileName: string): boolean {
|
|
316
|
+
const relative = path.relative(PACKAGE_ROOT, fileName);
|
|
317
|
+
return (
|
|
318
|
+
!relative.startsWith("..") &&
|
|
319
|
+
!path.isAbsolute(relative) &&
|
|
320
|
+
(relative.startsWith(`src${path.sep}`) || relative.startsWith(`build${path.sep}`))
|
|
321
|
+
);
|
|
322
|
+
}
|
package/src/codegen/types.ts
CHANGED
|
@@ -5,7 +5,10 @@ if (typeof(__dirname) === "undefined") {
|
|
|
5
5
|
global.__dirname = path.dirname(new URL(import.meta.url).pathname);
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/** Root of the @colyseus/schema package — `src/codegen/` in dev, `build/codegen/` once bundled. */
|
|
9
|
+
export const PACKAGE_ROOT = path.resolve(__dirname, "..", "..");
|
|
10
|
+
|
|
11
|
+
const VERSION = JSON.parse(fs.readFileSync(path.resolve(PACKAGE_ROOT, "package.json")).toString()).version;
|
|
9
12
|
const COMMENT_HEADER = `
|
|
10
13
|
THIS FILE HAS BEEN GENERATED AUTOMATICALLY
|
|
11
14
|
DO NOT CHANGE IT MANUALLY UNLESS YOU KNOW WHAT YOU'RE DOING
|
|
@@ -240,7 +240,12 @@ export const decodeSchemaOperation: DecodeOperation = function <T extends Schema
|
|
|
240
240
|
return DEFINITION_MISMATCH;
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
-
|
|
243
|
+
// a peer that still carries a @deprecated() field keeps sending it — the
|
|
244
|
+
// bytes must be consumed or the stream desyncs, but the local accessor
|
|
245
|
+
// may throw: read nothing, write nothing, report nothing.
|
|
246
|
+
const isDeprecated = field.deprecated === true;
|
|
247
|
+
|
|
248
|
+
const previousValue = isDeprecated ? undefined : ref[$getByIndex](index);
|
|
244
249
|
const value = decodeValue(
|
|
245
250
|
decoder,
|
|
246
251
|
operation,
|
|
@@ -253,6 +258,8 @@ export const decodeSchemaOperation: DecodeOperation = function <T extends Schema
|
|
|
253
258
|
allChanges,
|
|
254
259
|
);
|
|
255
260
|
|
|
261
|
+
if (isDeprecated) { return; }
|
|
262
|
+
|
|
256
263
|
if (value !== null && value !== undefined) {
|
|
257
264
|
// Write via the generated setter. Bypass to `(ref as any)[$values][index]`
|
|
258
265
|
// was attempted but only works for @type-decorated classes (which
|
|
@@ -430,7 +437,11 @@ export const decodeArray: DecodeOperation = function (
|
|
|
430
437
|
return;
|
|
431
438
|
|
|
432
439
|
} else if (operation === OPERATION.REVERSE) {
|
|
433
|
-
|
|
440
|
+
// Positional reverse of the decoder's authoritative storage. Don't
|
|
441
|
+
// call `tgt.reverse()` — that's the encoder-side method, and its
|
|
442
|
+
// dirty-tick check would misread the stale recorder a `clone(true)`
|
|
443
|
+
// instance carries.
|
|
444
|
+
tgt.items.reverse();
|
|
434
445
|
return;
|
|
435
446
|
|
|
436
447
|
} else if (operation === OPERATION.DELETE_BY_REFID) {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { OPERATION } from "../encoding/spec.js";
|
|
19
19
|
import { Schema } from "../Schema.js";
|
|
20
|
-
import { $changes, $childType, $decoder, $onEncodeEnd, $encoder, $getByIndex, $
|
|
20
|
+
import { $changes, $childType, $decoder, $onEncodeEnd, $encoder, $getByIndex, $refId, $refTypeFieldIndexes, $numFields, type $deleteByIndex } from "../types/symbols.js";
|
|
21
21
|
|
|
22
22
|
import type { MapSchema } from "../types/custom/MapSchema.js";
|
|
23
23
|
import type { ArraySchema } from "../types/custom/ArraySchema.js";
|
|
@@ -27,7 +27,7 @@ import type { StreamSchema } from "../types/custom/StreamSchema.js";
|
|
|
27
27
|
|
|
28
28
|
import { Root } from "./Root.js";
|
|
29
29
|
import { Metadata } from "../Metadata.js";
|
|
30
|
-
import { type ChangeRecorder,
|
|
30
|
+
import { type ChangeRecorder, SchemaChangeRecorder, CollectionChangeRecorder, popcount32 } from "./ChangeRecorder.js";
|
|
31
31
|
import type { EncodeOperation } from "./EncodeOperation.js";
|
|
32
32
|
import { type EncodeDescriptor, getEncodeDescriptor } from "./EncodeDescriptor.js";
|
|
33
33
|
import type { DecodeOperation } from "../decoder/DecodeOperation.js";
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
setParentIndex as _setParentIndex,
|
|
38
38
|
findParent as _findParent, hasParent as _hasParent,
|
|
39
39
|
getAllParents as _getAllParents,
|
|
40
|
+
indexInParent as _indexInParent,
|
|
40
41
|
} from "./changeTree/parentChain.js";
|
|
41
42
|
import { forEachLive as _forEachLive, forEachLiveWithCtx as _forEachLiveWithCtx } from "./changeTree/liveIteration.js";
|
|
42
43
|
import {
|
|
@@ -113,12 +114,27 @@ export function createChangeTreeList(): ChangeTreeList {
|
|
|
113
114
|
return { next: undefined, tail: undefined, nextPosition: 0 };
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Live node in a tree's parent chain — mutating one edits the chain. Only
|
|
119
|
+
* `parentChain.ts` should hold these.
|
|
120
|
+
*/
|
|
116
121
|
export interface ParentChain {
|
|
117
122
|
ref: Ref;
|
|
118
123
|
index: number;
|
|
119
124
|
next?: ParentChain;
|
|
120
125
|
}
|
|
121
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Detached copy of one parent link, handed out by the query helpers. Distinct
|
|
129
|
+
* from `ParentChain` on purpose: it carries no `next`, so it cannot be walked
|
|
130
|
+
* as if it were the chain, and it is readonly, so it cannot be mistaken for a
|
|
131
|
+
* way to move a parent's index — `setParentIndex` does that.
|
|
132
|
+
*/
|
|
133
|
+
export interface ParentEntry {
|
|
134
|
+
readonly ref: Ref;
|
|
135
|
+
readonly index: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
122
138
|
// Flags bitfield. *_UNRELIABLE / _PATCH_ONLY / _STATIC mirror the parent
|
|
123
139
|
// field's annotation — inherited at setParent/setRoot time.
|
|
124
140
|
export const IS_FILTERED = 1, IS_VISIBILITY_SHARED = 2, IS_NEW = 4;
|
|
@@ -135,6 +151,11 @@ export const IS_STREAM_COLLECTION = 64;
|
|
|
135
151
|
// only fields assigned after `pool.acquire()` would reach the wire — the
|
|
136
152
|
// retained ones (constructor-initialized children) would never be encoded.
|
|
137
153
|
export const NEEDS_RESTAGE = 128;
|
|
154
|
+
// Queued in `Root.pendingFilterRefresh` — the tree's parent-edge set changed
|
|
155
|
+
// (instance sharing gained/lost an edge) and `isFiltered` /
|
|
156
|
+
// `isVisibilitySharedWithParent` must be re-derived from the LIVE edges
|
|
157
|
+
// before the next encode. See inheritedFlags.refreshFilterState.
|
|
158
|
+
export const PENDING_FILTER_REFRESH = 256;
|
|
138
159
|
/**
|
|
139
160
|
* Flags a child inherits from its parent's own transitive state via
|
|
140
161
|
* `checkInheritedFlags`. Read as a bitwise mask so the inheritance step
|
|
@@ -169,6 +190,14 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
|
|
|
169
190
|
*/
|
|
170
191
|
refTarget: T;
|
|
171
192
|
|
|
193
|
+
/**
|
|
194
|
+
* True when `ref` is an ArraySchema — the only proxied type, so its
|
|
195
|
+
* user-facing identity differs from `refTarget`. Canonical predicate for
|
|
196
|
+
* "is this tree's ref an array" without probing `ref` (which would hit
|
|
197
|
+
* the Proxy trap) — two monomorphic loads on the tree itself.
|
|
198
|
+
*/
|
|
199
|
+
get isArray(): boolean { return this.refTarget !== this.ref; }
|
|
200
|
+
|
|
172
201
|
metadata: Metadata;
|
|
173
202
|
|
|
174
203
|
/**
|
|
@@ -279,8 +308,7 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
|
|
|
279
308
|
|
|
280
309
|
ensureUnreliableRecorder(): ChangeRecorder {
|
|
281
310
|
if (this.unreliableRecorder === undefined) {
|
|
282
|
-
|
|
283
|
-
this.unreliableRecorder = isSchema
|
|
311
|
+
this.unreliableRecorder = this._isSchema
|
|
284
312
|
? new SchemaChangeRecorder((this.metadata?.[$numFields] ?? 0) as number)
|
|
285
313
|
: new CollectionChangeRecorder();
|
|
286
314
|
}
|
|
@@ -325,12 +353,13 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
|
|
|
325
353
|
return Metadata.hasStreamAtIndex(this.metadata, index);
|
|
326
354
|
}
|
|
327
355
|
|
|
328
|
-
constructor(ref: T) {
|
|
356
|
+
constructor(ref: T, refTarget: T = ref) {
|
|
329
357
|
this.ref = ref;
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
|
|
358
|
+
// Raw (non-Proxy) target, passed explicitly by ArraySchema's ctor —
|
|
359
|
+
// the only proxied type. Defaulting to `ref` for everything else
|
|
360
|
+
// skips a guaranteed-miss megamorphic `$proxyTarget` probe per
|
|
361
|
+
// construction. Cached so hot-path reads skip the Proxy `get` trap.
|
|
362
|
+
this.refTarget = refTarget;
|
|
334
363
|
|
|
335
364
|
// Single per-class lookup that subsumes Symbol.metadata,
|
|
336
365
|
// isValidInstance, $encoder, $filter, and the filter bitmask.
|
|
@@ -592,28 +621,42 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
|
|
|
592
621
|
}
|
|
593
622
|
|
|
594
623
|
/**
|
|
595
|
-
* ArraySchema
|
|
596
|
-
* `+count`, then record ADDs for
|
|
624
|
+
* ArraySchema insert (unshift / splice with more inserts than deletes):
|
|
625
|
+
* re-key pending ops at or above `at` by `+count`, then record ADDs for
|
|
626
|
+
* the new items at indexes `at..at+count-1`.
|
|
597
627
|
*
|
|
598
|
-
* The rebuilt map's insertion order IS the wire order:
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
628
|
+
* The rebuilt map's insertion order IS the wire order:
|
|
629
|
+
* 1. ops below `at` — the insert doesn't move them, and an insert of
|
|
630
|
+
* their own must still be applied before this one (ascending);
|
|
631
|
+
* 2. the new ADDs, ascending — the decoder splice-inserts each one,
|
|
632
|
+
* which only works lowest-index-first;
|
|
633
|
+
* 3. the re-keyed ops, in their original relative order — their
|
|
634
|
+
* indexes now address the post-insert layout.
|
|
635
|
+
* See ArraySchema#$setAt.
|
|
602
636
|
*/
|
|
603
|
-
|
|
604
|
-
if (this._isSchema) throw new Error("ChangeTree (Schema):
|
|
637
|
+
insertAt(at: number, count: number): void {
|
|
638
|
+
if (this._isSchema) throw new Error("ChangeTree (Schema): insertAt is not supported");
|
|
605
639
|
const src = this.collDirty!;
|
|
606
640
|
const dst = new Map<number, OPERATION>();
|
|
607
641
|
const track = !this.paused && !this.isFullStateOnly;
|
|
642
|
+
if (at > 0) {
|
|
643
|
+
for (const [idx, val] of src) if (idx < at) dst.set(idx, val);
|
|
644
|
+
}
|
|
608
645
|
if (track) {
|
|
609
|
-
for (let i = 0; i < count; i++) dst.set(i, OPERATION.ADD);
|
|
646
|
+
for (let i = 0; i < count; i++) dst.set(at + i, OPERATION.ADD);
|
|
610
647
|
}
|
|
611
|
-
for (const [idx, val] of src) dst.set(idx + count, val);
|
|
648
|
+
for (const [idx, val] of src) if (idx >= at) dst.set(idx + count, val);
|
|
612
649
|
this.collDirty = dst;
|
|
613
|
-
|
|
650
|
+
// no unreliable re-key — collection trees never carry an unreliable
|
|
651
|
+
// recorder (tree-level @unreliable is disabled, see isFieldUnreliable)
|
|
614
652
|
if (track) this.root?.enqueueChangeTree(this);
|
|
615
653
|
}
|
|
616
654
|
|
|
655
|
+
/** ArraySchema#unshift(): insert `count` items at the head. */
|
|
656
|
+
unshift(count: number): void {
|
|
657
|
+
this.insertAt(0, count);
|
|
658
|
+
}
|
|
659
|
+
|
|
617
660
|
// Tree attachment + child iteration — see ./changeTree/treeAttachment.ts.
|
|
618
661
|
setRoot(root: Root): void { _setRoot(this, root); }
|
|
619
662
|
setParent(parent: Ref, root?: Root, parentIndex?: number): void { _setParent(this, parent, root, parentIndex); }
|
|
@@ -762,7 +805,12 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
|
|
|
762
805
|
endEncode() {
|
|
763
806
|
this.reset();
|
|
764
807
|
this.changesNode = undefined;
|
|
765
|
-
|
|
808
|
+
// Every collection class defines [$onEncodeEnd]; Schema never does —
|
|
809
|
+
// probing it was a guaranteed megamorphic miss per drained tree.
|
|
810
|
+
// `?.` stays: a FIELDLESS Schema has no metadata, so its tree is
|
|
811
|
+
// `_isSchema === false` too. refTarget receiver skips ArraySchema's
|
|
812
|
+
// proxy hops.
|
|
813
|
+
if (!this._isSchema) (this.refTarget as any)[$onEncodeEnd]?.();
|
|
766
814
|
this.isNew = false;
|
|
767
815
|
}
|
|
768
816
|
|
|
@@ -770,11 +818,11 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
|
|
|
770
818
|
endEncodeUnreliable() {
|
|
771
819
|
this.unreliableRecorder?.reset();
|
|
772
820
|
this.unreliableChangesNode = undefined;
|
|
773
|
-
(this.
|
|
821
|
+
if (!this._isSchema) (this.refTarget as any)[$onEncodeEnd]?.();
|
|
774
822
|
}
|
|
775
823
|
|
|
776
824
|
discard() {
|
|
777
|
-
(this.
|
|
825
|
+
if (!this._isSchema) (this.refTarget as any)[$onEncodeEnd]?.();
|
|
778
826
|
this.reset();
|
|
779
827
|
this.unreliableRecorder?.reset();
|
|
780
828
|
}
|
|
@@ -811,7 +859,7 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
|
|
|
811
859
|
/** @returns true if parent was found and removed */
|
|
812
860
|
removeParent(parent: Ref = this.parent): boolean { return _removeParent(this, parent); }
|
|
813
861
|
|
|
814
|
-
findParent(predicate: (parent: Ref, index: number) => boolean):
|
|
862
|
+
findParent(predicate: (parent: Ref, index: number) => boolean): ParentEntry | undefined {
|
|
815
863
|
return _findParent(this, predicate);
|
|
816
864
|
}
|
|
817
865
|
|
|
@@ -819,7 +867,10 @@ export class ChangeTree<T extends Ref = any> implements ChangeRecorder {
|
|
|
819
867
|
return _hasParent(this, predicate);
|
|
820
868
|
}
|
|
821
869
|
|
|
822
|
-
|
|
870
|
+
/** Wire index this tree holds inside `parent`, or undefined if not a parent. */
|
|
871
|
+
indexInParent(parent: Ref): number | undefined { return _indexInParent(this, parent); }
|
|
872
|
+
|
|
873
|
+
getAllParents(): ParentEntry[] { return _getAllParents(this); }
|
|
823
874
|
|
|
824
875
|
}
|
|
825
876
|
|
|
@@ -242,8 +242,17 @@ export const encodeArray: EncodeOperation = function (
|
|
|
242
242
|
if (operation === OPERATION.DELETE) {
|
|
243
243
|
operation = OPERATION.DELETE_BY_REFID;
|
|
244
244
|
|
|
245
|
-
} else if (operation === OPERATION.ADD) {
|
|
245
|
+
} else if ((operation & OPERATION.ADD) === OPERATION.ADD) {
|
|
246
|
+
// ADD, DELETE_AND_ADD, MOVE_AND_ADD. The wire index below is a
|
|
247
|
+
// refId — positional ops would make the decoder misread it as a
|
|
248
|
+
// slot, so everything must degrade to a BY_REFID op here.
|
|
246
249
|
operation = OPERATION.ADD_BY_REFID;
|
|
250
|
+
|
|
251
|
+
} else if ((operation & OPERATION.MOVE) === OPERATION.MOVE) {
|
|
252
|
+
// Pure reorder (MOVE / DELETE_AND_MOVE). Filtered clients hold
|
|
253
|
+
// per-view subsets, so element order is not synchronized for
|
|
254
|
+
// them (ADD_BY_REFID appends) — there is nothing to emit.
|
|
255
|
+
return;
|
|
247
256
|
}
|
|
248
257
|
|
|
249
258
|
} else if (operation === OPERATION.DELETE && isSchemaChild) {
|