@irtio/schema 3.1.1 → 4.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.
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # @irtio/schema
2
+
3
+ Define shared multiplayer state, typed RPCs, and messages. The same schema supplies TypeScript
4
+ types and binary encoding for your room and its clients.
5
+
6
+ ```bash
7
+ npm install @irtio/schema
8
+ ```
9
+
10
+ ## Define state and an RPC
11
+
12
+ Create `irtio/schema.ts`:
13
+
14
+ ```ts
15
+ import { defineSchema, entity, f32, server, singleton, u32 } from '@irtio/schema';
16
+
17
+ export const schema = defineSchema(
18
+ {
19
+ players: entity({ x: f32, y: f32 }),
20
+ match: singleton({ round: u32 }),
21
+ },
22
+ {
23
+ roles: ['player'] as const,
24
+ rpc: {
25
+ nextRound: server({}),
26
+ },
27
+ },
28
+ );
29
+ ```
30
+
31
+ `players` contains records addressed by id. `match` is one server-owned object. `nextRound`
32
+ declares a client-to-server call; implement it with `defineRoom` from `@irtio/server`.
33
+ For a deployed project, use the project id generated by `irtio init` in the schema options.
34
+
35
+ ## Choose a schema primitive
36
+
37
+ | Export | Use |
38
+ | --- | --- |
39
+ | `entity(fields, options?)` | Records with ids, ownership, and optional visibility rules |
40
+ | `singleton(fields, options?)` | One object, written by the server |
41
+ | `bool`, `u8`, `u16`, `u32`, `i32`, `f32`, `f64` | Boolean and numeric fields |
42
+ | `str(maxBytes)` | A UTF-8 string with a byte limit |
43
+ | `enumOf(...)`, `struct(...)`, `list(...)`, `ref(...)` | Enums, nested values, bounded lists, and references |
44
+ | `server(spec)`, `client(spec)` | RPC declarations in either direction |
45
+ | `State<typeof schema>` | The inferred server state type |
46
+
47
+ A schema validates shape and bounds. Authorize actions and validate gameplay in room handlers.
48
+ Append new RPCs and messages to their declaration maps to preserve existing wire ids.
49
+
50
+ For codec and tooling integrations, the package also exports state creation, snapshot and delta
51
+ codecs, change tracking, canonical schema reconstruction, and schema diffing. Use the client SDK
52
+ and room API to manage these during normal gameplay.
53
+
54
+ See the [schema reference](https://irt.io/docs/reference/schema) for field signatures, collection
55
+ options, defaults, and migration rules, or start with the
56
+ [quickstart](https://irt.io/docs/getting-started).
package/dist/index.d.ts CHANGED
@@ -123,6 +123,11 @@ declare function normalizeValue(d: TypeDesc, v: unknown, path?: string): unknown
123
123
  * physics entity is client-writable, so a client's write history for one of these entities is
124
124
  * exactly its intent frames — which is what week 10's client-side re-stepping replays.
125
125
  *
126
+ * That makes `intents` the owner-write list rather than a force list, which surprises people: a
127
+ * field the owner writes and the step never reads — a cosmetic `yaw`, a held emote — still goes
128
+ * in `intents`, because that is the only way to make a field of a body-backed collection writable
129
+ * at all. Listing a non-force there is correct, not a misuse.
130
+ *
126
131
  * Unlike week 8's `interpolate` (a client-side rendering choice, deliberately outside the schema
127
132
  * hash), physics options are **server shape**: a room and a client that disagree about which
128
133
  * fields the simulation owns disagree about the world. They go in the canonical form and
@@ -143,7 +148,12 @@ type PhysicsBodyMap = {
143
148
  interface EntityPhysics {
144
149
  /** Fields the simulation owns: translation (`x`/`y`/`z`), rotation quaternion (`q*`), velocities (`v*`/`w*`). */
145
150
  readonly body: PhysicsBodyMap;
146
- /** Owner-written input fields the step consumes. Declaration order is the canonical order. */
151
+ /**
152
+ * The owner-write list: every field the owning client may write. Usually inputs the step
153
+ * consumes, but not only — a purely cosmetic owned field (a `yaw` nothing simulates, a chosen
154
+ * colour) belongs here too, because this list *is* how a field on a body-backed collection
155
+ * becomes writable. Declaration order is the canonical order.
156
+ */
147
157
  readonly intents?: readonly string[];
148
158
  }
149
159
  /** Compiled form on `CollectionDesc.physics`. */
@@ -282,10 +292,22 @@ declare function ownershipFromCanonical(at: string, raw: unknown): OwnershipOpti
282
292
 
283
293
  /**
284
294
  * `'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`.
295
+ * its rows are never encoded into a snapshot or a delta. The collection's definition — its name
296
+ * and field names — is still part of the schema the server and every client share, so the names
297
+ * are public and only the values are private. It is the first-class spelling of what
298
+ * `visibility: 'role'` with an empty `roles` list has always done by accident, and it takes no
299
+ * `roles`.
287
300
  */
288
- type Visibility = 'all' | 'role' | 'spatial-grid' | 'server';
301
+ /**
302
+ * `'owner'` is per-record, not per-role: an entity record is visible to the one client that owns
303
+ * it — the first-class ownership every entity record already carries, the thing `perPlayer`,
304
+ * `requestOwnership` and `ctx.owner` all speak about — and to the server. Its adds and removes are
305
+ * scoped the same way, so a client never learns another client's record exists, and a transfer of
306
+ * ownership reaches the old owner as a `remove` and the new one as an `add`. A record left
307
+ * server-owned (`SERVER_OWNER`, the empty string) is owned by no client and is therefore visible
308
+ * to none. It is entity-only — a singleton has no owner — and takes no `roles` and no `grid`.
309
+ */
310
+ type Visibility = 'all' | 'role' | 'spatial-grid' | 'server' | 'owner';
289
311
  interface GridOptions<K extends string = string> {
290
312
  /** Numeric fields containing the entity's world position. */
291
313
  readonly x: K;
@@ -304,7 +326,8 @@ interface EntityOptions {
304
326
  readonly serverOwned?: boolean;
305
327
  /**
306
328
  * `'all'` (default) | `'role'` (per-role views, needs `roles`) | `'spatial-grid'` (per-client
307
- * AOI) | `'server'` (room-private: no client sees or writes it).
329
+ * AOI) | `'server'` (room-private: no client sees or writes it) | `'owner'` (per-record: the
330
+ * client that owns the record, and the server).
308
331
  */
309
332
  readonly visibility?: Visibility;
310
333
  /** With `visibility: 'role'`: the roles that see this collection. */
@@ -361,6 +384,17 @@ interface EntityOptions {
361
384
  * Server behaviour, so it is part of the canonical form and the hash — unlike `predicted`.
362
385
  */
363
386
  readonly perPlayer?: boolean;
387
+ /**
388
+ * M8 proposal 13: the fields of this per-player record that outlive the room.
389
+ *
390
+ * On a relay-with-state project a client that joins with a verified identity assertion has these
391
+ * fields hydrated from a per-(project, subject) blob, and they are written back when the player
392
+ * leaves. Anonymous joins get schema defaults and write nothing. Requires `perPlayer: true`, the
393
+ * named fields must exist, and their worst-case encoded size must fit `PERSIST_BLOB_BYTES`.
394
+ *
395
+ * Server behaviour — hydration shape — so it is part of the canonical form and the hash.
396
+ */
397
+ readonly persist?: readonly string[];
364
398
  /**
365
399
  * D77: clients may `add` records to this collection (the adder becomes the owner, with the id
366
400
  * it chose) and the owner may `remove` one. Off by default, which is what every collection
@@ -387,8 +421,10 @@ interface EntityOptions {
387
421
  * only when declared, so every schema written before this keeps the hash it had.
388
422
  *
389
423
  * 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.
424
+ * both are refused. Refused on a `physics` collection too, and for the same reason: the owner
425
+ * writes only the fields in `physics.intents`, so every other field is already server-only.
426
+ * (`physics.intents` is the owner-write list, not a force list — an owned field the step never
427
+ * reads, a cosmetic `yaw` say, goes in `intents` too. That is how it becomes writable.)
392
428
  *
393
429
  * There is deliberately no dynamic form. A per-field *owner id* would have to ride the wire per
394
430
  * field per record, and the case that needs one is served by a second collection.
@@ -413,6 +449,21 @@ declare function entity<F extends Fields, const O extends EntityOptions>(fields:
413
449
  /** Exactly one instance; same `serverOwned`/`visibility` options. */
414
450
  declare function singleton<F extends Fields>(fields: F): SingletonDef<F, {}>;
415
451
  declare function singleton<F extends Fields, const O extends EntityOptions>(fields: F, options: O): SingletonDef<F, O>;
452
+ /**
453
+ * The hard cap on one player's persistence blob, in encoded bytes.
454
+ *
455
+ * A compile that could exceed it is refused here rather than at the first leave that would have
456
+ * written an oversize blob: a cap discovered in production is a cap nobody can act on.
457
+ */
458
+ declare const PERSIST_BLOB_BYTES: number;
459
+ /**
460
+ * The worst-case encoded size of `names` out of `fields`, in bytes.
461
+ *
462
+ * Static and deliberately pessimistic: every bound is the largest value the declared type can
463
+ * hold, so a schema that compiles can never write a blob over the cap. Mirrors `codec.ts`'s
464
+ * writers — LEB128 varints for list and string lengths, one presence byte for `.opt`.
465
+ */
466
+ declare function maxEncodedBytes(fields: readonly FieldDesc[], names: readonly string[]): number;
416
467
  type RpcDirection = 'server' | 'client';
417
468
  interface RpcDef<Dir extends RpcDirection = RpcDirection, P extends Fields = Fields, R extends Fields | undefined = Fields | undefined> {
418
469
  readonly kind: 'rpc';
@@ -484,6 +535,11 @@ interface CollectionDesc {
484
535
  readonly physics: PhysicsDesc | undefined;
485
536
  /** D77: one record per joined client, owned by them. In the hash. */
486
537
  readonly perPlayer: boolean;
538
+ /**
539
+ * M8 proposal 13: the per-player fields that outlive the room, sorted, or `undefined` when none
540
+ * were declared. In the hash when present.
541
+ */
542
+ readonly persist: readonly string[] | undefined;
487
543
  /** D77: clients may add records here and their owner may remove them. In the hash. */
488
544
  readonly clientCreate: boolean;
489
545
  /** D77: the compiled transfer and leave rules. `undefined` when none were declared. In the hash. */
@@ -793,12 +849,30 @@ type State<S> = {
793
849
  type RolesOf<O extends EntityOptions> = O extends {
794
850
  roles: readonly (infer R)[];
795
851
  } ? R : never;
796
- /** Is collection with options `O` visible to `Role`? */
852
+ /**
853
+ * Is a collection with options `O` visible to `Role`?
854
+ *
855
+ * `server` is never visible. A `role` collection is visible when `Role` **overlaps** its roles,
856
+ * not when `Role` is wholly contained in them — and that distinction is the whole point.
857
+ *
858
+ * The default `Role` on {@link ClientState} is the union of every role the schema declares. Under
859
+ * containment, `['host' | 'guest'] extends ['host']` is false, so a bare `ClientState<S>` dropped
860
+ * *every* role-visible collection and reading one needed a cast — even though a hidden collection
861
+ * is present at runtime (the wire has a slot for every collection; a client without the role just
862
+ * receives it empty or default-valued). Overlap gives the bare default all of them, which is what
863
+ * the runtime hands you, while `ClientState<S, 'hunter'>` still excludes anything scoped only to
864
+ * other roles, which is what a role-narrowed view is for.
865
+ *
866
+ * `spatial-grid` and `owner` are `true` for every role, for the same reason: they scope *entities*,
867
+ * not collections. The collection is present in every client's state — empty, or holding only the
868
+ * ids that client's AOI or ownership entitles it to — and the filtering happens at runtime, which
869
+ * no role-indexed type can express.
870
+ */
797
871
  type VisibleTo<O extends EntityOptions, Role extends string> = O extends {
798
872
  visibility: 'server';
799
873
  } ? false : O extends {
800
874
  visibility: 'role';
801
- } ? [Role] extends [RolesOf<O>] ? true : false : true;
875
+ } ? [Extract<Role, RolesOf<O>>] extends [never] ? false : true : true;
802
876
  type VisibleKeys<S, Role extends string> = {
803
877
  [K in keyof SchemaDefs<S>]: VisibleTo<DefOptions<SchemaDefs<S>[K]>, Role> extends true ? K : never;
804
878
  }[keyof SchemaDefs<S>];
@@ -1258,4 +1332,4 @@ declare function diffSchemas(oldSchema: AnySchema, newSchema: AnySchema): Schema
1258
1332
  */
1259
1333
  declare function breakingIsWireOnly(changes: readonly SchemaChange[]): boolean;
1260
1334
 
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 };
1335
+ 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, PERSIST_BLOB_BYTES, 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, maxEncodedBytes, 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
@@ -699,11 +699,95 @@ function compileServerFields(name, kind, fields, o) {
699
699
  }
700
700
  if (o.physics) {
701
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`
702
+ `${name}: serverFields is redundant on a physics collection \u2014 the owner writes only the fields in physics.intents, so every other field is already server-only. Remove serverFields`
703
703
  );
704
704
  }
705
705
  return new Set([...out].sort());
706
706
  }
707
+ var PERSIST_BLOB_BYTES = 4 * 1024;
708
+ function maxEncodedBytes(fields, names) {
709
+ const byName = new Map(fields.map((f) => [f.name, f]));
710
+ let total = 0;
711
+ for (const n of names) {
712
+ const f = byName.get(n);
713
+ if (f) total += worstCase(f.type);
714
+ }
715
+ return total;
716
+ }
717
+ function varintBytes(max) {
718
+ let bytes = 1;
719
+ let v = Math.max(0, Math.floor(max));
720
+ while (v > 127) {
721
+ v = Math.floor(v / 128);
722
+ bytes++;
723
+ }
724
+ return bytes;
725
+ }
726
+ function worstCase(d) {
727
+ const presence = d.opt ? 1 : 0;
728
+ switch (d.kind) {
729
+ case "bool":
730
+ case "u8":
731
+ case "enum":
732
+ return presence + 1;
733
+ case "u16":
734
+ return presence + 2;
735
+ case "u32":
736
+ case "i32":
737
+ case "f32":
738
+ return presence + 4;
739
+ case "f64":
740
+ return presence + 8;
741
+ case "str":
742
+ return presence + varintBytes(d.max) + d.max;
743
+ case "ref":
744
+ return presence + varintBytes(ID_MAX_BYTES) + ID_MAX_BYTES;
745
+ case "list":
746
+ return presence + varintBytes(d.max) + d.max * worstCase(d.item);
747
+ case "struct": {
748
+ let n = presence;
749
+ for (const fd of Object.values(d.fields)) n += worstCase(fd);
750
+ return n;
751
+ }
752
+ }
753
+ }
754
+ function compilePersist(name, fields, perPlayer, declared) {
755
+ if (declared === void 0) return void 0;
756
+ if (!Array.isArray(declared)) {
757
+ throw new Error(`${name}: persist must be an array of field names`);
758
+ }
759
+ if (!perPlayer) {
760
+ throw new Error(
761
+ `${name}: persist requires perPlayer \u2014 only a per-player record has a player to follow`
762
+ );
763
+ }
764
+ if (declared.length === 0) {
765
+ throw new Error(`${name}: persist is empty; leave it off instead`);
766
+ }
767
+ const known = new Map(fields.map((f) => [f.name, f]));
768
+ const out = [];
769
+ for (const field of declared) {
770
+ if (typeof field !== "string" || !known.has(field)) {
771
+ throw new Error(
772
+ `${name}: persist names ${JSON.stringify(field)}, which is not a field of ${name}`
773
+ );
774
+ }
775
+ if (out.includes(field)) throw new Error(`${name}: persist names ${field} twice`);
776
+ if (known.get(field).type.kind === "ref") {
777
+ throw new Error(
778
+ `${name}: persist names ${field}, an entity ref \u2014 a ref is an id inside one room and means nothing in the next one`
779
+ );
780
+ }
781
+ out.push(field);
782
+ }
783
+ const bytes = maxEncodedBytes(fields, out);
784
+ if (bytes > PERSIST_BLOB_BYTES) {
785
+ throw new Error(
786
+ `${name}: persist fields can encode up to ${bytes} bytes, over the 4 KB persistence cap`
787
+ );
788
+ }
789
+ return out.sort();
790
+ }
707
791
  function checkFields(fields) {
708
792
  const names = Object.keys(fields);
709
793
  if (names.length === 0) throw new Error("entity/singleton needs at least one field");
@@ -762,6 +846,7 @@ function defineSchema(defs, options = {}) {
762
846
  );
763
847
  }
764
848
  const ownership = o.ownership || perPlayer ? compileOwnership(name, def.kind, fields, o.ownership ?? {}, perPlayer) : void 0;
849
+ const persist = compilePersist(name, fields, perPlayer, o.persist);
765
850
  const serverFields = compileServerFields(name, def.kind, fields, o);
766
851
  return {
767
852
  name,
@@ -772,6 +857,7 @@ function defineSchema(defs, options = {}) {
772
857
  serverOwned: o.serverOwned === true,
773
858
  serverFields,
774
859
  perPlayer,
860
+ persist,
775
861
  clientCreate: o.clientCreate === true,
776
862
  ownership,
777
863
  visibility: o.visibility ?? "all",
@@ -982,6 +1068,10 @@ function canonicalize(s) {
982
1068
  // writes `upgrades` disagree about the world.
983
1069
  serverFields: c.serverFields ? [...c.serverFields] : void 0,
984
1070
  perPlayer: c.perPlayer ? true : void 0,
1071
+ // M8 proposal 13: the same only-when-declared rule again. In the hash when present because
1072
+ // it is hydration shape: a room that fills a field from a blob and a client that does not
1073
+ // expect it to be filled disagree about the world.
1074
+ persist: c.persist ? [...c.persist] : void 0,
985
1075
  clientCreate: c.clientCreate ? true : void 0,
986
1076
  ownership: c.ownership ? canonicalOwnership(c.ownership) : void 0
987
1077
  // ---- end M6 lane K ----
@@ -1075,6 +1165,26 @@ function validateForDeploy(schema) {
1075
1165
  message: `${c.name}: grid is only valid with visibility 'spatial-grid'`
1076
1166
  });
1077
1167
  }
1168
+ if (c.visibility === "owner") {
1169
+ if (c.kind !== "entity") {
1170
+ issues.push({
1171
+ level: "error",
1172
+ message: `${c.name}: owner visibility is entity-only \u2014 a singleton has no owner`
1173
+ });
1174
+ }
1175
+ if (c.roles && c.roles.length > 0) {
1176
+ issues.push({
1177
+ level: "error",
1178
+ message: `${c.name}: visibility 'owner' takes no roles \u2014 it is scoped by record ownership, not by role`
1179
+ });
1180
+ }
1181
+ if (c.serverOwned) {
1182
+ issues.push({
1183
+ level: "warning",
1184
+ message: `${c.name}: visibility 'owner' on a serverOwned collection \u2014 every record stays server-owned, so no client ever sees one`
1185
+ });
1186
+ }
1187
+ }
1078
1188
  if (c.visibility === "server" && c.roles && c.roles.length > 0) {
1079
1189
  issues.push({
1080
1190
  level: "error",
@@ -1236,7 +1346,7 @@ function typeOf(t, at) {
1236
1346
  }
1237
1347
  function entityOptions(e, name) {
1238
1348
  const visibility = stringAt(e, "visibility");
1239
- if (visibility !== "all" && visibility !== "role" && visibility !== "spatial-grid" && visibility !== "server") {
1349
+ if (visibility !== "all" && visibility !== "role" && visibility !== "spatial-grid" && visibility !== "server" && visibility !== "owner") {
1240
1350
  throw new Error(
1241
1351
  `schemaFromCanonical: ${name}: unknown visibility ${JSON.stringify(visibility)}`
1242
1352
  );
@@ -1250,6 +1360,11 @@ function entityOptions(e, name) {
1250
1360
  const physics = e.physics === void 0 || e.physics === null ? void 0 : physicsFromCanonical(`schemaFromCanonical: ${name}`, e.physics);
1251
1361
  const grid = e.grid === void 0 || e.grid === null ? void 0 : gridFromCanonical(name, objectAt(e, "grid"));
1252
1362
  const ownership = e.ownership === void 0 || e.ownership === null ? void 0 : ownershipFromCanonical(`schemaFromCanonical: ${name}`, e.ownership);
1363
+ const persist = e.persist === void 0 || e.persist === null ? void 0 : arrayAtRaw(e, "persist").map((f) => {
1364
+ if (typeof f !== "string")
1365
+ throw new Error(`schemaFromCanonical: ${name}: persist must be strings`);
1366
+ return f;
1367
+ });
1253
1368
  const serverFields = e.serverFields === void 0 || e.serverFields === null ? void 0 : arrayAtRaw(e, "serverFields").map((f) => {
1254
1369
  if (typeof f !== "string")
1255
1370
  throw new Error(`schemaFromCanonical: ${name}: serverFields must be strings`);
@@ -1263,6 +1378,7 @@ function entityOptions(e, name) {
1263
1378
  ...physics !== void 0 ? { physics } : {},
1264
1379
  // ---- M6 lane K: stateful relay ----
1265
1380
  ...e.perPlayer === true ? { perPlayer: true } : {},
1381
+ ...persist !== void 0 ? { persist } : {},
1266
1382
  ...e.clientCreate === true ? { clientCreate: true } : {},
1267
1383
  ...ownership !== void 0 ? { ownership } : {},
1268
1384
  // ---- end M6 lane K ----
@@ -3348,6 +3464,7 @@ export {
3348
3464
  ID_MAX_BYTES,
3349
3465
  MAX_MESSAGES,
3350
3466
  MAX_USER_RPCS,
3467
+ PERSIST_BLOB_BYTES,
3351
3468
  PHYSICS_BODY_CHANNELS,
3352
3469
  RESERVED_COLLECTION_NAMES,
3353
3470
  RESERVED_RPC_NAMES,
@@ -3401,6 +3518,7 @@ export {
3401
3518
  markOwner,
3402
3519
  markPath,
3403
3520
  markRemove,
3521
+ maxEncodedBytes,
3404
3522
  mergeDirty,
3405
3523
  mergeMask,
3406
3524
  normalizeRecord,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/schema",
3
- "version": "3.1.1",
3
+ "version": "4.1.0",
4
4
  "description": "irtio schema DSL, type inference, canonical hash, codec, change tracking, and schema diff",
5
5
  "license": "MIT",
6
6
  "repository": {