@irtio/schema 3.1.1 → 4.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 CHANGED
@@ -361,6 +361,17 @@ interface EntityOptions {
361
361
  * Server behaviour, so it is part of the canonical form and the hash — unlike `predicted`.
362
362
  */
363
363
  readonly perPlayer?: boolean;
364
+ /**
365
+ * M8 proposal 13: the fields of this per-player record that outlive the room.
366
+ *
367
+ * On a relay-with-state project a client that joins with a verified identity assertion has these
368
+ * fields hydrated from a per-(project, subject) blob, and they are written back when the player
369
+ * leaves. Anonymous joins get schema defaults and write nothing. Requires `perPlayer: true`, the
370
+ * named fields must exist, and their worst-case encoded size must fit `PERSIST_BLOB_BYTES`.
371
+ *
372
+ * Server behaviour — hydration shape — so it is part of the canonical form and the hash.
373
+ */
374
+ readonly persist?: readonly string[];
364
375
  /**
365
376
  * D77: clients may `add` records to this collection (the adder becomes the owner, with the id
366
377
  * it chose) and the owner may `remove` one. Off by default, which is what every collection
@@ -413,6 +424,21 @@ declare function entity<F extends Fields, const O extends EntityOptions>(fields:
413
424
  /** Exactly one instance; same `serverOwned`/`visibility` options. */
414
425
  declare function singleton<F extends Fields>(fields: F): SingletonDef<F, {}>;
415
426
  declare function singleton<F extends Fields, const O extends EntityOptions>(fields: F, options: O): SingletonDef<F, O>;
427
+ /**
428
+ * The hard cap on one player's persistence blob, in encoded bytes.
429
+ *
430
+ * A compile that could exceed it is refused here rather than at the first leave that would have
431
+ * written an oversize blob: a cap discovered in production is a cap nobody can act on.
432
+ */
433
+ declare const PERSIST_BLOB_BYTES: number;
434
+ /**
435
+ * The worst-case encoded size of `names` out of `fields`, in bytes.
436
+ *
437
+ * Static and deliberately pessimistic: every bound is the largest value the declared type can
438
+ * hold, so a schema that compiles can never write a blob over the cap. Mirrors `codec.ts`'s
439
+ * writers — LEB128 varints for list and string lengths, one presence byte for `.opt`.
440
+ */
441
+ declare function maxEncodedBytes(fields: readonly FieldDesc[], names: readonly string[]): number;
416
442
  type RpcDirection = 'server' | 'client';
417
443
  interface RpcDef<Dir extends RpcDirection = RpcDirection, P extends Fields = Fields, R extends Fields | undefined = Fields | undefined> {
418
444
  readonly kind: 'rpc';
@@ -484,6 +510,11 @@ interface CollectionDesc {
484
510
  readonly physics: PhysicsDesc | undefined;
485
511
  /** D77: one record per joined client, owned by them. In the hash. */
486
512
  readonly perPlayer: boolean;
513
+ /**
514
+ * M8 proposal 13: the per-player fields that outlive the room, sorted, or `undefined` when none
515
+ * were declared. In the hash when present.
516
+ */
517
+ readonly persist: readonly string[] | undefined;
487
518
  /** D77: clients may add records here and their owner may remove them. In the hash. */
488
519
  readonly clientCreate: boolean;
489
520
  /** D77: the compiled transfer and leave rules. `undefined` when none were declared. In the hash. */
@@ -1258,4 +1289,4 @@ declare function diffSchemas(oldSchema: AnySchema, newSchema: AnySchema): Schema
1258
1289
  */
1259
1290
  declare function breakingIsWireOnly(changes: readonly SchemaChange[]): boolean;
1260
1291
 
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 };
1292
+ 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
@@ -704,6 +704,90 @@ function compileServerFields(name, kind, fields, o) {
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 ----
@@ -1250,6 +1340,11 @@ function entityOptions(e, name) {
1250
1340
  const physics = e.physics === void 0 || e.physics === null ? void 0 : physicsFromCanonical(`schemaFromCanonical: ${name}`, e.physics);
1251
1341
  const grid = e.grid === void 0 || e.grid === null ? void 0 : gridFromCanonical(name, objectAt(e, "grid"));
1252
1342
  const ownership = e.ownership === void 0 || e.ownership === null ? void 0 : ownershipFromCanonical(`schemaFromCanonical: ${name}`, e.ownership);
1343
+ const persist = e.persist === void 0 || e.persist === null ? void 0 : arrayAtRaw(e, "persist").map((f) => {
1344
+ if (typeof f !== "string")
1345
+ throw new Error(`schemaFromCanonical: ${name}: persist must be strings`);
1346
+ return f;
1347
+ });
1253
1348
  const serverFields = e.serverFields === void 0 || e.serverFields === null ? void 0 : arrayAtRaw(e, "serverFields").map((f) => {
1254
1349
  if (typeof f !== "string")
1255
1350
  throw new Error(`schemaFromCanonical: ${name}: serverFields must be strings`);
@@ -1263,6 +1358,7 @@ function entityOptions(e, name) {
1263
1358
  ...physics !== void 0 ? { physics } : {},
1264
1359
  // ---- M6 lane K: stateful relay ----
1265
1360
  ...e.perPlayer === true ? { perPlayer: true } : {},
1361
+ ...persist !== void 0 ? { persist } : {},
1266
1362
  ...e.clientCreate === true ? { clientCreate: true } : {},
1267
1363
  ...ownership !== void 0 ? { ownership } : {},
1268
1364
  // ---- end M6 lane K ----
@@ -3348,6 +3444,7 @@ export {
3348
3444
  ID_MAX_BYTES,
3349
3445
  MAX_MESSAGES,
3350
3446
  MAX_USER_RPCS,
3447
+ PERSIST_BLOB_BYTES,
3351
3448
  PHYSICS_BODY_CHANNELS,
3352
3449
  RESERVED_COLLECTION_NAMES,
3353
3450
  RESERVED_RPC_NAMES,
@@ -3401,6 +3498,7 @@ export {
3401
3498
  markOwner,
3402
3499
  markPath,
3403
3500
  markRemove,
3501
+ maxEncodedBytes,
3404
3502
  mergeDirty,
3405
3503
  mergeMask,
3406
3504
  normalizeRecord,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/schema",
3
- "version": "3.1.1",
3
+ "version": "4.0.0",
4
4
  "description": "irtio schema DSL, type inference, canonical hash, codec, change tracking, and schema diff",
5
5
  "license": "MIT",
6
6
  "repository": {