@irtio/schema 1.0.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 +148 -14
- package/dist/index.js +185 -38
- 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';
|
|
@@ -458,10 +488,21 @@ interface CollectionDesc {
|
|
|
458
488
|
readonly clientCreate: boolean;
|
|
459
489
|
/** D77: the compiled transfer and leave rules. `undefined` when none were declared. In the hash. */
|
|
460
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;
|
|
461
497
|
}
|
|
462
498
|
interface RpcDesc {
|
|
463
499
|
readonly name: string;
|
|
464
|
-
/**
|
|
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
|
+
*/
|
|
465
506
|
readonly index: number;
|
|
466
507
|
readonly direction: RpcDirection;
|
|
467
508
|
readonly params: readonly FieldDesc[];
|
|
@@ -471,13 +512,13 @@ interface RpcDesc {
|
|
|
471
512
|
/** Declared message shapes: name → the fields one message of that name carries. */
|
|
472
513
|
type MessageMap = Readonly<Record<string, Fields>>;
|
|
473
514
|
/**
|
|
474
|
-
* One compiled message shape. `index` is its position in `schema.messages`
|
|
475
|
-
* is what rides the wire, so
|
|
476
|
-
* 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`).
|
|
477
518
|
*/
|
|
478
519
|
interface MessageDesc {
|
|
479
520
|
readonly name: string;
|
|
480
|
-
/** Index in `schema.messages` (
|
|
521
|
+
/** Index in `schema.messages` (declaration order) = wire index. */
|
|
481
522
|
readonly index: number;
|
|
482
523
|
readonly fields: readonly FieldDesc[];
|
|
483
524
|
}
|
|
@@ -487,6 +528,13 @@ interface MessageDesc {
|
|
|
487
528
|
* later is additive rather than a wire change.
|
|
488
529
|
*/
|
|
489
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;
|
|
490
538
|
interface SchemaOptions<Rpc extends RpcMap, Roles extends readonly string[], Msgs extends MessageMap = MessageMap> {
|
|
491
539
|
readonly rpc?: Rpc;
|
|
492
540
|
readonly roles?: Roles;
|
|
@@ -595,6 +643,8 @@ interface Collection<T, Init = T> extends ReadonlyCollection<T> {
|
|
|
595
643
|
add(id: string, values: Init, options?: AddOptions): T;
|
|
596
644
|
remove(id: string): boolean;
|
|
597
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;
|
|
598
648
|
[Symbol.iterator](): IterableIterator<readonly [string, T]>;
|
|
599
649
|
}
|
|
600
650
|
interface Record_<T> {
|
|
@@ -612,10 +662,43 @@ declare class EntityCollection<T extends object = any, Init = T> implements Coll
|
|
|
612
662
|
has(id: string): boolean;
|
|
613
663
|
setOwner(id: string, owner: string): void;
|
|
614
664
|
ownerOf(id: string): string | undefined;
|
|
665
|
+
reconcile(entries: Iterable<readonly [string, Init]>): void;
|
|
615
666
|
get size(): number;
|
|
616
667
|
ids(): IterableIterator<string>;
|
|
617
668
|
[Symbol.iterator](): IterableIterator<readonly [string, T]>;
|
|
618
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;
|
|
619
702
|
/** Builds a full record from init values: every field present, defaults applied, f32 frounded. */
|
|
620
703
|
declare function normalizeRecord(desc: CollectionDesc, values: Record<string, unknown>): Record<string, unknown>;
|
|
621
704
|
/** A record with every field at its default. */
|
|
@@ -712,6 +795,8 @@ type RolesOf<O extends EntityOptions> = O extends {
|
|
|
712
795
|
} ? R : never;
|
|
713
796
|
/** Is collection with options `O` visible to `Role`? */
|
|
714
797
|
type VisibleTo<O extends EntityOptions, Role extends string> = O extends {
|
|
798
|
+
visibility: 'server';
|
|
799
|
+
} ? false : O extends {
|
|
715
800
|
visibility: 'role';
|
|
716
801
|
} ? [Role] extends [RolesOf<O>] ? true : false : true;
|
|
717
802
|
type VisibleKeys<S, Role extends string> = {
|
|
@@ -929,6 +1014,12 @@ declare function bytesEqual(a: Uint8Array, b: Uint8Array): boolean;
|
|
|
929
1014
|
*
|
|
930
1015
|
* Encoding validates: oversize strings/lists, out-of-range or non-integer ints, wrong types,
|
|
931
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.
|
|
932
1023
|
*/
|
|
933
1024
|
|
|
934
1025
|
/**
|
|
@@ -949,8 +1040,22 @@ interface ValueSink {
|
|
|
949
1040
|
}
|
|
950
1041
|
/** Writes one value in snapshot form (presence byte first when the descriptor is `.opt`). */
|
|
951
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
|
+
}
|
|
952
1057
|
/** Reads one value in snapshot form. */
|
|
953
|
-
declare function readValue(r: ByteReader, desc: TypeDesc): unknown;
|
|
1058
|
+
declare function readValue(r: ByteReader, desc: TypeDesc, opts?: ReadOptions, path?: string): unknown;
|
|
954
1059
|
/** Snapshot-form record encoding — used for RPC params and returns. */
|
|
955
1060
|
declare function encodeFields(fields: readonly FieldDesc[], value: Record<string, unknown>): Uint8Array;
|
|
956
1061
|
/** Decodes a snapshot-form record. Accepts raw bytes or a positioned `ByteReader`. */
|
|
@@ -970,7 +1075,29 @@ interface DecodedSnapshot {
|
|
|
970
1075
|
readonly hash8: Uint8Array;
|
|
971
1076
|
readonly state: PlainState;
|
|
972
1077
|
}
|
|
973
|
-
|
|
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;
|
|
974
1101
|
type DeltaOp = {
|
|
975
1102
|
readonly op: 'add';
|
|
976
1103
|
readonly id: string;
|
|
@@ -1117,11 +1244,18 @@ interface SchemaChange {
|
|
|
1117
1244
|
/** Compares two compiled schemas and returns every classified change, sorted by path then code. */
|
|
1118
1245
|
declare function diffSchemas(oldSchema: AnySchema, newSchema: AnySchema): SchemaChange[];
|
|
1119
1246
|
/**
|
|
1120
|
-
* True when every breaking change in `changes` lives under the RPC table (`rpc` / `rpc.*`
|
|
1121
|
-
*
|
|
1122
|
-
* reconnects them) but touches nothing a snapshot stores, so a deploy needs
|
|
1123
|
-
* and no migration: a chain step without one decodes and re-encodes losslessly.
|
|
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.
|
|
1124
1258
|
*/
|
|
1125
1259
|
declare function breakingIsWireOnly(changes: readonly SchemaChange[]): boolean;
|
|
1126
1260
|
|
|
1127
|
-
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, RESERVED_RPC_PREFIX, 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, 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");
|
|
@@ -679,6 +726,7 @@ var RESERVED_RPC_NAMES = ["requestOwnership"];
|
|
|
679
726
|
var RESERVED_RPC_PREFIX = "$";
|
|
680
727
|
var RESERVED_COLLECTION_NAMES = ["clients"];
|
|
681
728
|
var MAX_MESSAGES = 256;
|
|
729
|
+
var MAX_USER_RPCS = 6e4;
|
|
682
730
|
function defineSchema(defs, options = {}) {
|
|
683
731
|
const names = Object.keys(defs);
|
|
684
732
|
for (const n of names) {
|
|
@@ -714,6 +762,7 @@ function defineSchema(defs, options = {}) {
|
|
|
714
762
|
);
|
|
715
763
|
}
|
|
716
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);
|
|
717
766
|
return {
|
|
718
767
|
name,
|
|
719
768
|
index,
|
|
@@ -721,6 +770,7 @@ function defineSchema(defs, options = {}) {
|
|
|
721
770
|
fields,
|
|
722
771
|
fieldIndex: new Map(fields.map((f) => [f.name, f.index])),
|
|
723
772
|
serverOwned: o.serverOwned === true,
|
|
773
|
+
serverFields,
|
|
724
774
|
perPlayer,
|
|
725
775
|
clientCreate: o.clientCreate === true,
|
|
726
776
|
ownership,
|
|
@@ -764,6 +814,11 @@ function defineSchema(defs, options = {}) {
|
|
|
764
814
|
}
|
|
765
815
|
}
|
|
766
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
|
+
}
|
|
767
822
|
for (const n of Object.keys(rpcMap)) {
|
|
768
823
|
assertFieldName(n);
|
|
769
824
|
if (RESERVED_RPC_NAMES.includes(n)) {
|
|
@@ -775,7 +830,7 @@ function defineSchema(defs, options = {}) {
|
|
|
775
830
|
);
|
|
776
831
|
}
|
|
777
832
|
}
|
|
778
|
-
const rpcs = Object.keys(rpcMap).
|
|
833
|
+
const rpcs = Object.keys(rpcMap).map((name, index) => {
|
|
779
834
|
const r = rpcMap[name];
|
|
780
835
|
for (const f of Object.values(r.params))
|
|
781
836
|
checkRefs(f.desc, entityNames, `rpc ${name} params`);
|
|
@@ -819,7 +874,7 @@ function defineSchema(defs, options = {}) {
|
|
|
819
874
|
}
|
|
820
875
|
for (const f of Object.keys(fields)) assertFieldName(f);
|
|
821
876
|
}
|
|
822
|
-
const messages =
|
|
877
|
+
const messages = messageNames.map((name, index) => {
|
|
823
878
|
const fields = compileFields(messageMap[name]);
|
|
824
879
|
for (const f of fields) checkRefs(f.type, entityNames, `message ${name}.${f.name}`);
|
|
825
880
|
return { name, index, fields };
|
|
@@ -922,6 +977,10 @@ function canonicalize(s) {
|
|
|
922
977
|
// canonical form, and therefore the hash, it had. They are in the hash when declared because
|
|
923
978
|
// they are server behaviour: a client and a room that disagree about who may take a crate
|
|
924
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,
|
|
925
984
|
perPlayer: c.perPlayer ? true : void 0,
|
|
926
985
|
clientCreate: c.clientCreate ? true : void 0,
|
|
927
986
|
ownership: c.ownership ? canonicalOwnership(c.ownership) : void 0
|
|
@@ -1016,11 +1075,17 @@ function validateForDeploy(schema) {
|
|
|
1016
1075
|
message: `${c.name}: grid is only valid with visibility 'spatial-grid'`
|
|
1017
1076
|
});
|
|
1018
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
|
+
}
|
|
1019
1084
|
if (c.visibility === "role") {
|
|
1020
1085
|
if (!c.roles || c.roles.length === 0) {
|
|
1021
1086
|
issues.push({
|
|
1022
1087
|
level: "warning",
|
|
1023
|
-
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)`
|
|
1024
1089
|
});
|
|
1025
1090
|
} else {
|
|
1026
1091
|
for (const r of c.roles) {
|
|
@@ -1171,7 +1236,7 @@ function typeOf(t, at) {
|
|
|
1171
1236
|
}
|
|
1172
1237
|
function entityOptions(e, name) {
|
|
1173
1238
|
const visibility = stringAt(e, "visibility");
|
|
1174
|
-
if (visibility !== "all" && visibility !== "role" && visibility !== "spatial-grid") {
|
|
1239
|
+
if (visibility !== "all" && visibility !== "role" && visibility !== "spatial-grid" && visibility !== "server") {
|
|
1175
1240
|
throw new Error(
|
|
1176
1241
|
`schemaFromCanonical: ${name}: unknown visibility ${JSON.stringify(visibility)}`
|
|
1177
1242
|
);
|
|
@@ -1185,6 +1250,11 @@ function entityOptions(e, name) {
|
|
|
1185
1250
|
const physics = e.physics === void 0 || e.physics === null ? void 0 : physicsFromCanonical(`schemaFromCanonical: ${name}`, e.physics);
|
|
1186
1251
|
const grid = e.grid === void 0 || e.grid === null ? void 0 : gridFromCanonical(name, objectAt(e, "grid"));
|
|
1187
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
|
+
});
|
|
1188
1258
|
return {
|
|
1189
1259
|
serverOwned: e.serverOwned === true,
|
|
1190
1260
|
visibility,
|
|
@@ -1194,8 +1264,9 @@ function entityOptions(e, name) {
|
|
|
1194
1264
|
// ---- M6 lane K: stateful relay ----
|
|
1195
1265
|
...e.perPlayer === true ? { perPlayer: true } : {},
|
|
1196
1266
|
...e.clientCreate === true ? { clientCreate: true } : {},
|
|
1197
|
-
...ownership !== void 0 ? { ownership } : {}
|
|
1267
|
+
...ownership !== void 0 ? { ownership } : {},
|
|
1198
1268
|
// ---- end M6 lane K ----
|
|
1269
|
+
...serverFields !== void 0 ? { serverFields } : {}
|
|
1199
1270
|
};
|
|
1200
1271
|
}
|
|
1201
1272
|
function gridFromCanonical(name, grid) {
|
|
@@ -1300,6 +1371,9 @@ var EntityCollection = class {
|
|
|
1300
1371
|
ownerOf(id) {
|
|
1301
1372
|
return this.records.get(id)?.owner;
|
|
1302
1373
|
}
|
|
1374
|
+
reconcile(entries) {
|
|
1375
|
+
reconcileCollection(this, this.desc, entries);
|
|
1376
|
+
}
|
|
1303
1377
|
get size() {
|
|
1304
1378
|
return this.records.size;
|
|
1305
1379
|
}
|
|
@@ -1310,6 +1384,39 @@ var EntityCollection = class {
|
|
|
1310
1384
|
for (const [id, r] of this.records) yield [id, r.value];
|
|
1311
1385
|
}
|
|
1312
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
|
+
}
|
|
1313
1420
|
function normalizeRecord(desc, values) {
|
|
1314
1421
|
if (typeof values !== "object" || values === null) {
|
|
1315
1422
|
throw new Error(`${desc.name}: values must be an object`);
|
|
@@ -1549,6 +1656,13 @@ var ByteWriter = class {
|
|
|
1549
1656
|
return this.buf.slice(0, this.pos);
|
|
1550
1657
|
}
|
|
1551
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
|
+
}
|
|
1552
1666
|
var ByteReader = class {
|
|
1553
1667
|
constructor(buf, offset = 0) {
|
|
1554
1668
|
this.buf = buf;
|
|
@@ -1639,10 +1753,10 @@ var ByteReader = class {
|
|
|
1639
1753
|
* decoded by every receiver).
|
|
1640
1754
|
*/
|
|
1641
1755
|
str(max) {
|
|
1642
|
-
if (max === void 0) return
|
|
1756
|
+
if (max === void 0) return utf82(this.blob());
|
|
1643
1757
|
const n = this.varint();
|
|
1644
1758
|
if (n > max) throw new Error(`string exceeds ${max} UTF-8 bytes (${n})`);
|
|
1645
|
-
return
|
|
1759
|
+
return utf82(this.bytes(n));
|
|
1646
1760
|
}
|
|
1647
1761
|
/** The rest of the buffer (a view). */
|
|
1648
1762
|
rest() {
|
|
@@ -1745,13 +1859,13 @@ function writeValue(w, desc, v, path = "value") {
|
|
|
1745
1859
|
return;
|
|
1746
1860
|
}
|
|
1747
1861
|
case "f32":
|
|
1862
|
+
case "f64": {
|
|
1748
1863
|
if (typeof value !== "number") fail(path, "must be a number");
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
if (typeof value !== "number") fail(path, "must be a number");
|
|
1753
|
-
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);
|
|
1754
1867
|
return;
|
|
1868
|
+
}
|
|
1755
1869
|
case "str":
|
|
1756
1870
|
case "ref": {
|
|
1757
1871
|
if (typeof value !== "string") fail(path, "must be a string");
|
|
@@ -1785,7 +1899,12 @@ function writeValue(w, desc, v, path = "value") {
|
|
|
1785
1899
|
}
|
|
1786
1900
|
}
|
|
1787
1901
|
}
|
|
1788
|
-
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") {
|
|
1789
1908
|
if (desc.opt && r.u8() === 0) return void 0;
|
|
1790
1909
|
switch (desc.kind) {
|
|
1791
1910
|
case "bool":
|
|
@@ -1799,9 +1918,15 @@ function readValue(r, desc) {
|
|
|
1799
1918
|
case "i32":
|
|
1800
1919
|
return r.i32();
|
|
1801
1920
|
case "f32":
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
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
|
+
}
|
|
1805
1930
|
case "str":
|
|
1806
1931
|
case "ref":
|
|
1807
1932
|
return r.str(desc.kind === "str" ? desc.max : ID_MAX_BYTES);
|
|
@@ -1816,12 +1941,13 @@ function readValue(r, desc) {
|
|
|
1816
1941
|
const n = r.varint();
|
|
1817
1942
|
if (n > desc.max) throw new Error(`list exceeds max length ${desc.max} (${n})`);
|
|
1818
1943
|
const out = [];
|
|
1819
|
-
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}]`));
|
|
1820
1945
|
return out;
|
|
1821
1946
|
}
|
|
1822
1947
|
case "struct": {
|
|
1823
1948
|
const o = {};
|
|
1824
|
-
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}`);
|
|
1825
1951
|
return o;
|
|
1826
1952
|
}
|
|
1827
1953
|
}
|
|
@@ -1831,9 +1957,10 @@ function writeRecord(w, fields, value, path) {
|
|
|
1831
1957
|
fail(path, "must be an object");
|
|
1832
1958
|
for (const f of fields) writeValue(w, f.type, value[f.name], `${path}.${f.name}`);
|
|
1833
1959
|
}
|
|
1834
|
-
function readRecord(r, fields) {
|
|
1960
|
+
function readRecord(r, fields, opts, path = "") {
|
|
1835
1961
|
const o = {};
|
|
1836
|
-
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);
|
|
1837
1964
|
return o;
|
|
1838
1965
|
}
|
|
1839
1966
|
function encodeFields(fields, value) {
|
|
@@ -1893,8 +2020,9 @@ function encodeSnapshot(schema, state, header, options) {
|
|
|
1893
2020
|
writeSnapshot(w, schema, state, header.tick, options);
|
|
1894
2021
|
return w.finish();
|
|
1895
2022
|
}
|
|
1896
|
-
function decodeSnapshot(schema, bytes) {
|
|
2023
|
+
function decodeSnapshot(schema, bytes, options = {}) {
|
|
1897
2024
|
const r = new ByteReader(bytes);
|
|
2025
|
+
const opts = { onNonFinite: options.onNonFinite ?? warnNonFinite };
|
|
1898
2026
|
const tick = r.u32();
|
|
1899
2027
|
const hash8 = r.bytes(8).slice();
|
|
1900
2028
|
const state = createState(schema);
|
|
@@ -1903,13 +2031,13 @@ function decodeSnapshot(schema, bytes) {
|
|
|
1903
2031
|
const coll = entityOf(state, c.name);
|
|
1904
2032
|
const n = r.varint();
|
|
1905
2033
|
for (let i = 0; i < n; i++) {
|
|
1906
|
-
const id = r.str();
|
|
1907
|
-
const owner = r.str();
|
|
1908
|
-
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 });
|
|
1909
2037
|
}
|
|
1910
2038
|
} else {
|
|
1911
2039
|
const target = singletonOf(state, c.name);
|
|
1912
|
-
const value = readRecord(r, c.fields);
|
|
2040
|
+
const value = readRecord(r, c.fields, opts, c.name);
|
|
1913
2041
|
for (const f of c.fields) target[f.name] = value[f.name];
|
|
1914
2042
|
}
|
|
1915
2043
|
}
|
|
@@ -2248,6 +2376,7 @@ function skipValue(r, desc) {
|
|
|
2248
2376
|
return;
|
|
2249
2377
|
case "list": {
|
|
2250
2378
|
const n = r.varint();
|
|
2379
|
+
if (n > desc.max) throw new Error(`list exceeds max length ${desc.max} (${n})`);
|
|
2251
2380
|
for (let i = 0; i < n; i++) skipValue(r, desc.item);
|
|
2252
2381
|
return;
|
|
2253
2382
|
}
|
|
@@ -2395,6 +2524,11 @@ function objHandler(node) {
|
|
|
2395
2524
|
get(target, prop, receiver) {
|
|
2396
2525
|
if (typeof prop !== "string") return Reflect.get(target, prop, receiver);
|
|
2397
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
|
+
}
|
|
2398
2532
|
if (!f) return Reflect.get(target, prop, receiver);
|
|
2399
2533
|
const v = target[f.name];
|
|
2400
2534
|
if (v === null || typeof v !== "object") return v;
|
|
@@ -2641,6 +2775,14 @@ var TrackedCollection = class {
|
|
|
2641
2775
|
ownerOf(id) {
|
|
2642
2776
|
return this.inner.ownerOf(id);
|
|
2643
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
|
+
}
|
|
2644
2786
|
get size() {
|
|
2645
2787
|
return this.inner.size;
|
|
2646
2788
|
}
|
|
@@ -2792,7 +2934,9 @@ function diffSchemas(oldSchema, newSchema) {
|
|
|
2792
2934
|
}
|
|
2793
2935
|
function breakingIsWireOnly(changes) {
|
|
2794
2936
|
const breaking = changes.filter((c) => c.kind === "breaking");
|
|
2795
|
-
return breaking.length > 0 && breaking.every(
|
|
2937
|
+
return breaking.length > 0 && breaking.every(
|
|
2938
|
+
(c) => c.path === "rpc" || c.path.startsWith("rpc.") || c.path.startsWith("message.")
|
|
2939
|
+
);
|
|
2796
2940
|
}
|
|
2797
2941
|
function diffCollections(oldCollections, newCollections, changes) {
|
|
2798
2942
|
const oldByName = new Map(oldCollections.map((c) => [c.name, c]));
|
|
@@ -2868,6 +3012,14 @@ function diffCollections(oldCollections, newCollections, changes) {
|
|
|
2868
3012
|
message: `${oldC.name}: spatial grid contract changed \u2014 changes per-client membership`
|
|
2869
3013
|
});
|
|
2870
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
|
+
}
|
|
2871
3023
|
diffFields(oldC.fields, newC.fields, oldC.name, changes);
|
|
2872
3024
|
}
|
|
2873
3025
|
}
|
|
@@ -2894,14 +3046,6 @@ function diffRpcs(oldRpcs, newRpcs, changes) {
|
|
|
2894
3046
|
});
|
|
2895
3047
|
}
|
|
2896
3048
|
}
|
|
2897
|
-
if (oldRpcs.length !== newRpcs.length) {
|
|
2898
|
-
changes.push({
|
|
2899
|
-
kind: "breaking",
|
|
2900
|
-
code: "rpc.builtin_reordered",
|
|
2901
|
-
path: "rpc",
|
|
2902
|
-
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)`
|
|
2903
|
-
});
|
|
2904
|
-
}
|
|
2905
3049
|
for (const oldR of oldRpcs) {
|
|
2906
3050
|
const newR = newByName.get(oldR.name);
|
|
2907
3051
|
if (!newR) continue;
|
|
@@ -2910,7 +3054,7 @@ function diffRpcs(oldRpcs, newRpcs, changes) {
|
|
|
2910
3054
|
kind: "breaking",
|
|
2911
3055
|
code: "rpc.reordered",
|
|
2912
3056
|
path: `rpc.${oldR.name}`,
|
|
2913
|
-
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)`
|
|
2914
3058
|
});
|
|
2915
3059
|
}
|
|
2916
3060
|
if (oldR.direction !== newR.direction) {
|
|
@@ -2956,7 +3100,7 @@ function diffMessages(oldMessages, newMessages, changes) {
|
|
|
2956
3100
|
kind: "breaking",
|
|
2957
3101
|
code: "message.reordered",
|
|
2958
3102
|
path: `message.${oldM.name}`,
|
|
2959
|
-
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)`
|
|
2960
3104
|
});
|
|
2961
3105
|
}
|
|
2962
3106
|
diffFields(oldM.fields, newM.fields, `message.${oldM.name}`, changes);
|
|
@@ -3203,6 +3347,7 @@ export {
|
|
|
3203
3347
|
EntityCollection,
|
|
3204
3348
|
ID_MAX_BYTES,
|
|
3205
3349
|
MAX_MESSAGES,
|
|
3350
|
+
MAX_USER_RPCS,
|
|
3206
3351
|
PHYSICS_BODY_CHANNELS,
|
|
3207
3352
|
RESERVED_COLLECTION_NAMES,
|
|
3208
3353
|
RESERVED_RPC_NAMES,
|
|
@@ -3264,6 +3409,7 @@ export {
|
|
|
3264
3409
|
parseHold,
|
|
3265
3410
|
physicsFromCanonical,
|
|
3266
3411
|
readValue,
|
|
3412
|
+
reconcileCollection,
|
|
3267
3413
|
ref,
|
|
3268
3414
|
schemaFromCanonical,
|
|
3269
3415
|
server,
|
|
@@ -3278,6 +3424,7 @@ export {
|
|
|
3278
3424
|
u16,
|
|
3279
3425
|
u32,
|
|
3280
3426
|
u8,
|
|
3427
|
+
utf8Length,
|
|
3281
3428
|
validateForDeploy,
|
|
3282
3429
|
validateValue,
|
|
3283
3430
|
walkDelta,
|
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@irtio/schema",
|
|
3
|
-
"version": "
|
|
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
|
},
|