@irtio/schema 1.0.0 → 3.1.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.
Files changed (3) hide show
  1. package/dist/index.d.ts +148 -14
  2. package/dist/index.js +185 -38
  3. 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
- type Visibility = 'all' | 'role' | 'spatial-grid';
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
- /** `'all'` (default) | `'role'` (per-role views, needs `roles`) | `'spatial-grid'` (per-client AOI). */
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
- /** Index in `schema.rpcs` (sorted by name). Built-ins are appended by `@irtio/protocol`. */
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` (sorted by name) and
475
- * is what rides the wire, so adding a message whose name sorts early is a breaking change in
476
- * exactly the way adding an early-sorting collection is (see `diffSchemas`).
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` (sorted by name) = wire index. */
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
- declare function decodeSnapshot(schema: AnySchema, bytes: Uint8Array): DecodedSnapshot;
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
- * paths) and there is at least one. Such a change breaks connected clients (the version fence
1122
- * reconnects them) but touches nothing a snapshot stores, so a deploy needs `--allow-breaking`
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
- return typeof v === "number" ? null : `${at} must be a number`;
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
- return Math.fround(v);
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).sort().map((name, index) => {
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 = [...messageNames].sort().map((name, index) => {
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 decoder.decode(this.blob());
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 decoder.decode(this.bytes(n));
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
- w.f32(value);
1750
- return;
1751
- case "f64":
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 readValue(r, desc) {
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
- return r.f32();
1803
- case "f64":
1804
- return r.f64();
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)) o[k] = readValue(r, fd);
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) o[f.name] = readValue(r, f.type);
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((c) => c.path === "rpc" || c.path.startsWith("rpc."));
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 RPCs are addressed by a positional id, so calls would reach the wrong handler (breaking)`
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 messages are addressed positionally, so peers would decode this one as another (breaking)`
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": "1.0.0",
3
+ "version": "3.1.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
  },