mcp-tenant-lib 0.3.4 → 0.3.5

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/tenant.d.ts CHANGED
@@ -56,6 +56,16 @@ export declare class Tenant<TSchema, TValues> {
56
56
  * list_fields read should be able to see that they're done.
57
57
  */
58
58
  submitted: boolean;
59
+ /**
60
+ * Timestamp of the most recent real edit to `store` (a field value
61
+ * changing) or a successfully-applied `restoreState` (see below) —
62
+ * distinct from `lastActivityAt`, which also counts reads/pings and
63
+ * drives idle-sweep instead. Used solely to arbitrate `resync` messages
64
+ * (ws.ts) when more than one browser tab reconnects to the same
65
+ * recreated tenant: whichever tab's last edit is newer wins, rather than
66
+ * whichever tab's resync happens to reach the server first.
67
+ */
68
+ lastStateChangeAt: number;
59
69
  wsClients: Set<WebSocket>;
60
70
  connections: Map<string, TenantConnection>;
61
71
  lastActivityAt: number;
@@ -139,6 +149,27 @@ export declare class Tenant<TSchema, TValues> {
139
149
  rejectCall(id: string, error: string): void;
140
150
  touch(): void;
141
151
  applyState(schema: TSchema, values: TValues): void;
152
+ /**
153
+ * Restores schema/values/submitted from a browser page's own live state
154
+ * after this tenant was recreated empty (see the `recreated` init flag in
155
+ * ws.ts) — unlike applyState (used by define_form to redefine a form),
156
+ * this preserves `submitted` as reported by the page rather than always
157
+ * resetting it, since a resync is recovering prior state, not starting a
158
+ * new round.
159
+ *
160
+ * `changedAt` is the pushing page's own last-local-edit timestamp
161
+ * (Date.now() when the user or agent last changed a field in that tab —
162
+ * see mcp-form's client). When two tabs both reconnect to the same
163
+ * recreated tenant, each pushes its own resync independently; without
164
+ * this guard, whichever happens to reach the server first would win,
165
+ * which has nothing to do with which tab actually has the fresher data.
166
+ * Comparing against `lastStateChangeAt` (bumped by every real edit,
167
+ * including a previously-applied resync) means an older resync arriving
168
+ * after a newer one — or after the tenant was already touched some other
169
+ * way, e.g. an agent's set_field — is ignored rather than clobbering it.
170
+ * Returns whether the resync was applied.
171
+ */
172
+ restoreState(schema: TSchema, values: TValues, submitted: boolean, changedAt: number): boolean;
142
173
  broadcastReinit(): void;
143
174
  broadcastUpdate(field: string, value: unknown): void;
144
175
  dispose(): void;
package/dist/tenant.js CHANGED
@@ -59,6 +59,16 @@ export class Tenant {
59
59
  * list_fields read should be able to see that they're done.
60
60
  */
61
61
  submitted = false;
62
+ /**
63
+ * Timestamp of the most recent real edit to `store` (a field value
64
+ * changing) or a successfully-applied `restoreState` (see below) —
65
+ * distinct from `lastActivityAt`, which also counts reads/pings and
66
+ * drives idle-sweep instead. Used solely to arbitrate `resync` messages
67
+ * (ws.ts) when more than one browser tab reconnects to the same
68
+ * recreated tenant: whichever tab's last edit is newer wins, rather than
69
+ * whichever tab's resync happens to reach the server first.
70
+ */
71
+ lastStateChangeAt = 0;
62
72
  wsClients;
63
73
  connections = new Map();
64
74
  #legacyManifest;
@@ -102,7 +112,7 @@ export class Tenant {
102
112
  constructor(id, initialSchema, initialValues) {
103
113
  this.id = id;
104
114
  this.schema = initialSchema;
105
- this.store = new Store(initialValues);
115
+ this.store = new Store(initialValues); // placeholder; #attachStore below wires the real onChange listener
106
116
  this.submitBus = new EventEmitter();
107
117
  this.submitBus.setMaxListeners(0);
108
118
  this.submitBus.on('newListener', (event) => {
@@ -117,7 +127,7 @@ export class Tenant {
117
127
  });
118
128
  this.wsClients = new Set();
119
129
  this.lastActivityAt = Date.now();
120
- this.store.onChange((field, value) => this.broadcastUpdate(field, value));
130
+ this.#attachStore(this.store);
121
131
  }
122
132
  /**
123
133
  * Legacy/no-WS path: registers a manifest with no real connection behind
@@ -292,14 +302,51 @@ export class Tenant {
292
302
  touch() {
293
303
  this.lastActivityAt = Date.now();
294
304
  }
305
+ #attachStore(store) {
306
+ this.store = store;
307
+ this.store.onChange((field, value) => {
308
+ this.lastStateChangeAt = Date.now();
309
+ this.broadcastUpdate(field, value);
310
+ });
311
+ }
295
312
  applyState(schema, values) {
296
313
  this.store.dispose();
297
314
  this.schema = schema;
298
- this.store = new Store(values);
315
+ this.#attachStore(new Store(values));
299
316
  this.submitted = false;
300
- this.store.onChange((field, value) => this.broadcastUpdate(field, value));
301
317
  this.broadcastReinit();
302
318
  }
319
+ /**
320
+ * Restores schema/values/submitted from a browser page's own live state
321
+ * after this tenant was recreated empty (see the `recreated` init flag in
322
+ * ws.ts) — unlike applyState (used by define_form to redefine a form),
323
+ * this preserves `submitted` as reported by the page rather than always
324
+ * resetting it, since a resync is recovering prior state, not starting a
325
+ * new round.
326
+ *
327
+ * `changedAt` is the pushing page's own last-local-edit timestamp
328
+ * (Date.now() when the user or agent last changed a field in that tab —
329
+ * see mcp-form's client). When two tabs both reconnect to the same
330
+ * recreated tenant, each pushes its own resync independently; without
331
+ * this guard, whichever happens to reach the server first would win,
332
+ * which has nothing to do with which tab actually has the fresher data.
333
+ * Comparing against `lastStateChangeAt` (bumped by every real edit,
334
+ * including a previously-applied resync) means an older resync arriving
335
+ * after a newer one — or after the tenant was already touched some other
336
+ * way, e.g. an agent's set_field — is ignored rather than clobbering it.
337
+ * Returns whether the resync was applied.
338
+ */
339
+ restoreState(schema, values, submitted, changedAt) {
340
+ if (changedAt < this.lastStateChangeAt)
341
+ return false;
342
+ this.store.dispose();
343
+ this.schema = schema;
344
+ this.#attachStore(new Store(values));
345
+ this.submitted = submitted;
346
+ this.lastStateChangeAt = changedAt;
347
+ this.broadcastReinit();
348
+ return true;
349
+ }
303
350
  broadcastReinit() {
304
351
  const payload = JSON.stringify({ type: 'reinit', schema: this.schema, state: this.store.snapshot(), waiting: this.waiting, submitted: this.submitted });
305
352
  for (const client of this.wsClients) {
package/dist/types.d.ts CHANGED
@@ -4,7 +4,14 @@ export interface SubmitPayload {
4
4
  [field: string]: unknown;
5
5
  }
6
6
  export type ServerMessage<TSchema = unknown, TValues = unknown> = {
7
- type: 'init' | 'reinit';
7
+ type: 'init';
8
+ schema: TSchema;
9
+ state: TValues;
10
+ waiting: boolean;
11
+ submitted: boolean;
12
+ recreated: boolean;
13
+ } | {
14
+ type: 'reinit';
8
15
  schema: TSchema;
9
16
  state: TValues;
10
17
  waiting: boolean;
@@ -25,6 +32,31 @@ export interface SetMessage {
25
32
  field: string;
26
33
  value: unknown;
27
34
  }
35
+ /**
36
+ * Sent by a page that reconnects to a tenant the server reports as
37
+ * `recreated` (see the `init` message) — i.e. the in-memory tenant was
38
+ * lost, most commonly an MCP server restart, while this page's own JS
39
+ * runtime (and therefore its last-known schema/values) survived because
40
+ * the tab itself never reloaded. Lets the still-live browser page push its
41
+ * state back up as the source of truth instead of accepting the server's
42
+ * freshly-recreated default state. The server applies it via the same
43
+ * path as define_form (Tenant.applyState) and rebroadcasts `reinit` to any
44
+ * other connected tabs.
45
+ */
46
+ export interface ResyncMessage<TSchema = unknown, TValues = unknown> {
47
+ type: 'resync';
48
+ schema: TSchema;
49
+ values: TValues;
50
+ submitted: boolean;
51
+ /**
52
+ * The pushing page's own last-local-edit timestamp (Date.now()) — lets
53
+ * the server arbitrate when two tabs both resync the same recreated
54
+ * tenant, favoring whichever has the more recently edited data rather
55
+ * than whichever resync message happens to arrive first. See
56
+ * Tenant.restoreState in mcp-tenant-lib for the comparison.
57
+ */
58
+ changedAt: number;
59
+ }
28
60
  export interface SubmitMessage {
29
61
  type: 'submit';
30
62
  }
@@ -93,4 +125,4 @@ export interface RenameConnectionMessage {
93
125
  type: 'rename_connection';
94
126
  appLabel: string;
95
127
  }
96
- export type ClientMessage = SetMessage | SubmitMessage | InterruptMessage | RegisterToolsMessage | CallResultMessage | RenameConnectionMessage;
128
+ export type ClientMessage = SetMessage | SubmitMessage | InterruptMessage | RegisterToolsMessage | CallResultMessage | RenameConnectionMessage | ResyncMessage;
package/dist/ws.js CHANGED
@@ -60,7 +60,7 @@ export function attachWebSocketServer(httpServer, port, initialSchema, initialVa
60
60
  if (state)
61
61
  state.isAlive = true;
62
62
  });
63
- ws.send(JSON.stringify({ type: 'init', schema: t.schema, state: t.store.snapshot(), waiting: t.waiting, submitted: t.submitted }));
63
+ ws.send(JSON.stringify({ type: 'init', schema: t.schema, state: t.store.snapshot(), waiting: t.waiting, submitted: t.submitted, recreated }));
64
64
  ws.on('message', (raw) => {
65
65
  t.touch();
66
66
  let msg;
@@ -92,6 +92,12 @@ export function attachWebSocketServer(httpServer, port, initialSchema, initialVa
92
92
  else
93
93
  t.resolveCall(msg.id, msg.result);
94
94
  }
95
+ if (msg.type === 'resync') {
96
+ const applied = t.restoreState(msg.schema, msg.values, msg.submitted, msg.changedAt);
97
+ console.error(applied
98
+ ? `[ws] resync from connection=${connectionId}: restoring tenant "${tenantId}" state pushed back by the browser`
99
+ : `[ws] resync from connection=${connectionId}: ignored — tenant "${tenantId}" already has state at least as recent`);
100
+ }
95
101
  });
96
102
  ws.on('close', (code, reason) => {
97
103
  console.error(`[ws] connection closed: tenant=${tenantId} connection=${connectionId} code=${code} reason=${reason.toString() || '(none)'}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-tenant-lib",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "type": "module",
5
5
  "description": "Generic tenant/session bookkeeping + MCP wiring, reusable across projects.",
6
6
  "repository": {