@irtio/runtime 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.
@@ -1,7 +1,9 @@
1
1
  import {
2
+ HOLD_ACTIONS,
2
3
  parseHibernationBlob,
4
+ sameSchemaHash,
3
5
  writeHibernationBlob
4
- } from "./chunk-LT46QFLW.js";
6
+ } from "./chunk-NPRIYYNM.js";
5
7
 
6
8
  // src/migrate.ts
7
9
  import { PRESENCE_COLLECTION, withBuiltins } from "@irtio/protocol";
@@ -24,8 +26,18 @@ function migrateSnapshot(blob, chain, options) {
24
26
  }
25
27
  const parsed = parseHibernationBlob(blob);
26
28
  let schema = schemaFromCanonical(first.schemaJson);
27
- let plain = decodeSnapshot(withBuiltins(schema), parsed.snapshot).state;
28
- let state = toMigrationState(withBuiltins(schema), plain);
29
+ let ext = withBuiltins(schema);
30
+ if (!sameSchemaHash(parsed.schemaHash8, ext.hash8)) {
31
+ throw new Error(
32
+ `migrateSnapshot: room ${options.roomId}: the snapshot was written under schema ${hex(parsed.schemaHash8)}, but the chain starts at v${first.version} whose schema is ${hex(ext.hash8)}. The migration chain does not begin at the deployment these bytes were written under \u2014 most likely the save predates the oldest retained deployment.`
33
+ );
34
+ }
35
+ let plain = decodeSnapshot(ext, parsed.snapshot, {
36
+ onNonFinite: (path, value) => options.log?.("warn", [
37
+ `migrateSnapshot: room ${options.roomId}: ${path} held ${value}; reset to its default`
38
+ ])
39
+ }).state;
40
+ let state = toMigrationState(ext, plain);
29
41
  delete state[PRESENCE_COLLECTION];
30
42
  const applied = [];
31
43
  for (let i = 1; i < chain.length; i++) {
@@ -51,7 +63,8 @@ function migrateSnapshot(blob, chain, options) {
51
63
  }
52
64
  schema = nextSchema;
53
65
  }
54
- plain = fromMigrationState(withBuiltins(schema), state, last.version);
66
+ ext = withBuiltins(schema);
67
+ plain = fromMigrationState(ext, state, last.version);
55
68
  const bytes = writeHibernationBlob(
56
69
  {
57
70
  seed: parsed.seed,
@@ -60,9 +73,23 @@ function migrateSnapshot(blob, chain, options) {
60
73
  mode: parsed.mode,
61
74
  // Stamped with the *target* schema: these bytes are now that schema's, and a room waking on
62
75
  // it must not see them as a mismatch.
63
- schemaHash8: withBuiltins(schema).hash8
76
+ schemaHash8: ext.hash8
64
77
  },
65
- encodeSnapshot(withBuiltins(schema), plain, { tick: parsed.tick })
78
+ encodeSnapshot(ext, plain, { tick: parsed.tick }),
79
+ // The physics section is dropped, and that is the D22 rule rather than an oversight: the world
80
+ // is rebuilt from post-transform schema state, because pose and velocity ARE schema fields and
81
+ // a migration is free to move them. Carrying the serialized world across would silently
82
+ // overwrite whatever the transform wrote — `physics-migrate.test.ts` pins both halves of that.
83
+ // What follows here is the *third* section, which had no such reason.
84
+ void 0,
85
+ // D77 holds were dropped the same way physics is, but by accident: `writeHibernationBlob` takes
86
+ // four arguments and this call passed two, so a migrating deploy silently emptied the hold map
87
+ // and nothing logged. A crate under `onLeave: { hold, then }` then woke with no hold, its
88
+ // deadline never fired, and the record stayed owned by a client id that never comes back —
89
+ // unwritable by anyone, forever. Unlike a physics world, a hold has no field layout in it, so
90
+ // it survives a schema change on its own terms; what it needs is re-validating against the
91
+ // schema it is about to wake under.
92
+ migrateHolds(parsed.holds, ext, options)
66
93
  );
67
94
  return {
68
95
  bytes,
@@ -72,6 +99,52 @@ function migrateSnapshot(blob, chain, options) {
72
99
  ms: performance.now() - startedAt
73
100
  };
74
101
  }
102
+ function hex(bytes) {
103
+ if (!bytes) return "(none \u2014 a pre-v3 blob carries no schema hash)";
104
+ return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
105
+ }
106
+ function migrateHolds(bytes, target, options) {
107
+ if (!bytes || bytes.length === 0) return void 0;
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(new TextDecoder().decode(bytes));
111
+ } catch (err) {
112
+ options.log?.("warn", ["migrate: hold deadlines did not decode; they are dropped:", err]);
113
+ return void 0;
114
+ }
115
+ if (!Array.isArray(parsed)) {
116
+ options.log?.("warn", ["migrate: hold deadlines were not an array; they are dropped"]);
117
+ return void 0;
118
+ }
119
+ const entities = new Map(
120
+ target.collections.map((c) => [c.name, c.kind])
121
+ );
122
+ const kept = [];
123
+ for (const h of parsed) {
124
+ if (typeof h?.collection !== "string" || typeof h.id !== "string" || typeof h.holder !== "string" || typeof h.deadlineMs !== "number") {
125
+ options.log?.("warn", ["migrate: dropping a hold that is not a hold record:", h]);
126
+ continue;
127
+ }
128
+ const kind = entities.get(h.collection);
129
+ if (kind !== "entity") {
130
+ options.log?.("warn", [
131
+ `migrate: dropping the hold on ${h.collection}.${h.id} \u2014`,
132
+ kind === void 0 ? "the target schema no longer declares that collection" : "that collection is no longer an entity collection"
133
+ ]);
134
+ continue;
135
+ }
136
+ if (!HOLD_ACTIONS.has(h.then)) {
137
+ options.log?.("warn", [
138
+ `migrate: dropping the hold on ${h.collection}.${h.id} \u2014 unknown leave action`,
139
+ h.then
140
+ ]);
141
+ continue;
142
+ }
143
+ kept.push(h);
144
+ }
145
+ if (kept.length === 0) return void 0;
146
+ return new TextEncoder().encode(JSON.stringify(kept));
147
+ }
75
148
  function toMigrationState(schema, plain) {
76
149
  const out = {};
77
150
  for (const c of schema.collections) {