@irtio/schema 0.11.0 → 3.0.0
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/dist/index.d.ts +157 -10
- package/dist/index.js +196 -37
- package/package.json +6 -1
package/dist/index.d.ts
CHANGED
|
@@ -93,6 +93,7 @@ type InitFields<F extends Fields> = Simplify<{
|
|
|
93
93
|
[K in InitOptKeys<F>]?: Infer<F[K]> | undefined;
|
|
94
94
|
}>;
|
|
95
95
|
declare function describeType(d: TypeDesc): string;
|
|
96
|
+
declare function utf8Length(s: string): number;
|
|
96
97
|
/** Entity ids are strings of at most this many UTF-8 bytes (`str(32)` on the wire). */
|
|
97
98
|
declare const ID_MAX_BYTES = 32;
|
|
98
99
|
/**
|
|
@@ -279,7 +280,12 @@ declare function ownershipFromCanonical(at: string, raw: unknown): OwnershipOpti
|
|
|
279
280
|
* (ordered collection descriptors, sorted RPC table, canonical form, hash).
|
|
280
281
|
*/
|
|
281
282
|
|
|
282
|
-
|
|
283
|
+
/**
|
|
284
|
+
* `'server'` is the room's own private state: no client role sees it, no client may write it, and
|
|
285
|
+
* it never reaches the wire. It is the first-class spelling of what `visibility: 'role'` with an
|
|
286
|
+
* empty `roles` list has always done by accident, and it takes no `roles`.
|
|
287
|
+
*/
|
|
288
|
+
type Visibility = 'all' | 'role' | 'spatial-grid' | 'server';
|
|
283
289
|
interface GridOptions<K extends string = string> {
|
|
284
290
|
/** Numeric fields containing the entity's world position. */
|
|
285
291
|
readonly x: K;
|
|
@@ -296,7 +302,10 @@ interface GridDesc extends GridOptions<string> {
|
|
|
296
302
|
interface EntityOptions {
|
|
297
303
|
/** `true` → never client-owned; `DeepReadonly` on the client at compile time. */
|
|
298
304
|
readonly serverOwned?: boolean;
|
|
299
|
-
/**
|
|
305
|
+
/**
|
|
306
|
+
* `'all'` (default) | `'role'` (per-role views, needs `roles`) | `'spatial-grid'` (per-client
|
|
307
|
+
* AOI) | `'server'` (room-private: no client sees or writes it).
|
|
308
|
+
*/
|
|
300
309
|
readonly visibility?: Visibility;
|
|
301
310
|
/** With `visibility: 'role'`: the roles that see this collection. */
|
|
302
311
|
readonly roles?: readonly string[];
|
|
@@ -364,6 +373,27 @@ interface EntityOptions {
|
|
|
364
373
|
* unowned, and a leaving player's records keep their (now absent) owner.
|
|
365
374
|
*/
|
|
366
375
|
readonly ownership?: OwnershipOptions;
|
|
376
|
+
/**
|
|
377
|
+
* Fields of a client-owned record that only the server may write.
|
|
378
|
+
*
|
|
379
|
+
* Ownership is per record, so the server's own facts about a client-owned entity — granted
|
|
380
|
+
* upgrades, a validated score, a cooldown the room controls — have had to live in a parallel
|
|
381
|
+
* collection keyed by the same id. This puts them on the record and refuses the client's writes
|
|
382
|
+
* to them by name: the leaf goes back to the value it had before any validator runs, the client
|
|
383
|
+
* is corrected, and the rest of the same write still lands.
|
|
384
|
+
*
|
|
385
|
+
* Server behaviour, so it is part of the canonical form and the hash — a client and a room that
|
|
386
|
+
* disagree about who writes `upgrades` disagree about the world. Emitted into the canonical form
|
|
387
|
+
* only when declared, so every schema written before this keeps the hash it had.
|
|
388
|
+
*
|
|
389
|
+
* Entity-only, and meaningless on a `serverOwned` collection (where every field already is);
|
|
390
|
+
* both are refused. On a `physics` collection the intent list already answers the question, so a
|
|
391
|
+
* field named by both is refused too.
|
|
392
|
+
*
|
|
393
|
+
* There is deliberately no dynamic form. A per-field *owner id* would have to ride the wire per
|
|
394
|
+
* field per record, and the case that needs one is served by a second collection.
|
|
395
|
+
*/
|
|
396
|
+
readonly serverFields?: readonly string[];
|
|
367
397
|
}
|
|
368
398
|
interface EntityDef<F extends Fields = Fields, O extends EntityOptions = EntityOptions> {
|
|
369
399
|
readonly kind: 'entity';
|
|
@@ -418,6 +448,12 @@ declare function client<P extends Fields = {}, R extends Fields = Fields>(spec:
|
|
|
418
448
|
}): RpcDef<'client', P, R>;
|
|
419
449
|
/** RPC names a builder may not declare (built-ins live in `@irtio/protocol`). */
|
|
420
450
|
declare const RESERVED_RPC_NAMES: readonly string[];
|
|
451
|
+
/**
|
|
452
|
+
* The prefix irtio's built-in RPC names carry (`$ownership.ask`), refused to a builder here and to
|
|
453
|
+
* a caller in `@irtio/protocol`'s `isReservedRpcName`. Stated twice because the dependency runs
|
|
454
|
+
* schema → protocol and never the other way.
|
|
455
|
+
*/
|
|
456
|
+
declare const RESERVED_RPC_PREFIX = "$";
|
|
421
457
|
/** Collection names a builder may not declare (the runtime merges built-in state under them). */
|
|
422
458
|
declare const RESERVED_COLLECTION_NAMES: readonly string[];
|
|
423
459
|
interface FieldDesc {
|
|
@@ -452,10 +488,21 @@ interface CollectionDesc {
|
|
|
452
488
|
readonly clientCreate: boolean;
|
|
453
489
|
/** D77: the compiled transfer and leave rules. `undefined` when none were declared. In the hash. */
|
|
454
490
|
readonly ownership: OwnershipDesc | undefined;
|
|
491
|
+
/**
|
|
492
|
+
* M7 pf3 (e): the field names only the server may write, sorted, or `undefined` when none were
|
|
493
|
+
* declared. In the hash when present. A `Set` because `writes.ts` asks the membership question
|
|
494
|
+
* once per written leaf.
|
|
495
|
+
*/
|
|
496
|
+
readonly serverFields: ReadonlySet<string> | undefined;
|
|
455
497
|
}
|
|
456
498
|
interface RpcDesc {
|
|
457
499
|
readonly name: string;
|
|
458
|
-
/**
|
|
500
|
+
/**
|
|
501
|
+
* The wire id: this RPC's position in **declaration order**, 0-based (M7 pf2). It is not a
|
|
502
|
+
* sort — the order the developer wrote the `rpc` object in is the order of the id space, and
|
|
503
|
+
* moving a declaration moves an id. `@irtio/protocol`'s built-ins live in a reserved range at
|
|
504
|
+
* the top of the u16 and never collide with these.
|
|
505
|
+
*/
|
|
459
506
|
readonly index: number;
|
|
460
507
|
readonly direction: RpcDirection;
|
|
461
508
|
readonly params: readonly FieldDesc[];
|
|
@@ -465,13 +512,13 @@ interface RpcDesc {
|
|
|
465
512
|
/** Declared message shapes: name → the fields one message of that name carries. */
|
|
466
513
|
type MessageMap = Readonly<Record<string, Fields>>;
|
|
467
514
|
/**
|
|
468
|
-
* One compiled message shape. `index` is its position in `schema.messages`
|
|
469
|
-
* is what rides the wire, so
|
|
470
|
-
* exactly the way
|
|
515
|
+
* One compiled message shape. `index` is its position in `schema.messages` in **declaration
|
|
516
|
+
* order** (M7 pf2) and is what rides the wire, so appending a message is additive and moving an
|
|
517
|
+
* existing declaration is breaking, in exactly the way it is for a collection (see `diffSchemas`).
|
|
471
518
|
*/
|
|
472
519
|
interface MessageDesc {
|
|
473
520
|
readonly name: string;
|
|
474
|
-
/** Index in `schema.messages` (
|
|
521
|
+
/** Index in `schema.messages` (declaration order) = wire index. */
|
|
475
522
|
readonly index: number;
|
|
476
523
|
readonly fields: readonly FieldDesc[];
|
|
477
524
|
}
|
|
@@ -481,6 +528,13 @@ interface MessageDesc {
|
|
|
481
528
|
* later is additive rather than a wire change.
|
|
482
529
|
*/
|
|
483
530
|
declare const MAX_MESSAGES = 256;
|
|
531
|
+
/**
|
|
532
|
+
* M7 pf2: the ceiling on builder-declared RPCs. `Call.rpcId` is a `u16`, and `@irtio/protocol`
|
|
533
|
+
* reserves the top of that space for irtio's built-ins (65535 downward). This cap is what keeps
|
|
534
|
+
* the two ranges from ever meeting, with thousands of ids of headroom on both sides — a schema
|
|
535
|
+
* that hits it has a problem no id scheme can fix.
|
|
536
|
+
*/
|
|
537
|
+
declare const MAX_USER_RPCS = 60000;
|
|
484
538
|
interface SchemaOptions<Rpc extends RpcMap, Roles extends readonly string[], Msgs extends MessageMap = MessageMap> {
|
|
485
539
|
readonly rpc?: Rpc;
|
|
486
540
|
readonly roles?: Roles;
|
|
@@ -589,6 +643,8 @@ interface Collection<T, Init = T> extends ReadonlyCollection<T> {
|
|
|
589
643
|
add(id: string, values: Init, options?: AddOptions): T;
|
|
590
644
|
remove(id: string): boolean;
|
|
591
645
|
setOwner(id: string, owner: string): void;
|
|
646
|
+
/** M7 pf3 (d): make this collection hold exactly `entries`. See {@link reconcileCollection}. */
|
|
647
|
+
reconcile(entries: Iterable<readonly [string, Init]>): void;
|
|
592
648
|
[Symbol.iterator](): IterableIterator<readonly [string, T]>;
|
|
593
649
|
}
|
|
594
650
|
interface Record_<T> {
|
|
@@ -606,10 +662,43 @@ declare class EntityCollection<T extends object = any, Init = T> implements Coll
|
|
|
606
662
|
has(id: string): boolean;
|
|
607
663
|
setOwner(id: string, owner: string): void;
|
|
608
664
|
ownerOf(id: string): string | undefined;
|
|
665
|
+
reconcile(entries: Iterable<readonly [string, Init]>): void;
|
|
609
666
|
get size(): number;
|
|
610
667
|
ids(): IterableIterator<string>;
|
|
611
668
|
[Symbol.iterator](): IterableIterator<readonly [string, T]>;
|
|
612
669
|
}
|
|
670
|
+
/**
|
|
671
|
+
* M7 pf3 (d): make `coll` hold exactly the ids `entries` yields, and nothing else.
|
|
672
|
+
*
|
|
673
|
+
* The shape a transient set wants — fires, pings, scorch marks, anything recomputed wholesale
|
|
674
|
+
* every tick. What it replaces is an `add`/`remove` bookkeeping pass plus a scratch `Set`, written
|
|
675
|
+
* again in every room that has one, and got subtly wrong the first time in most of them.
|
|
676
|
+
*
|
|
677
|
+
* It is a **thin diff over the existing methods**, not a second write path:
|
|
678
|
+
*
|
|
679
|
+
* - an id in `entries` that the collection does not have is `add`ed, exactly as if you had called
|
|
680
|
+
* `add` yourself (defaults filled, owner the server);
|
|
681
|
+
* - an id it already has keeps its record object, its owner, and its physics body: the values in
|
|
682
|
+
* `entries` are **assigned onto the existing record**, field by field, so a value that did not
|
|
683
|
+
* really change does not dirty and a body-backed row is not torn down and rebuilt;
|
|
684
|
+
* - an id the collection has and `entries` does not is `remove`d.
|
|
685
|
+
*
|
|
686
|
+
* That is the whole of the difference between this and a loop of `add()`s: `add` installs a *new*
|
|
687
|
+
* record object, which re-dirties every field and (on a physics collection) rebuilds the body at
|
|
688
|
+
* its spawn pose. `reconcile` is for the set that mostly stays the same.
|
|
689
|
+
*
|
|
690
|
+
* On a tracked collection every one of those steps goes through the tracking proxies, so the
|
|
691
|
+
* dirty set comes out identical to the one hand-written bookkeeping would have produced.
|
|
692
|
+
*
|
|
693
|
+
* Partial `entries` values are honoured on an existing row: a pair of `['a', { x: 3 }]` assigns
|
|
694
|
+
* `x` and leaves every other field of `a` alone. On a *new* row the same pair goes through `add`,
|
|
695
|
+
* where the collection's `.default()`/`.opt` rules fill the rest and a missing required field
|
|
696
|
+
* throws — the same asymmetry `add` has always had.
|
|
697
|
+
*
|
|
698
|
+
* Removals happen after the additions, so an id present in `entries` is never removed and
|
|
699
|
+
* re-added by one call.
|
|
700
|
+
*/
|
|
701
|
+
declare function reconcileCollection<T extends object, Init>(coll: Collection<T, Init>, desc: CollectionDesc, entries: Iterable<readonly [string, Init]>): void;
|
|
613
702
|
/** Builds a full record from init values: every field present, defaults applied, f32 frounded. */
|
|
614
703
|
declare function normalizeRecord(desc: CollectionDesc, values: Record<string, unknown>): Record<string, unknown>;
|
|
615
704
|
/** A record with every field at its default. */
|
|
@@ -706,6 +795,8 @@ type RolesOf<O extends EntityOptions> = O extends {
|
|
|
706
795
|
} ? R : never;
|
|
707
796
|
/** Is collection with options `O` visible to `Role`? */
|
|
708
797
|
type VisibleTo<O extends EntityOptions, Role extends string> = O extends {
|
|
798
|
+
visibility: 'server';
|
|
799
|
+
} ? false : O extends {
|
|
709
800
|
visibility: 'role';
|
|
710
801
|
} ? [Role] extends [RolesOf<O>] ? true : false : true;
|
|
711
802
|
type VisibleKeys<S, Role extends string> = {
|
|
@@ -923,6 +1014,12 @@ declare function bytesEqual(a: Uint8Array, b: Uint8Array): boolean;
|
|
|
923
1014
|
*
|
|
924
1015
|
* Encoding validates: oversize strings/lists, out-of-range or non-integer ints, wrong types,
|
|
925
1016
|
* missing required values and unknown enum members all throw with the field path.
|
|
1017
|
+
*
|
|
1018
|
+
* Decoding validates what a peer can lie about: an oversize string, an over-max list, an unknown
|
|
1019
|
+
* enum index and a non-finite `f32`/`f64` all throw, which every caller of this codec already
|
|
1020
|
+
* surfaces as a refused frame rather than as a value in state. The single exception is
|
|
1021
|
+
* `decodeSnapshot`, which coerces a non-finite float to the field default and warns, because
|
|
1022
|
+
* those bytes can be a room's own save and refusing them loses the room; see the comment there.
|
|
926
1023
|
*/
|
|
927
1024
|
|
|
928
1025
|
/**
|
|
@@ -943,8 +1040,22 @@ interface ValueSink {
|
|
|
943
1040
|
}
|
|
944
1041
|
/** Writes one value in snapshot form (presence byte first when the descriptor is `.opt`). */
|
|
945
1042
|
declare function writeValue(w: ValueSink, desc: TypeDesc, v: unknown, path?: string): void;
|
|
1043
|
+
/**
|
|
1044
|
+
* How a decode answers a non-finite `f32`/`f64` it reads off the wire.
|
|
1045
|
+
*
|
|
1046
|
+
* The default — and the only answer on the delta/WRITE path — is to throw, which is what makes a
|
|
1047
|
+
* peer-authored NaN an ordinary refused frame. {@link decodeSnapshot} passes a `coerce` reporter
|
|
1048
|
+
* instead; see the comment there for why the two paths must differ.
|
|
1049
|
+
*/
|
|
1050
|
+
interface ReadOptions {
|
|
1051
|
+
/**
|
|
1052
|
+
* When set, a non-finite float is replaced by the field's default and reported here instead of
|
|
1053
|
+
* throwing. `path` is the field path, `value` the number that was read.
|
|
1054
|
+
*/
|
|
1055
|
+
readonly onNonFinite?: (path: string, value: number) => void;
|
|
1056
|
+
}
|
|
946
1057
|
/** Reads one value in snapshot form. */
|
|
947
|
-
declare function readValue(r: ByteReader, desc: TypeDesc): unknown;
|
|
1058
|
+
declare function readValue(r: ByteReader, desc: TypeDesc, opts?: ReadOptions, path?: string): unknown;
|
|
948
1059
|
/** Snapshot-form record encoding — used for RPC params and returns. */
|
|
949
1060
|
declare function encodeFields(fields: readonly FieldDesc[], value: Record<string, unknown>): Uint8Array;
|
|
950
1061
|
/** Decodes a snapshot-form record. Accepts raw bytes or a positioned `ByteReader`. */
|
|
@@ -964,7 +1075,29 @@ interface DecodedSnapshot {
|
|
|
964
1075
|
readonly hash8: Uint8Array;
|
|
965
1076
|
readonly state: PlainState;
|
|
966
1077
|
}
|
|
967
|
-
|
|
1078
|
+
interface DecodeSnapshotOptions {
|
|
1079
|
+
/**
|
|
1080
|
+
* Where a non-finite float found in the snapshot is reported. Defaults to a `console.warn`.
|
|
1081
|
+
* The field is restored as its declared default either way.
|
|
1082
|
+
*/
|
|
1083
|
+
readonly onNonFinite?: (path: string, value: number) => void;
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Decodes a whole-state snapshot: a join payload, or the codec section of a room save.
|
|
1087
|
+
*
|
|
1088
|
+
* **Why this path tolerates a non-finite float where the delta path refuses one.** A delta is a
|
|
1089
|
+
* live frame from a peer, so a NaN in it is an attack or a bug happening *now* and refusing the
|
|
1090
|
+
* frame costs one frame. A snapshot is also how a hibernated room comes back, and those bytes
|
|
1091
|
+
* were written by *us* — possibly months ago, before `normalizeValue` re-checked after its
|
|
1092
|
+
* fround and before `writeValue` checked at all. Applying the strict rule to them means a save
|
|
1093
|
+
* containing one Infinity is a room that can never be restored again: every wake throws, and
|
|
1094
|
+
* since the save is the room, the room is gone. Weighed against that, coercing the one poisoned
|
|
1095
|
+
* field to its declared default and saying so loudly is plainly the better trade — the room
|
|
1096
|
+
* comes back, one number is wrong in a way the operator has been told about, and room code fixes
|
|
1097
|
+
* or overwrites it on the next tick. This deliberately does *not* extend to `decodeDelta` or the
|
|
1098
|
+
* WRITE path, where the bytes are a peer's and refusing them is free.
|
|
1099
|
+
*/
|
|
1100
|
+
declare function decodeSnapshot(schema: AnySchema, bytes: Uint8Array, options?: DecodeSnapshotOptions): DecodedSnapshot;
|
|
968
1101
|
type DeltaOp = {
|
|
969
1102
|
readonly op: 'add';
|
|
970
1103
|
readonly id: string;
|
|
@@ -1110,5 +1243,19 @@ interface SchemaChange {
|
|
|
1110
1243
|
}
|
|
1111
1244
|
/** Compares two compiled schemas and returns every classified change, sorted by path then code. */
|
|
1112
1245
|
declare function diffSchemas(oldSchema: AnySchema, newSchema: AnySchema): SchemaChange[];
|
|
1246
|
+
/**
|
|
1247
|
+
* True when every breaking change in `changes` lives under the RPC table (`rpc` / `rpc.*`) or the
|
|
1248
|
+
* message table (`message.*`), and there is at least one. Such a change breaks connected clients
|
|
1249
|
+
* (the version fence reconnects them) but touches nothing a snapshot stores, so a deploy needs
|
|
1250
|
+
* `--allow-breaking` and no migration: a chain step without one decodes and re-encodes losslessly.
|
|
1251
|
+
*
|
|
1252
|
+
* M7 pf2 added the message half. Messages are transient peer packets — the snapshot encoder has
|
|
1253
|
+
* no reference to `schema.messages` at all — so a message-only break has never had anything to
|
|
1254
|
+
* migrate; before pf2 nobody noticed, because a message index only moved when a name was inserted
|
|
1255
|
+
* early. Declaration-order indices make moves ordinary, and the pf2 transition itself renumbers
|
|
1256
|
+
* messages for every project that declares more than one. Demanding a hand-written identity
|
|
1257
|
+
* migration for a packet nothing stores would be a lie about what the deploy costs.
|
|
1258
|
+
*/
|
|
1259
|
+
declare function breakingIsWireOnly(changes: readonly SchemaChange[]): boolean;
|
|
1113
1260
|
|
|
1114
|
-
export { type AddOptions, type AnyDef, type AnyMessageMap, type AnyRpc, type AnySchema, type AnyType, type Body2dState, type BodyFieldsOf, type BroadcastProxy, ByteReader, ByteWriter, CANONICAL_VERSION, type ClientCallProxy, type ClientImplementations, type ClientRpcs, type ClientState, type Collection, type CollectionDesc, type CollectionDirty, type DecodedSnapshot, type DeepReadonly, type DeepWritable, type DefMap, type Delta, type DeltaCollection, type DeltaOp, type DirtySet, EntityCollection, type EntityDef, type EntityOptions, type EntityPhysics, type FieldDesc, type FieldMask, type Fields, type FromCanonicalOptions, type GridDesc, type GridOptions, type Header, ID_MAX_BYTES, type Implementations, type Infer, type InferFields, type InitFields, type InitOf, type InstanceOf, type Kind, MAX_MESSAGES, type MessageChannels, type MessageDesc, type MessageMap, type MessageNames, type MessageValue, type MessagesOf, type OwnableKeys, type Owned, type OwnershipDesc, type OwnershipLeave, type OwnershipLeaveAction, type OwnershipOptions, type OwnershipPosition, type OwnershipTransfer, PHYSICS_BODY_CHANNELS, type PhysicsBodyChannel, type PhysicsBodyMap, type PhysicsDesc, type PhysicsKeys, type PlainState, RESERVED_COLLECTION_NAMES, RESERVED_RPC_NAMES, type ReadonlyBodyFields, type ReadonlyCollection, type RecordDirty, type RoleOf, type RpcDef, type RpcDesc, type RpcDirection, type RpcMap, type RpcParams, type RpcReturns, type RpcSpec, SERVER_OWNER, SINGLETON_ID, type Schema, type SchemaChange, type SchemaDefs, type SchemaIssue, type SchemaMessages, type SchemaOptions, type SchemaRoles, type SchemaRpc, type ServerCallProxy, type ServerMessageChannels, type ServerOwnedKeys, type ServerRpcs, type SingletonDef, type SnapshotOptions, type State, type Tracked, type Type, type TypeDesc, type ValueSink, type Visibility, type VisibleKeys, type WalkEvents, angleFrom2d, applyChannel2d, applyDelta, bool, bytesEqual, canonicalOwnership, canonicalPhysics, canonicalType, channelOf2d, client, cloneValue, collectionDirty, compileOwnership, compilePhysics, computeDirty, createDirtySet, createFieldMask, createState, decodeDelta, decodeDeltaFrom, decodeFields, decodeSnapshot, defaultRecord, defaultValue, defineSchema, describeType, diffSchemas, encodeDelta, encodeFields, encodeSnapshot, entity, enumOf, estimateSize, f32, f64, filterDirty, frozenProxy, i32, isDirtyEmpty, isWhole, list, markAdd, markField, markOwner, markPath, markRemove, mergeDirty, mergeMask, normalizeRecord, normalizeValue, ownershipFromCanonical, parseHold, physicsFromCanonical, readValue, ref, schemaFromCanonical, server, sha256, sha256Hex, singleton, stableStringify, str, struct, toHex, track, u16, u32, u8, validateForDeploy, validateValue, walkDelta, walkSnapshot, writeValue };
|
|
1261
|
+
export { type AddOptions, type AnyDef, type AnyMessageMap, type AnyRpc, type AnySchema, type AnyType, type Body2dState, type BodyFieldsOf, type BroadcastProxy, ByteReader, ByteWriter, CANONICAL_VERSION, type ClientCallProxy, type ClientImplementations, type ClientRpcs, type ClientState, type Collection, type CollectionDesc, type CollectionDirty, type DecodeSnapshotOptions, type DecodedSnapshot, type DeepReadonly, type DeepWritable, type DefMap, type Delta, type DeltaCollection, type DeltaOp, type DirtySet, EntityCollection, type EntityDef, type EntityOptions, type EntityPhysics, type FieldDesc, type FieldMask, type Fields, type FromCanonicalOptions, type GridDesc, type GridOptions, type Header, ID_MAX_BYTES, type Implementations, type Infer, type InferFields, type InitFields, type InitOf, type InstanceOf, type Kind, MAX_MESSAGES, MAX_USER_RPCS, type MessageChannels, type MessageDesc, type MessageMap, type MessageNames, type MessageValue, type MessagesOf, type OwnableKeys, type Owned, type OwnershipDesc, type OwnershipLeave, type OwnershipLeaveAction, type OwnershipOptions, type OwnershipPosition, type OwnershipTransfer, PHYSICS_BODY_CHANNELS, type PhysicsBodyChannel, type PhysicsBodyMap, type PhysicsDesc, type PhysicsKeys, type PlainState, RESERVED_COLLECTION_NAMES, RESERVED_RPC_NAMES, RESERVED_RPC_PREFIX, type ReadOptions, type ReadonlyBodyFields, type ReadonlyCollection, type RecordDirty, type RoleOf, type RpcDef, type RpcDesc, type RpcDirection, type RpcMap, type RpcParams, type RpcReturns, type RpcSpec, SERVER_OWNER, SINGLETON_ID, type Schema, type SchemaChange, type SchemaDefs, type SchemaIssue, type SchemaMessages, type SchemaOptions, type SchemaRoles, type SchemaRpc, type ServerCallProxy, type ServerMessageChannels, type ServerOwnedKeys, type ServerRpcs, type SingletonDef, type SnapshotOptions, type State, type Tracked, type Type, type TypeDesc, type ValueSink, type Visibility, type VisibleKeys, type WalkEvents, angleFrom2d, applyChannel2d, applyDelta, bool, breakingIsWireOnly, bytesEqual, canonicalOwnership, canonicalPhysics, canonicalType, channelOf2d, client, cloneValue, collectionDirty, compileOwnership, compilePhysics, computeDirty, createDirtySet, createFieldMask, createState, decodeDelta, decodeDeltaFrom, decodeFields, decodeSnapshot, defaultRecord, defaultValue, defineSchema, describeType, diffSchemas, encodeDelta, encodeFields, encodeSnapshot, entity, enumOf, estimateSize, f32, f64, filterDirty, frozenProxy, i32, isDirtyEmpty, isWhole, list, markAdd, markField, markOwner, markPath, markRemove, mergeDirty, mergeMask, normalizeRecord, normalizeValue, ownershipFromCanonical, parseHold, physicsFromCanonical, readValue, reconcileCollection, ref, schemaFromCanonical, server, sha256, sha256Hex, singleton, stableStringify, str, struct, toHex, track, u16, u32, u8, utf8Length, validateForDeploy, validateValue, walkDelta, walkSnapshot, writeValue };
|
package/dist/index.js
CHANGED
|
@@ -112,7 +112,8 @@ function validateValue(d, v, path = "") {
|
|
|
112
112
|
}
|
|
113
113
|
case "f32":
|
|
114
114
|
case "f64":
|
|
115
|
-
|
|
115
|
+
if (typeof v !== "number") return `${at} must be a number`;
|
|
116
|
+
return Number.isFinite(v) ? null : `${at} must be a finite number (${d.kind})`;
|
|
116
117
|
case "str":
|
|
117
118
|
case "ref": {
|
|
118
119
|
if (typeof v !== "string") return `${at} must be a string`;
|
|
@@ -187,7 +188,11 @@ function normalizeValue(d, v, path = "") {
|
|
|
187
188
|
switch (d.kind) {
|
|
188
189
|
case "f32": {
|
|
189
190
|
if (typeof v !== "number") throw new Error(`${at} must be a number`);
|
|
190
|
-
|
|
191
|
+
if (!Number.isFinite(v)) throw new Error(`${at} must be a finite number (f32)`);
|
|
192
|
+
const f = Math.fround(v);
|
|
193
|
+
if (!Number.isFinite(f))
|
|
194
|
+
throw new Error(`${at} overflows f32 (${v} rounds to ${f > 0 ? "Infinity" : "-Infinity"})`);
|
|
195
|
+
return f;
|
|
191
196
|
}
|
|
192
197
|
case "list": {
|
|
193
198
|
if (!Array.isArray(v)) throw new Error(`${at} must be an array`);
|
|
@@ -657,6 +662,48 @@ function singleton(fields, options) {
|
|
|
657
662
|
checkFields(fields);
|
|
658
663
|
return { kind: "singleton", fields, options: options ?? {} };
|
|
659
664
|
}
|
|
665
|
+
function compileServerFields(name, kind, fields, o) {
|
|
666
|
+
const declared = o.serverFields;
|
|
667
|
+
if (declared === void 0) return void 0;
|
|
668
|
+
if (!Array.isArray(declared)) {
|
|
669
|
+
throw new Error(`${name}: serverFields must be an array of field names`);
|
|
670
|
+
}
|
|
671
|
+
if (kind !== "entity") {
|
|
672
|
+
throw new Error(
|
|
673
|
+
`${name}: serverFields is entity-only \u2014 a client never writes a singleton, so every field of one is already server-only`
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
if (o.serverOwned === true) {
|
|
677
|
+
throw new Error(
|
|
678
|
+
`${name}: serverFields on a serverOwned collection says nothing \u2014 serverOwned already means no client writes any field of it`
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
if (declared.length === 0) {
|
|
682
|
+
throw new Error(`${name}: serverFields is empty; leave it off instead`);
|
|
683
|
+
}
|
|
684
|
+
const known = new Set(fields.map((f) => f.name));
|
|
685
|
+
const out = /* @__PURE__ */ new Set();
|
|
686
|
+
for (const field of declared) {
|
|
687
|
+
if (typeof field !== "string" || !known.has(field)) {
|
|
688
|
+
throw new Error(
|
|
689
|
+
`${name}: serverFields names ${JSON.stringify(field)}, which is not a field of ${name}`
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
if (out.has(field)) throw new Error(`${name}: serverFields names ${field} twice`);
|
|
693
|
+
out.add(field);
|
|
694
|
+
}
|
|
695
|
+
if (out.size === fields.length) {
|
|
696
|
+
throw new Error(
|
|
697
|
+
`${name}: serverFields names every field \u2014 that is serverOwned: true, which says it once`
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
if (o.physics) {
|
|
701
|
+
throw new Error(
|
|
702
|
+
`${name}: serverFields says nothing on a physics collection \u2014 the owner of a body-backed record writes its declared intents and nothing else. Take the field out of physics.intents instead`
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
return new Set([...out].sort());
|
|
706
|
+
}
|
|
660
707
|
function checkFields(fields) {
|
|
661
708
|
const names = Object.keys(fields);
|
|
662
709
|
if (names.length === 0) throw new Error("entity/singleton needs at least one field");
|
|
@@ -676,8 +723,10 @@ function makeRpc(direction, spec) {
|
|
|
676
723
|
return { kind: "rpc", direction, params, returns: spec.returns };
|
|
677
724
|
}
|
|
678
725
|
var RESERVED_RPC_NAMES = ["requestOwnership"];
|
|
726
|
+
var RESERVED_RPC_PREFIX = "$";
|
|
679
727
|
var RESERVED_COLLECTION_NAMES = ["clients"];
|
|
680
728
|
var MAX_MESSAGES = 256;
|
|
729
|
+
var MAX_USER_RPCS = 6e4;
|
|
681
730
|
function defineSchema(defs, options = {}) {
|
|
682
731
|
const names = Object.keys(defs);
|
|
683
732
|
for (const n of names) {
|
|
@@ -713,6 +762,7 @@ function defineSchema(defs, options = {}) {
|
|
|
713
762
|
);
|
|
714
763
|
}
|
|
715
764
|
const ownership = o.ownership || perPlayer ? compileOwnership(name, def.kind, fields, o.ownership ?? {}, perPlayer) : void 0;
|
|
765
|
+
const serverFields = compileServerFields(name, def.kind, fields, o);
|
|
716
766
|
return {
|
|
717
767
|
name,
|
|
718
768
|
index,
|
|
@@ -720,6 +770,7 @@ function defineSchema(defs, options = {}) {
|
|
|
720
770
|
fields,
|
|
721
771
|
fieldIndex: new Map(fields.map((f) => [f.name, f.index])),
|
|
722
772
|
serverOwned: o.serverOwned === true,
|
|
773
|
+
serverFields,
|
|
723
774
|
perPlayer,
|
|
724
775
|
clientCreate: o.clientCreate === true,
|
|
725
776
|
ownership,
|
|
@@ -763,13 +814,23 @@ function defineSchema(defs, options = {}) {
|
|
|
763
814
|
}
|
|
764
815
|
}
|
|
765
816
|
const rpcMap = options.rpc ?? {};
|
|
817
|
+
if (Object.keys(rpcMap).length > MAX_USER_RPCS) {
|
|
818
|
+
throw new Error(
|
|
819
|
+
`at most ${MAX_USER_RPCS} RPCs per schema, got ${Object.keys(rpcMap).length} \u2014 an rpcId is a u16 and the top of that space is reserved for irtio's built-in RPCs`
|
|
820
|
+
);
|
|
821
|
+
}
|
|
766
822
|
for (const n of Object.keys(rpcMap)) {
|
|
767
823
|
assertFieldName(n);
|
|
768
824
|
if (RESERVED_RPC_NAMES.includes(n)) {
|
|
769
825
|
throw new Error(`RPC name ${JSON.stringify(n)} is reserved by irtio (built-in RPC)`);
|
|
770
826
|
}
|
|
827
|
+
if (n.startsWith(RESERVED_RPC_PREFIX)) {
|
|
828
|
+
throw new Error(
|
|
829
|
+
`RPC name ${JSON.stringify(n)} starts with ${JSON.stringify(RESERVED_RPC_PREFIX)}, which is reserved for irtio's built-in RPCs \u2014 pick another name`
|
|
830
|
+
);
|
|
831
|
+
}
|
|
771
832
|
}
|
|
772
|
-
const rpcs = Object.keys(rpcMap).
|
|
833
|
+
const rpcs = Object.keys(rpcMap).map((name, index) => {
|
|
773
834
|
const r = rpcMap[name];
|
|
774
835
|
for (const f of Object.values(r.params))
|
|
775
836
|
checkRefs(f.desc, entityNames, `rpc ${name} params`);
|
|
@@ -813,7 +874,7 @@ function defineSchema(defs, options = {}) {
|
|
|
813
874
|
}
|
|
814
875
|
for (const f of Object.keys(fields)) assertFieldName(f);
|
|
815
876
|
}
|
|
816
|
-
const messages =
|
|
877
|
+
const messages = messageNames.map((name, index) => {
|
|
817
878
|
const fields = compileFields(messageMap[name]);
|
|
818
879
|
for (const f of fields) checkRefs(f.type, entityNames, `message ${name}.${f.name}`);
|
|
819
880
|
return { name, index, fields };
|
|
@@ -916,6 +977,10 @@ function canonicalize(s) {
|
|
|
916
977
|
// canonical form, and therefore the hash, it had. They are in the hash when declared because
|
|
917
978
|
// they are server behaviour: a client and a room that disagree about who may take a crate
|
|
918
979
|
// disagree about the world.
|
|
980
|
+
// M7 pf3 (e): the same only-when-declared rule again, for the same reason. Server
|
|
981
|
+
// behaviour, so it is in the hash when present: a client and a room that disagree about who
|
|
982
|
+
// writes `upgrades` disagree about the world.
|
|
983
|
+
serverFields: c.serverFields ? [...c.serverFields] : void 0,
|
|
919
984
|
perPlayer: c.perPlayer ? true : void 0,
|
|
920
985
|
clientCreate: c.clientCreate ? true : void 0,
|
|
921
986
|
ownership: c.ownership ? canonicalOwnership(c.ownership) : void 0
|
|
@@ -1010,11 +1075,17 @@ function validateForDeploy(schema) {
|
|
|
1010
1075
|
message: `${c.name}: grid is only valid with visibility 'spatial-grid'`
|
|
1011
1076
|
});
|
|
1012
1077
|
}
|
|
1078
|
+
if (c.visibility === "server" && c.roles && c.roles.length > 0) {
|
|
1079
|
+
issues.push({
|
|
1080
|
+
level: "error",
|
|
1081
|
+
message: `${c.name}: visibility 'server' takes no roles \u2014 it is visible to no client role`
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1013
1084
|
if (c.visibility === "role") {
|
|
1014
1085
|
if (!c.roles || c.roles.length === 0) {
|
|
1015
1086
|
issues.push({
|
|
1016
1087
|
level: "warning",
|
|
1017
|
-
message: `${c.name}: visibility 'role' without roles \u2014 visible to no client role (likely a mistake)`
|
|
1088
|
+
message: `${c.name}: visibility 'role' without roles \u2014 visible to no client role (likely a mistake; say visibility: 'server' if that is what you meant)`
|
|
1018
1089
|
});
|
|
1019
1090
|
} else {
|
|
1020
1091
|
for (const r of c.roles) {
|
|
@@ -1165,7 +1236,7 @@ function typeOf(t, at) {
|
|
|
1165
1236
|
}
|
|
1166
1237
|
function entityOptions(e, name) {
|
|
1167
1238
|
const visibility = stringAt(e, "visibility");
|
|
1168
|
-
if (visibility !== "all" && visibility !== "role" && visibility !== "spatial-grid") {
|
|
1239
|
+
if (visibility !== "all" && visibility !== "role" && visibility !== "spatial-grid" && visibility !== "server") {
|
|
1169
1240
|
throw new Error(
|
|
1170
1241
|
`schemaFromCanonical: ${name}: unknown visibility ${JSON.stringify(visibility)}`
|
|
1171
1242
|
);
|
|
@@ -1179,6 +1250,11 @@ function entityOptions(e, name) {
|
|
|
1179
1250
|
const physics = e.physics === void 0 || e.physics === null ? void 0 : physicsFromCanonical(`schemaFromCanonical: ${name}`, e.physics);
|
|
1180
1251
|
const grid = e.grid === void 0 || e.grid === null ? void 0 : gridFromCanonical(name, objectAt(e, "grid"));
|
|
1181
1252
|
const ownership = e.ownership === void 0 || e.ownership === null ? void 0 : ownershipFromCanonical(`schemaFromCanonical: ${name}`, e.ownership);
|
|
1253
|
+
const serverFields = e.serverFields === void 0 || e.serverFields === null ? void 0 : arrayAtRaw(e, "serverFields").map((f) => {
|
|
1254
|
+
if (typeof f !== "string")
|
|
1255
|
+
throw new Error(`schemaFromCanonical: ${name}: serverFields must be strings`);
|
|
1256
|
+
return f;
|
|
1257
|
+
});
|
|
1182
1258
|
return {
|
|
1183
1259
|
serverOwned: e.serverOwned === true,
|
|
1184
1260
|
visibility,
|
|
@@ -1188,8 +1264,9 @@ function entityOptions(e, name) {
|
|
|
1188
1264
|
// ---- M6 lane K: stateful relay ----
|
|
1189
1265
|
...e.perPlayer === true ? { perPlayer: true } : {},
|
|
1190
1266
|
...e.clientCreate === true ? { clientCreate: true } : {},
|
|
1191
|
-
...ownership !== void 0 ? { ownership } : {}
|
|
1267
|
+
...ownership !== void 0 ? { ownership } : {},
|
|
1192
1268
|
// ---- end M6 lane K ----
|
|
1269
|
+
...serverFields !== void 0 ? { serverFields } : {}
|
|
1193
1270
|
};
|
|
1194
1271
|
}
|
|
1195
1272
|
function gridFromCanonical(name, grid) {
|
|
@@ -1294,6 +1371,9 @@ var EntityCollection = class {
|
|
|
1294
1371
|
ownerOf(id) {
|
|
1295
1372
|
return this.records.get(id)?.owner;
|
|
1296
1373
|
}
|
|
1374
|
+
reconcile(entries) {
|
|
1375
|
+
reconcileCollection(this, this.desc, entries);
|
|
1376
|
+
}
|
|
1297
1377
|
get size() {
|
|
1298
1378
|
return this.records.size;
|
|
1299
1379
|
}
|
|
@@ -1304,6 +1384,39 @@ var EntityCollection = class {
|
|
|
1304
1384
|
for (const [id, r] of this.records) yield [id, r.value];
|
|
1305
1385
|
}
|
|
1306
1386
|
};
|
|
1387
|
+
function reconcileCollection(coll, desc, entries) {
|
|
1388
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1389
|
+
for (const entry of entries) {
|
|
1390
|
+
if (!Array.isArray(entry) || entry.length < 2) {
|
|
1391
|
+
throw new Error(
|
|
1392
|
+
`${desc.name}.reconcile: entries must yield [id, values] pairs, got ${JSON.stringify(entry)}`
|
|
1393
|
+
);
|
|
1394
|
+
}
|
|
1395
|
+
const [id, values] = entry;
|
|
1396
|
+
seen.add(id);
|
|
1397
|
+
const current = coll.get(id);
|
|
1398
|
+
if (current === void 0) {
|
|
1399
|
+
coll.add(id, values);
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
assignFields(desc, current, values);
|
|
1403
|
+
}
|
|
1404
|
+
for (const id of [...coll.ids()]) if (!seen.has(id)) coll.remove(id);
|
|
1405
|
+
}
|
|
1406
|
+
function assignFields(desc, target, values) {
|
|
1407
|
+
if (typeof values !== "object" || values === null) {
|
|
1408
|
+
throw new Error(`${desc.name}: values must be an object`);
|
|
1409
|
+
}
|
|
1410
|
+
for (const [name, raw] of Object.entries(values)) {
|
|
1411
|
+
const index = desc.fieldIndex.get(name);
|
|
1412
|
+
if (index === void 0) {
|
|
1413
|
+
throw new Error(`${desc.name}: unknown field ${JSON.stringify(name)}`);
|
|
1414
|
+
}
|
|
1415
|
+
const field = desc.fields[index];
|
|
1416
|
+
const next = raw === void 0 && field.type.opt ? void 0 : normalizeValue(field.type, raw, `${desc.name}.${name}`);
|
|
1417
|
+
target[name] = next;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1307
1420
|
function normalizeRecord(desc, values) {
|
|
1308
1421
|
if (typeof values !== "object" || values === null) {
|
|
1309
1422
|
throw new Error(`${desc.name}: values must be an object`);
|
|
@@ -1543,6 +1656,13 @@ var ByteWriter = class {
|
|
|
1543
1656
|
return this.buf.slice(0, this.pos);
|
|
1544
1657
|
}
|
|
1545
1658
|
};
|
|
1659
|
+
function utf82(bytes) {
|
|
1660
|
+
try {
|
|
1661
|
+
return decoder.decode(bytes);
|
|
1662
|
+
} catch {
|
|
1663
|
+
throw new Error(`invalid UTF-8 in string (${bytes.length} bytes)`);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1546
1666
|
var ByteReader = class {
|
|
1547
1667
|
constructor(buf, offset = 0) {
|
|
1548
1668
|
this.buf = buf;
|
|
@@ -1633,10 +1753,10 @@ var ByteReader = class {
|
|
|
1633
1753
|
* decoded by every receiver).
|
|
1634
1754
|
*/
|
|
1635
1755
|
str(max) {
|
|
1636
|
-
if (max === void 0) return
|
|
1756
|
+
if (max === void 0) return utf82(this.blob());
|
|
1637
1757
|
const n = this.varint();
|
|
1638
1758
|
if (n > max) throw new Error(`string exceeds ${max} UTF-8 bytes (${n})`);
|
|
1639
|
-
return
|
|
1759
|
+
return utf82(this.bytes(n));
|
|
1640
1760
|
}
|
|
1641
1761
|
/** The rest of the buffer (a view). */
|
|
1642
1762
|
rest() {
|
|
@@ -1739,13 +1859,13 @@ function writeValue(w, desc, v, path = "value") {
|
|
|
1739
1859
|
return;
|
|
1740
1860
|
}
|
|
1741
1861
|
case "f32":
|
|
1862
|
+
case "f64": {
|
|
1742
1863
|
if (typeof value !== "number") fail(path, "must be a number");
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
if (typeof value !== "number") fail(path, "must be a number");
|
|
1747
|
-
w.f64(value);
|
|
1864
|
+
if (!Number.isFinite(value)) fail(path, `must be a finite number (${desc.kind})`);
|
|
1865
|
+
if (desc.kind === "f32") w.f32(value);
|
|
1866
|
+
else w.f64(value);
|
|
1748
1867
|
return;
|
|
1868
|
+
}
|
|
1749
1869
|
case "str":
|
|
1750
1870
|
case "ref": {
|
|
1751
1871
|
if (typeof value !== "string") fail(path, "must be a string");
|
|
@@ -1779,7 +1899,12 @@ function writeValue(w, desc, v, path = "value") {
|
|
|
1779
1899
|
}
|
|
1780
1900
|
}
|
|
1781
1901
|
}
|
|
1782
|
-
function
|
|
1902
|
+
function warnNonFinite(path, value) {
|
|
1903
|
+
console.warn(
|
|
1904
|
+
`irtio: snapshot field ${path} held ${value}; restored as the field's default. The value predates the finiteness checks on write and encode.`
|
|
1905
|
+
);
|
|
1906
|
+
}
|
|
1907
|
+
function readValue(r, desc, opts, path = "value") {
|
|
1783
1908
|
if (desc.opt && r.u8() === 0) return void 0;
|
|
1784
1909
|
switch (desc.kind) {
|
|
1785
1910
|
case "bool":
|
|
@@ -1793,9 +1918,15 @@ function readValue(r, desc) {
|
|
|
1793
1918
|
case "i32":
|
|
1794
1919
|
return r.i32();
|
|
1795
1920
|
case "f32":
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1921
|
+
case "f64": {
|
|
1922
|
+
const v = desc.kind === "f32" ? r.f32() : r.f64();
|
|
1923
|
+
if (!Number.isFinite(v)) {
|
|
1924
|
+
if (!opts?.onNonFinite) throw new Error(`must be a finite number (${desc.kind})`);
|
|
1925
|
+
opts.onNonFinite(path, v);
|
|
1926
|
+
return defaultValue(desc);
|
|
1927
|
+
}
|
|
1928
|
+
return v;
|
|
1929
|
+
}
|
|
1799
1930
|
case "str":
|
|
1800
1931
|
case "ref":
|
|
1801
1932
|
return r.str(desc.kind === "str" ? desc.max : ID_MAX_BYTES);
|
|
@@ -1810,12 +1941,13 @@ function readValue(r, desc) {
|
|
|
1810
1941
|
const n = r.varint();
|
|
1811
1942
|
if (n > desc.max) throw new Error(`list exceeds max length ${desc.max} (${n})`);
|
|
1812
1943
|
const out = [];
|
|
1813
|
-
for (let i = 0; i < n; i++) out.push(readValue(r, desc.item));
|
|
1944
|
+
for (let i = 0; i < n; i++) out.push(readValue(r, desc.item, opts, `${path}[${i}]`));
|
|
1814
1945
|
return out;
|
|
1815
1946
|
}
|
|
1816
1947
|
case "struct": {
|
|
1817
1948
|
const o = {};
|
|
1818
|
-
for (const [k, fd] of Object.entries(desc.fields))
|
|
1949
|
+
for (const [k, fd] of Object.entries(desc.fields))
|
|
1950
|
+
o[k] = readValue(r, fd, opts, `${path}.${k}`);
|
|
1819
1951
|
return o;
|
|
1820
1952
|
}
|
|
1821
1953
|
}
|
|
@@ -1825,9 +1957,10 @@ function writeRecord(w, fields, value, path) {
|
|
|
1825
1957
|
fail(path, "must be an object");
|
|
1826
1958
|
for (const f of fields) writeValue(w, f.type, value[f.name], `${path}.${f.name}`);
|
|
1827
1959
|
}
|
|
1828
|
-
function readRecord(r, fields) {
|
|
1960
|
+
function readRecord(r, fields, opts, path = "") {
|
|
1829
1961
|
const o = {};
|
|
1830
|
-
for (const f of fields)
|
|
1962
|
+
for (const f of fields)
|
|
1963
|
+
o[f.name] = readValue(r, f.type, opts, path ? `${path}.${f.name}` : f.name);
|
|
1831
1964
|
return o;
|
|
1832
1965
|
}
|
|
1833
1966
|
function encodeFields(fields, value) {
|
|
@@ -1887,8 +2020,9 @@ function encodeSnapshot(schema, state, header, options) {
|
|
|
1887
2020
|
writeSnapshot(w, schema, state, header.tick, options);
|
|
1888
2021
|
return w.finish();
|
|
1889
2022
|
}
|
|
1890
|
-
function decodeSnapshot(schema, bytes) {
|
|
2023
|
+
function decodeSnapshot(schema, bytes, options = {}) {
|
|
1891
2024
|
const r = new ByteReader(bytes);
|
|
2025
|
+
const opts = { onNonFinite: options.onNonFinite ?? warnNonFinite };
|
|
1892
2026
|
const tick = r.u32();
|
|
1893
2027
|
const hash8 = r.bytes(8).slice();
|
|
1894
2028
|
const state = createState(schema);
|
|
@@ -1897,13 +2031,13 @@ function decodeSnapshot(schema, bytes) {
|
|
|
1897
2031
|
const coll = entityOf(state, c.name);
|
|
1898
2032
|
const n = r.varint();
|
|
1899
2033
|
for (let i = 0; i < n; i++) {
|
|
1900
|
-
const id = r.str();
|
|
1901
|
-
const owner = r.str();
|
|
1902
|
-
coll.add(id, readRecord(r, c.fields), { owner });
|
|
2034
|
+
const id = r.str(ID_MAX_BYTES);
|
|
2035
|
+
const owner = r.str(ID_MAX_BYTES);
|
|
2036
|
+
coll.add(id, readRecord(r, c.fields, opts, `${c.name}.${id}`), { owner });
|
|
1903
2037
|
}
|
|
1904
2038
|
} else {
|
|
1905
2039
|
const target = singletonOf(state, c.name);
|
|
1906
|
-
const value = readRecord(r, c.fields);
|
|
2040
|
+
const value = readRecord(r, c.fields, opts, c.name);
|
|
1907
2041
|
for (const f of c.fields) target[f.name] = value[f.name];
|
|
1908
2042
|
}
|
|
1909
2043
|
}
|
|
@@ -2242,6 +2376,7 @@ function skipValue(r, desc) {
|
|
|
2242
2376
|
return;
|
|
2243
2377
|
case "list": {
|
|
2244
2378
|
const n = r.varint();
|
|
2379
|
+
if (n > desc.max) throw new Error(`list exceeds max length ${desc.max} (${n})`);
|
|
2245
2380
|
for (let i = 0; i < n; i++) skipValue(r, desc.item);
|
|
2246
2381
|
return;
|
|
2247
2382
|
}
|
|
@@ -2389,6 +2524,11 @@ function objHandler(node) {
|
|
|
2389
2524
|
get(target, prop, receiver) {
|
|
2390
2525
|
if (typeof prop !== "string") return Reflect.get(target, prop, receiver);
|
|
2391
2526
|
const f = node.fields.get(prop);
|
|
2527
|
+
if (!f && prop === "reconcile") {
|
|
2528
|
+
throw new Error(
|
|
2529
|
+
`${node.label}.reconcile: reconcile() diffs the ids of an entity collection; ${node.label} is a single record. Assign its fields directly.`
|
|
2530
|
+
);
|
|
2531
|
+
}
|
|
2392
2532
|
if (!f) return Reflect.get(target, prop, receiver);
|
|
2393
2533
|
const v = target[f.name];
|
|
2394
2534
|
if (v === null || typeof v !== "object") return v;
|
|
@@ -2635,6 +2775,14 @@ var TrackedCollection = class {
|
|
|
2635
2775
|
ownerOf(id) {
|
|
2636
2776
|
return this.inner.ownerOf(id);
|
|
2637
2777
|
}
|
|
2778
|
+
/**
|
|
2779
|
+
* M7 pf3 (d): the tracked half of {@link reconcileCollection}. It calls this collection's own
|
|
2780
|
+
* `add`/`remove`/`get`, so every mark the hand-written bookkeeping would have made is made here
|
|
2781
|
+
* — including the record-proxy invalidation an `add` over a live id does.
|
|
2782
|
+
*/
|
|
2783
|
+
reconcile(entries) {
|
|
2784
|
+
reconcileCollection(this, this.desc, entries);
|
|
2785
|
+
}
|
|
2638
2786
|
get size() {
|
|
2639
2787
|
return this.inner.size;
|
|
2640
2788
|
}
|
|
@@ -2784,6 +2932,12 @@ function diffSchemas(oldSchema, newSchema) {
|
|
|
2784
2932
|
);
|
|
2785
2933
|
return changes;
|
|
2786
2934
|
}
|
|
2935
|
+
function breakingIsWireOnly(changes) {
|
|
2936
|
+
const breaking = changes.filter((c) => c.kind === "breaking");
|
|
2937
|
+
return breaking.length > 0 && breaking.every(
|
|
2938
|
+
(c) => c.path === "rpc" || c.path.startsWith("rpc.") || c.path.startsWith("message.")
|
|
2939
|
+
);
|
|
2940
|
+
}
|
|
2787
2941
|
function diffCollections(oldCollections, newCollections, changes) {
|
|
2788
2942
|
const oldByName = new Map(oldCollections.map((c) => [c.name, c]));
|
|
2789
2943
|
const newByName = new Map(newCollections.map((c) => [c.name, c]));
|
|
@@ -2858,6 +3012,14 @@ function diffCollections(oldCollections, newCollections, changes) {
|
|
|
2858
3012
|
message: `${oldC.name}: spatial grid contract changed \u2014 changes per-client membership`
|
|
2859
3013
|
});
|
|
2860
3014
|
}
|
|
3015
|
+
if (!sameStringSet([...oldC.serverFields ?? []], [...newC.serverFields ?? []])) {
|
|
3016
|
+
changes.push({
|
|
3017
|
+
kind: "breaking",
|
|
3018
|
+
code: "entity.server_fields_changed",
|
|
3019
|
+
path: oldC.name,
|
|
3020
|
+
message: `${oldC.name}: serverFields changed from [${[...oldC.serverFields ?? []].join(", ")}] to [${[...newC.serverFields ?? []].join(", ")}] \u2014 changes which fields a client may write`
|
|
3021
|
+
});
|
|
3022
|
+
}
|
|
2861
3023
|
diffFields(oldC.fields, newC.fields, oldC.name, changes);
|
|
2862
3024
|
}
|
|
2863
3025
|
}
|
|
@@ -2884,14 +3046,6 @@ function diffRpcs(oldRpcs, newRpcs, changes) {
|
|
|
2884
3046
|
});
|
|
2885
3047
|
}
|
|
2886
3048
|
}
|
|
2887
|
-
if (oldRpcs.length !== newRpcs.length) {
|
|
2888
|
-
changes.push({
|
|
2889
|
-
kind: "breaking",
|
|
2890
|
-
code: "rpc.builtin_reordered",
|
|
2891
|
-
path: "rpc",
|
|
2892
|
-
message: `rpc: the number of RPCs changed from ${oldRpcs.length} to ${newRpcs.length}, which shifts the ids of irtio's built-in RPCs (they are numbered after yours) \u2014 connected clients would call the wrong one (breaking)`
|
|
2893
|
-
});
|
|
2894
|
-
}
|
|
2895
3049
|
for (const oldR of oldRpcs) {
|
|
2896
3050
|
const newR = newByName.get(oldR.name);
|
|
2897
3051
|
if (!newR) continue;
|
|
@@ -2900,7 +3054,7 @@ function diffRpcs(oldRpcs, newRpcs, changes) {
|
|
|
2900
3054
|
kind: "breaking",
|
|
2901
3055
|
code: "rpc.reordered",
|
|
2902
3056
|
path: `rpc.${oldR.name}`,
|
|
2903
|
-
message: `rpc ${oldR.name}: wire id changed (${oldR.index} -> ${newR.index}) \u2014
|
|
3057
|
+
message: `rpc ${oldR.name}: wire id changed (${oldR.index} -> ${newR.index}) \u2014 RPC ids are your declaration order, so moving a declaration moves an id and calls would reach the wrong handler (breaking)`
|
|
2904
3058
|
});
|
|
2905
3059
|
}
|
|
2906
3060
|
if (oldR.direction !== newR.direction) {
|
|
@@ -2946,7 +3100,7 @@ function diffMessages(oldMessages, newMessages, changes) {
|
|
|
2946
3100
|
kind: "breaking",
|
|
2947
3101
|
code: "message.reordered",
|
|
2948
3102
|
path: `message.${oldM.name}`,
|
|
2949
|
-
message: `message ${oldM.name}: wire index changed (${oldM.index} -> ${newM.index}) \u2014
|
|
3103
|
+
message: `message ${oldM.name}: wire index changed (${oldM.index} -> ${newM.index}) \u2014 message indices are your declaration order, so peers would decode this one as another (breaking)`
|
|
2950
3104
|
});
|
|
2951
3105
|
}
|
|
2952
3106
|
diffFields(oldM.fields, newM.fields, `message.${oldM.name}`, changes);
|
|
@@ -3193,15 +3347,18 @@ export {
|
|
|
3193
3347
|
EntityCollection,
|
|
3194
3348
|
ID_MAX_BYTES,
|
|
3195
3349
|
MAX_MESSAGES,
|
|
3350
|
+
MAX_USER_RPCS,
|
|
3196
3351
|
PHYSICS_BODY_CHANNELS,
|
|
3197
3352
|
RESERVED_COLLECTION_NAMES,
|
|
3198
3353
|
RESERVED_RPC_NAMES,
|
|
3354
|
+
RESERVED_RPC_PREFIX,
|
|
3199
3355
|
SERVER_OWNER,
|
|
3200
3356
|
SINGLETON_ID,
|
|
3201
3357
|
angleFrom2d,
|
|
3202
3358
|
applyChannel2d,
|
|
3203
3359
|
applyDelta,
|
|
3204
3360
|
bool,
|
|
3361
|
+
breakingIsWireOnly,
|
|
3205
3362
|
bytesEqual,
|
|
3206
3363
|
canonicalOwnership,
|
|
3207
3364
|
canonicalPhysics,
|
|
@@ -3252,6 +3409,7 @@ export {
|
|
|
3252
3409
|
parseHold,
|
|
3253
3410
|
physicsFromCanonical,
|
|
3254
3411
|
readValue,
|
|
3412
|
+
reconcileCollection,
|
|
3255
3413
|
ref,
|
|
3256
3414
|
schemaFromCanonical,
|
|
3257
3415
|
server,
|
|
@@ -3266,6 +3424,7 @@ export {
|
|
|
3266
3424
|
u16,
|
|
3267
3425
|
u32,
|
|
3268
3426
|
u8,
|
|
3427
|
+
utf8Length,
|
|
3269
3428
|
validateForDeploy,
|
|
3270
3429
|
validateValue,
|
|
3271
3430
|
walkDelta,
|
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@irtio/schema",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "irtio schema DSL, type inference, canonical hash, codec, change tracking, and schema diff",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/alex-irt/irtio.git",
|
|
9
|
+
"directory": "packages/schema"
|
|
10
|
+
},
|
|
6
11
|
"publishConfig": {
|
|
7
12
|
"access": "public"
|
|
8
13
|
},
|