@homie-rocks/studio 0.46.5 → 0.46.6

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/worker/index.mjs CHANGED
@@ -90,7 +90,7 @@ import { appRole, openPath } from './app-format.mjs';
90
90
  import { playerEnabled, EMBED_GAME_JS } from './embed.mjs';
91
91
  import { NetRoom, WATCH_POLICIES, departure, errorLine, versionOf } from './room.mjs';
92
92
  import { hostedGame, hostedBuild, compiledFor, startHost } from './hosted.mjs';
93
- import { roomStore, restoreDelay } from './room-store.mjs';
93
+ import { roomStore, restoreDelay, LOOSE_SAVE } from './room-store.mjs';
94
94
  import { appCors, isAppOrigin } from './standalone.mjs';
95
95
  import { ROOM_ID, badRoomPage, embedAncestors, embedPreview, frameAncestors, noWatchPage, playPage, shellKey, shellStandIn, watchPage } from './pages.mjs';
96
96
  import {
@@ -105,7 +105,7 @@ import { forgetLines, historyOf, keepLines, keptLine } from './lounge-store.mjs'
105
105
  import { isLoungePath, loungeRoutes } from './lounge.mjs';
106
106
  import { doorPage, serverPage, serversPage } from './site.mjs';
107
107
  import { isLocalOrigin, qrSvg } from './qr.mjs';
108
- import { acceptMultiplex, diagnosticsOf, gateFor, nextGate } from './gate.mjs';
108
+ import { acceptMultiplex, diagnosticsOf, gateFor, gatePlayers, nextGate } from './gate.mjs';
109
109
  import { worldSource } from './world-wire.mjs';
110
110
  export { Gate, Concentrator } from './gate.mjs';
111
111
  import { seatCount, paramsFrom, perAddress, seatsOf } from './seats.mjs';
@@ -1361,6 +1361,9 @@ export class Table {
1361
1361
  if (this.saved?.retired === true) this.cleanupPending = true;
1362
1362
  this.recorded = (await ctx.storage.get('recorded')) ?? 0;
1363
1363
  this.officeSaved = (await ctx.storage.get('office')) ?? null;
1364
+ // A big match room's last unconfirmed checkpoint (worker/room-store.mjs), read once as the object wakes.
1365
+ try { this.looseSave = (await ctx.storage.list?.({ prefix: LOOSE_SAVE })) ?? null; }
1366
+ catch (error) { this.looseSave = null; this.storageProblem(error); }
1364
1367
  });
1365
1368
  }
1366
1369
 
@@ -1434,7 +1437,7 @@ export class Table {
1434
1437
  try {
1435
1438
  const build = hostedBuild(this.game);
1436
1439
  const persistent = this.isPersistent();
1437
- try { this.durable ??= roomStore(this.ctx.storage, { persistent: this.isPersistent() }); } catch (error) { if (persistent) throw error; this.storageProblem(error); }
1440
+ try { this.durable ??= this.openStore(); } catch (error) { if (persistent) throw error; this.storageProblem(error); }
1438
1441
  let saved = this.cleanupPending ? null : this.readRoom();
1439
1442
  restoring = Boolean(saved);
1440
1443
  if (this.persistentStored && build.settings.lifetime !== 'persistent') throw new Error('This room holds a lasting world. Keep room.lifetime persistent; its save is retained.');
@@ -1480,7 +1483,9 @@ export class Table {
1480
1483
  this.hostRt = startHost(this.game, {
1481
1484
  ...(saved ? { restore: new TextEncoder().encode((saved.hostText ?? JSON.stringify(saved.host))), restoreEpoch: epoch, startDelayMs: boot.count === 1 ? restoreDelay(`${this.game}/${this.code}`) : 0 } : {}),
1482
1485
  record: (owner,key,value) => this.queueRoundRecord(owner,key,value),
1483
- store: { save: (bytes, commitment) => this.saveRoom(bytes, commitment) },
1486
+ store: { save: (bytes, commitment) => this.saveRoom(bytes, commitment, true) },
1487
+ // A room behind Gates needs no snapshot as text: nothing is encoded that nobody is sent (room.mjs, hostSnap).
1488
+ snapshotText: () => !this.relayed,
1484
1489
  ...(build.settings.characters ? { characterFor: (peer) => {
1485
1490
  const account = room.clients.get(peer.id)?.player;
1486
1491
  return account ? this.characterGrants.get(account) ?? null : null;
@@ -1520,12 +1525,19 @@ export class Table {
1520
1525
  }
1521
1526
  }
1522
1527
 
1523
- saveRoom(bytes, commitment) {
1528
+ /** This object's room store, with the unconfirmed checkpoint it woke to (if any). */
1529
+ openStore(persistent = this.isPersistent()) {
1530
+ const loose = this.looseSave; this.looseSave = null;
1531
+ return roomStore(this.ctx.storage, { persistent, loose, onProblem: (error) => this.storageProblem(error) });
1532
+ }
1533
+
1534
+ /** `periodic`: the rules host's own checkpoint of a tick, as against a save an admission or a pause asked for. */
1535
+ saveRoom(bytes, commitment, periodic = false) {
1524
1536
  if (this.persistentFailed) throw new Error('Lasting world stopped; restart from its committed save');
1525
1537
  const now = Date.now();
1526
1538
  if (now < (this.saveRetryAt ?? 0)) throw this.saveError ?? new Error('room save retry is waiting');
1527
1539
  try {
1528
- this.durable ??= roomStore(this.ctx.storage, { persistent: this.isPersistent() });
1540
+ this.durable ??= this.openStore();
1529
1541
  const build = hostedBuild(this.game);
1530
1542
  const host = new TextDecoder().decode(bytes);
1531
1543
  const net = this.room.saved();
@@ -1544,6 +1556,10 @@ export class Table {
1544
1556
  commitment ??= { force: false, seconds: c.settings.durability.movementSeconds, tickHz: c.settings.tickHz,
1545
1557
  fields: { kinds: c.kinds.map(k => k.fields.flatMap(([,f],i) => f.durable ? [i] : [])), shared: c.shared.flatMap(([,f],i) => f.durable ? [i] : []) } };
1546
1558
  this.durable.commit(saved, commitment);
1559
+ } else if (this.relayed && periodic && !clearing) {
1560
+ // A room behind Gates: its once-a-second checkpoint holds none of its frames (worker/room-store.mjs). A save
1561
+ // an admission asks for is still confirmed before the welcome that names the seat can leave.
1562
+ this.durable.write(saved, { unconfirmed: true });
1547
1563
  } else this.durable.write(saved);
1548
1564
  if (clearing) { this.cleanupGeneration = (this.cleanupGeneration ?? 0) + 1; this.queueNetCleanup(false); }
1549
1565
  this.roomSave = saved;
@@ -1742,7 +1758,7 @@ export class Table {
1742
1758
  const room = this.room;
1743
1759
  if (!room || !this.hostRt) {
1744
1760
  if (this.ctx.storage.sql?.exec("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'save'").toArray().length) {
1745
- this.durable ??= roomStore(this.ctx.storage);
1761
+ this.durable ??= this.openStore(false);
1746
1762
  const saved = this.cleanupPending ? null : this.readRoom(true);
1747
1763
  if (saved) {
1748
1764
  this.game = saved.game; this.code = saved.room;
@@ -2118,7 +2134,7 @@ export class Table {
2118
2134
  // players sent to a Gate in the last ten seconds who have not reached it yet.
2119
2135
  const gates = Number(new URL(request.url).searchParams.get('gates')) || 1, now = Date.now(), promised = new Map();
2120
2136
  for (const [index, due] of this.gatePromised) { const live = due.filter((at) => at > now); if (live.length) { this.gatePromised.set(index, live); promised.set(index, live.length); } else this.gatePromised.delete(index); }
2121
- const gate = nextGate(gates, this.gateHeld, promised);
2137
+ const gate = nextGate(gates, this.gateHeld, promised, gatePlayers(this.env));
2122
2138
  if (!this.gatePromised.has(gate)) this.gatePromised.set(gate, []);
2123
2139
  this.gatePromised.get(gate).push(now + 10_000);
2124
2140
  return json({ gate });
@@ -2398,6 +2414,8 @@ export class Table {
2398
2414
  // A Gate's stamp on a player's message to this object handling it, ms.
2399
2415
  inputTransitMs: { n: transit.length, p50: at(0.5), p95: at(0.95), max: transit.at(-1) ?? null },
2400
2416
  durability: this.storageHealth ?? { ok: true },
2417
+ // Checkpoints written without holding the room's frames for them (worker/room-store.mjs), so far.
2418
+ save: { unconfirmed: this.durable?.unconfirmedWrites ?? 0 },
2401
2419
  });
2402
2420
  const work = diagnosticsOf(this.env, this.game, this.code).fetch('https://diagnostics/__diagnostics', { method: 'POST', body })
2403
2421
  .then((r) => r.body?.cancel?.())
package/worker/office.mjs CHANGED
@@ -62,7 +62,7 @@ import { toolIdentity } from './tool-identity.mjs';
62
62
  * from an office key it is ALWAYS an ask (the AI may only propose one), and so is marking a referrer paid.
63
63
  */
64
64
  import { seatsOf } from './seats.mjs';
65
- import { diagnosticsOf, roomLayout } from './gate.mjs';
65
+ import { diagnosticsOf, gatePlayers, roomLayout } from './gate.mjs';
66
66
  import { PUBLIC_SERVER, SERVER_LIMITS, checkServer, levelName, narrows, policyOf, roomServer, rowFor, serverView, serversOf, serverPassCookie, writeServer } from './servers.mjs';
67
67
  import { BRAIN_BUDGET, aiName, brainFor, fillSpot, passById, passCreate, passList, passRevoke, think } from './agents.mjs';
68
68
  import { DEFAULT_MODEL, NEURONS_PER_M, OWNER_MODEL, vocabularyOf } from './brain.mjs';
@@ -432,7 +432,7 @@ async function roomFacts(env, game, room, max) {
432
432
  * small room answers for itself, as it always has.
433
433
  */
434
434
  export async function roomDiagnostics(env, game, room, max) {
435
- if (!env.GATE || roomLayout(max).gates === 1) return roomFacts(env, game, room, max);
435
+ if (!env.GATE || roomLayout(max, gatePlayers(env)).gates === 1) return roomFacts(env, game, room, max);
436
436
  let kept = null;
437
437
  try {
438
438
  const res = await diagnosticsOf(env, game, room).fetch(`https://diagnostics/__diagnostics?room=${enc(room)}`);
@@ -12,8 +12,42 @@ import { normalizeWorld, denormalizeWorld, durableWorld, ownDurable, checkpointW
12
12
  * Only integer primary keys: no secondary-index write amplification.
13
13
  * The boot row outlives a save being replayed, so two restores of that save can never reuse an epoch.
14
14
  */
15
- export function roomStore(storage, { persistent = false } = {}) {
15
+ export function roomStore(storage, { persistent = false, loose = null, onProblem = () => {} } = {}) {
16
16
  const sql = storage.sql;
17
+ /*
18
+ * THE CHECKPOINT THAT DOES NOT HOLD THE ROOM'S FRAMES. Cloudflare holds everything an object sends after a write
19
+ * until that write is confirmed. A match room behind Gates writes its whole self once a second, and every world
20
+ * and every receipt of the ticks that followed waited on it: one or two worlds a second reached every Gate a
21
+ * tick or more late, at every size. Such a checkpoint is a second old by design, and the worlds between two of
22
+ * them were never durable, so nothing a player sees is being promised. `write(value, { unconfirmed: true })`
23
+ * puts it in the object's key-value storage with `allowUnconfirmed`, which is Cloudflare's own way to say so.
24
+ *
25
+ * Every confirmed write removes it, so one found on disk was written after the last confirmed save, unless the
26
+ * object stopped between a confirmed write and that removal: each save says when it was made (`at`), and a read
27
+ * takes the later of the two. `loose` is what the object read of it when it woke (a Map from
28
+ * `storage.list({ prefix: LOOSE_SAVE })`); reads after that are answered from memory.
29
+ */
30
+ let looseText = null, looseOnDisk = false, looseWrites = 0;
31
+ if (loose && typeof loose.get === 'function' && loose.get(LOOSE_SAVE)) {
32
+ looseOnDisk = true;
33
+ try {
34
+ const meta = loose.get(LOOSE_SAVE), pieces = [];
35
+ for (let i = 0; i < meta.pieces; i += 1) { const piece = loose.get(`${LOOSE_SAVE}/${i}`); if (!(piece instanceof Uint8Array)) throw new Error('unconfirmed save pieces missing'); pieces.push(piece); }
36
+ const bytes = new Uint8Array(pieces.reduce((n, p) => n + p.length, 0));
37
+ let at = 0;
38
+ for (const piece of pieces) { bytes.set(piece, at); at += piece.length; }
39
+ if (!Number.isInteger(meta.pieces) || meta.pieces < 1 || bytes.length !== meta.bytes || bytes.length > 16_000_000) throw new Error('unconfirmed save does not fit its record');
40
+ looseText = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
41
+ JSON.parse(looseText);
42
+ } catch (error) { looseText = null; onProblem(error); }
43
+ }
44
+ /** Removed with whatever confirmed write is being made: no await between them, so they are one transaction. */
45
+ function dropLoose() {
46
+ looseText = null;
47
+ if (!looseOnDisk || typeof storage.delete !== 'function') return;
48
+ looseOnDisk = false;
49
+ Promise.resolve(storage.delete([LOOSE_SAVE, ...Array.from({ length: LOOSE_PIECES }, (_, i) => `${LOOSE_SAVE}/${i}`)])).catch(onProblem);
50
+ }
17
51
  sql.exec('CREATE TABLE IF NOT EXISTS save (id INTEGER PRIMARY KEY, data BLOB NOT NULL)');
18
52
  sql.exec('CREATE TABLE IF NOT EXISTS boots (id INTEGER PRIMARY KEY, data TEXT NOT NULL)');
19
53
  let journalReady = false;
@@ -47,13 +81,22 @@ export function roomStore(storage, { persistent = false } = {}) {
47
81
  const total = sql.exec('SELECT SUM(length(data)) AS bytes FROM save').toArray()[0]?.bytes ?? 0;
48
82
  if (total > maxBytes) throw new Error('save exceeds 16 MB');
49
83
  const rows = sql.exec('SELECT id, data FROM save ORDER BY id').toArray();
50
- if (!rows.length) return null;
51
- if (rows.some((r, i) => r.id !== i) || rows.reduce((n, r) => n + r.data.byteLength, 0) > maxBytes) throw new Error('save pieces missing or save exceeds 16 MB');
52
- const pieces = rows.map((r) => new Uint8Array(r.data));
53
- const bytes = new Uint8Array(pieces.reduce((n, p) => n + p.length, 0));
54
- let at = 0;
55
- for (const p of pieces) { bytes.set(p, at); at += p.length; }
56
- let value = JSON.parse(decoder.decode(bytes));
84
+ if (!rows.length && looseText === null) return null;
85
+ let value = null;
86
+ if (rows.length) {
87
+ if (rows.some((r, i) => r.id !== i) || rows.reduce((n, r) => n + r.data.byteLength, 0) > maxBytes) throw new Error('save pieces missing or save exceeds 16 MB');
88
+ const pieces = rows.map((r) => new Uint8Array(r.data));
89
+ const bytes = new Uint8Array(pieces.reduce((n, p) => n + p.length, 0));
90
+ let at = 0;
91
+ for (const p of pieces) { bytes.set(p, at); at += p.length; }
92
+ value = JSON.parse(decoder.decode(bytes));
93
+ }
94
+ // An unconfirmed checkpoint is the room when it is the later of the two (above).
95
+ if (looseText !== null && !boot?.persistent) {
96
+ const unconfirmed = JSON.parse(looseText);
97
+ if (!value || !(Number(value.at) >= Number(unconfirmed.at))) value = unconfirmed;
98
+ }
99
+ if (!value) return null;
57
100
  if (lasting) journal();
58
101
  sequence = floor;
59
102
  const journals = lasting ? sql.exec('SELECT id, seq, data FROM world_journal WHERE id > 0 AND seq > ? ORDER BY seq', floor).toArray() : [];
@@ -127,10 +170,20 @@ export function roomStore(storage, { persistent = false } = {}) {
127
170
  // Brought up to this tick by what changed in it (each value copied), never by copying the room.
128
171
  durable = applyChanges(durable, moved);
129
172
  },
130
- write(value) {
173
+ /** `unconfirmed`: a match room's periodic checkpoint, which no frame waits on (above). Never a lasting world's. */
174
+ write(value, { unconfirmed = false } = {}) {
131
175
  if (lasting) journal();
132
- const bytes = encoder.encode(JSON.stringify(value));
176
+ const text = JSON.stringify(value);
177
+ const bytes = encoder.encode(text);
133
178
  if (bytes.length > 16_000_000 && !lasting) throw new Error('save exceeds 16 MB');
179
+ if (unconfirmed && !lasting && typeof storage.put === 'function' && bytes.length <= LOOSE_PIECES * pieceBytes) {
180
+ const entries = { [LOOSE_SAVE]: { pieces: Math.max(1, Math.ceil(bytes.length / pieceBytes)), bytes: bytes.length, at: value.at ?? null } };
181
+ for (let i = 0; i < entries[LOOSE_SAVE].pieces; i += 1) entries[`${LOOSE_SAVE}/${i}`] = bytes.slice(i * pieceBytes, (i + 1) * pieceBytes);
182
+ // One put is one transaction: a reader never finds the record of one checkpoint with the pieces of another.
183
+ Promise.resolve(storage.put(entries, { allowUnconfirmed: true })).catch(onProblem);
184
+ looseText = text; looseOnDisk = true; looseWrites += 1;
185
+ return;
186
+ }
134
187
  storage.transactionSync(() => {
135
188
  let id = 0;
136
189
  for (let at = 0; at < bytes.length; at += pieceBytes) sql.exec('INSERT INTO save (id, data) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data', id++, bytes.slice(at, at + pieceBytes));
@@ -138,16 +191,23 @@ export function roomStore(storage, { persistent = false } = {}) {
138
191
  // The checkpoint holds every record so far: raising the floor retires them without touching their rows.
139
192
  if (lasting && sequence !== floor) sql.exec("INSERT INTO world_journal (id, seq, data) VALUES (0, ?, x'') ON CONFLICT(id) DO UPDATE SET seq = excluded.seq", sequence);
140
193
  });
194
+ dropLoose();
141
195
  floor = sequence;
142
196
  if (lasting) settle(value);
143
197
  },
198
+ /** Checkpoints written without holding the room's frames, so far. */
199
+ get unconfirmedWrites() { return looseWrites; },
144
200
  boots() { const value = JSON.parse(sql.exec('SELECT data FROM boots WHERE id = 1').toArray()[0]?.data ?? 'null'); lasting ||= value?.persistent === true; return value; },
145
201
  boot(value) { sql.exec('INSERT INTO boots (id, data) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data', JSON.stringify({ ...value, ...(lasting ? { persistent: true } : {}) })); },
146
202
  retire() { this.boot({ retired: true }); },
147
- clear() { if (lasting) journal(); storage.transactionSync(() => { sql.exec('DELETE FROM save'); sql.exec('DELETE FROM boots'); if (lasting) sql.exec('DELETE FROM world_journal'); }); journalReady = false; base = null; durable = null; checkpoint = null; committedInputs = null; sequence = 0; floor = 0; },
203
+ clear() { dropLoose(); if (lasting) journal(); storage.transactionSync(() => { sql.exec('DELETE FROM save'); sql.exec('DELETE FROM boots'); if (lasting) sql.exec('DELETE FROM world_journal'); }); journalReady = false; base = null; durable = null; checkpoint = null; committedInputs = null; sequence = 0; floor = 0; },
148
204
  };
149
205
  }
150
206
 
207
+ /** Where a match room's unconfirmed checkpoint is kept in the object's key-value storage: a record, and its pieces under it. */
208
+ export const LOOSE_SAVE = 'room-save';
209
+ const LOOSE_PIECES = 16;
210
+
151
211
  /** A repeatable delay spreads rooms that shared a failing isolate over thirty seconds. */
152
212
  export function restoreDelay(id) {
153
213
  let h = 2166136261;
package/worker/room.mjs CHANGED
@@ -148,7 +148,10 @@ export function medianVote(list) {
148
148
  return v.length ? v[Math.floor((v.length - 1) / 2)] : null;
149
149
  }
150
150
  const utf8 = new TextEncoder();
151
- const byteLength = text => utf8.encode(text).length;
151
+ // A message's size in bytes. Text that is all ASCII (nearly every frame a game sends) is as long as it is wide: it is
152
+ // not encoded to be measured, which at ten inputs a second from a thousand players was a twentieth of a room's second.
153
+ const ASCII = /^[\x00-\x7f]*$/;
154
+ const byteLength = text => ASCII.test(text) ? text.length : utf8.encode(text).length;
152
155
  const PALETTE_SIZE = 12;
153
156
  const DEVICE_RANK = { desk: 0, tv: 1, phone: 2 };
154
157
 
@@ -473,6 +476,12 @@ export class NetRoom {
473
476
  const w = (c.rttWindow ??= []);
474
477
  w.push([now, ms]);
475
478
  while (w.length > 256 || (w.length > 1 && w[0][0] < now - 10000)) w.shift();
479
+ // A low percentile of ten seconds does not move in one. For a player behind a Gate (a big room) a full window
480
+ // is read once a second, not once an input: at ten inputs a second from a thousand players, sorting it each time
481
+ // was a tenth of the room's second when its clock moved between frames (0.46.3 against the build before it,
482
+ // test/crowd-room-cost.mjs). A room its players reach directly reads it as before.
483
+ if (c.conn.snapshotGroup && w.length >= 32 && now - (c.rttReadAt ?? 0) < 1000) return;
484
+ c.rttReadAt = now;
476
485
  const sorted = w.map((x) => x[1]).sort((a, b) => a - b);
477
486
  const low = sorted[Math.floor((sorted.length - 1) * 0.1)];
478
487
  if (c.measuredRtt !== undefined && Math.abs(low - c.measuredRtt) < 2 && now - (c.rttToldAt ?? 0) < 1000) return;
@@ -495,7 +504,10 @@ export class NetRoom {
495
504
  while (this.snapTimes.length && this.snapTimes[0] < now - 1000) this.snapTimes.shift();
496
505
  // A heartbeat (revision 9): the host's last state again while its frames hitch. It counts as a snapshot here
497
506
  // (the host is alive), and replicas are told which it is.
498
- const out = text ?? JSON.stringify({ t: 'snap', from: c ? c.seat : null, ...snap, ...(m.hb === 1 ? { hb: 1 } : {}) });
507
+ // The whole snapshot as text: what a player with no view of their own is sent. The server's host hands it over
508
+ // unless this room sends it to nobody (a room behind Gates); then it is made only if somebody turns out to need it.
509
+ let out = text ?? (c ? JSON.stringify({ t: 'snap', from: c.seat, ...snap, ...(m.hb === 1 ? { hb: 1 } : {}) }) : null);
510
+ const whole = () => (out ??= JSON.stringify({ t: 'snap', from: null, ...snap, ...(m.hb === 1 ? { hb: 1 } : {}) }));
499
511
  if (m.hb === 1) this.stats.heartbeats = (this.stats.heartbeats ?? 0) + 1;
500
512
  this.lastSnapText = out;
501
513
  // A server-hosted room behind Gates hands each Gate ONE world a tick; the Gate selects, encodes and paces
@@ -513,9 +525,10 @@ export class NetRoom {
513
525
  }
514
526
  // A congested socket skips a snapshot rather than queueing a stale one.
515
527
  if (o.conn.buffered && o.conn.buffered() > 256 * 1024) { this.stats.drops += 1; continue; }
516
- this.sendText(o.conn, this.hasViewSchedule() ? JSON.stringify({ t: 'snap', from: null, ...this.playerSnapshot(o, snap) }) : out);
528
+ this.sendText(o.conn, this.hasViewSchedule() ? JSON.stringify({ t: 'snap', from: null, ...this.playerSnapshot(o, snap) }) : whole());
517
529
  }
518
530
  if (groups) for (const group of groups) group.snapshot(snap, this.server.viewSettings ?? {}, this.tickHz);
531
+ if (out !== null) this.lastSnapText = out;
519
532
  }
520
533
 
521
534
  hasViewSchedule() {
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.46.5';
2
+ export const STUDIO_VERSION_TAG = '0.46.6';