@continuum-dev/session 0.1.2 → 0.1.3

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 CHANGED
@@ -92,6 +92,7 @@ function hydrateOrCreate(options?: SessionOptions): Session;
92
92
  Persistence note:
93
93
 
94
94
  - When `options.persistence` is provided, snapshot writes are debounced by 200ms.
95
+ - Pending writes are flushed on `beforeunload` so tab closes do not drop recent updates.
95
96
  - `SessionPersistenceOptions.maxBytes` enforces a payload size cap before writes.
96
97
  - `SessionPersistenceOptions.onError` receives `size_limit` and `storage_error` events.
97
98
  - Browser `storage` events are consumed for cross-tab session synchronization.
@@ -118,6 +119,8 @@ const stopIssues = session.onIssues((issues) => {
118
119
  });
119
120
  ```
120
121
 
122
+ Snapshot listeners receive immutable top-level copies of `view` and `data`.
123
+
121
124
  ### 2) View and State Updates
122
125
 
123
126
  #### `session.pushView(view)`
@@ -148,6 +151,8 @@ session.recordIntent({
148
151
  });
149
152
  ```
150
153
 
154
+ `recordIntent` clones incoming payload objects before storing them and deduplicates issues by `nodeId + code`.
155
+
151
156
  #### Viewport APIs
152
157
 
153
158
  ```typescript
@@ -310,6 +315,7 @@ Persistence behavior:
310
315
 
311
316
  - `serialize()` returns a JSON-safe payload with `formatVersion: 1`.
312
317
  - Automatic persistence writes are debounced (200ms) to reduce storage churn.
318
+ - Pending writes are flushed on `beforeunload` to reduce data loss risk during tab close.
313
319
  - If `maxBytes` is exceeded, the write is skipped and `onError` is invoked.
314
320
  - Remote storage updates can rehydrate in-memory state for cross-tab continuity.
315
321
 
@@ -1 +1 @@
1
- {"version":3,"file":"checkpoint-manager.d.ts","sourceRoot":"","sources":["../../../../../packages/session/src/lib/session/checkpoint-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAOvD;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,CAEtD;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,YAAY,GAAG,IAAI,CAqB3D;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,YAAY,GAAG,UAAU,CAezE;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,YAAY,EAAE,EAAE,EAAE,UAAU,GAAG,IAAI,CAalF;AAED;;;;;GAKG;AACH,wBAAgB,MAAM,CAAC,QAAQ,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI,CAkBzE"}
1
+ {"version":3,"file":"checkpoint-manager.d.ts","sourceRoot":"","sources":["../../../../../packages/session/src/lib/session/checkpoint-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAOvD;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,CAEtD;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,YAAY,GAAG,IAAI,CAqB3D;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,YAAY,GAAG,UAAU,CAyBzE;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,YAAY,EAAE,EAAE,EAAE,UAAU,GAAG,IAAI,CAalF;AAED;;;;;GAKG;AACH,wBAAgB,MAAM,CAAC,QAAQ,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI,CAkBzE"}
@@ -59,6 +59,16 @@ export function createManualCheckpoint(internal) {
59
59
  trigger: 'manual',
60
60
  };
61
61
  internal.checkpoints.push(checkpoint);
62
+ if (internal.checkpoints.length > internal.maxCheckpoints) {
63
+ const overflow = internal.checkpoints.length - internal.maxCheckpoints;
64
+ for (let index = 0; index < overflow; index += 1) {
65
+ const removableIndex = internal.checkpoints.findIndex((cp) => cp.trigger === 'manual');
66
+ if (removableIndex === -1) {
67
+ break;
68
+ }
69
+ internal.checkpoints.splice(removableIndex, 1);
70
+ }
71
+ }
62
72
  return checkpoint;
63
73
  }
64
74
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"event-log.d.ts","sourceRoot":"","sources":["../../../../../packages/session/src/lib/session/event-log.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAY,WAAW,EAA2B,MAAM,qBAAqB,CAAC;AAE1F,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAsDvD;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAC1B,QAAQ,EAAE,YAAY,EACtB,OAAO,EAAE,IAAI,CAAC,WAAW,EAAE,eAAe,GAAG,WAAW,GAAG,WAAW,GAAG,aAAa,CAAC,GACtF,IAAI,CAiFN"}
1
+ {"version":3,"file":"event-log.d.ts","sourceRoot":"","sources":["../../../../../packages/session/src/lib/session/event-log.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAY,WAAW,EAA2B,MAAM,qBAAqB,CAAC;AAE1F,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAoEvD;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAC1B,QAAQ,EAAE,YAAY,EACtB,OAAO,EAAE,IAAI,CAAC,WAAW,EAAE,eAAe,GAAG,WAAW,GAAG,WAAW,GAAG,aAAa,CAAC,GACtF,IAAI,CA+EN"}
@@ -3,6 +3,16 @@ import { generateId } from './session-state.js';
3
3
  import { buildSnapshotFromCurrentState, notifySnapshotAndIssueListeners } from './listeners.js';
4
4
  import { cloneCheckpointSnapshot } from './checkpoint-manager.js';
5
5
  import { validateNodeValue } from '@continuum-dev/runtime';
6
+ function dedupeIssues(existing, incoming) {
7
+ if (incoming.length === 0) {
8
+ return existing;
9
+ }
10
+ const nextKeys = new Set(incoming.map((issue) => `${issue.nodeId ?? ''}:${issue.code}`));
11
+ return [
12
+ ...existing.filter((issue) => !nextKeys.has(`${issue.nodeId ?? ''}:${issue.code}`)),
13
+ ...incoming,
14
+ ];
15
+ }
6
16
  function collectNodesByCanonicalId(nodes) {
7
17
  const byId = new Map();
8
18
  const walk = (items, parentPath) => {
@@ -75,24 +85,22 @@ export function recordIntent(internal, partial) {
75
85
  }
76
86
  const resolvedEntry = resolveNodeLookupEntry(internal.currentView.nodes, partial.nodeId);
77
87
  if (!resolvedEntry) {
78
- internal.issues = [
79
- ...internal.issues,
80
- {
88
+ internal.issues = dedupeIssues(internal.issues, [{
81
89
  severity: ISSUE_SEVERITY.WARNING,
82
90
  nodeId: partial.nodeId,
83
91
  message: `Node ${partial.nodeId} not found in current view`,
84
92
  code: ISSUE_CODES.UNKNOWN_NODE,
85
- },
86
- ];
93
+ }]);
87
94
  notifySnapshotAndIssueListeners(internal);
88
95
  return;
89
96
  }
90
97
  const { canonicalId, node } = resolvedEntry;
98
+ const payload = { ...partial.payload };
91
99
  internal.currentData = {
92
100
  ...internal.currentData,
93
101
  values: {
94
102
  ...internal.currentData.values,
95
- [canonicalId]: partial.payload,
103
+ [canonicalId]: payload,
96
104
  },
97
105
  lineage: {
98
106
  ...internal.currentData.lineage,
@@ -108,9 +116,9 @@ export function recordIntent(internal, partial) {
108
116
  },
109
117
  };
110
118
  if (internal.validateOnUpdate) {
111
- const validationIssues = validateNodeValue(node, partial.payload);
119
+ const validationIssues = validateNodeValue(node, payload);
112
120
  if (validationIssues.length > 0) {
113
- internal.issues = [...internal.issues, ...validationIssues];
121
+ internal.issues = dedupeIssues(internal.issues, validationIssues);
114
122
  }
115
123
  }
116
124
  const lastAutoCheckpoint = [...internal.checkpoints]
@@ -1 +1 @@
1
- {"version":3,"file":"listeners.d.ts","sourceRoot":"","sources":["../../../../../packages/session/src/lib/session/listeners.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC9D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC9D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAAC,QAAQ,EAAE,YAAY,GAAG,kBAAkB,GAAG,IAAI,CAG/F;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,YAAY,GAAG,IAAI,CASpE;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,YAAY,GAAG,IAAI,CAQjE;AAED;;;;GAIG;AACH,wBAAgB,+BAA+B,CAAC,QAAQ,EAAE,YAAY,GAAG,IAAI,CAG5E;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,YAAY,EACtB,QAAQ,EAAE,CAAC,QAAQ,EAAE,kBAAkB,GAAG,IAAI,KAAK,IAAI,GACtD,MAAM,IAAI,CAGZ;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,YAAY,EACtB,QAAQ,EAAE,CAAC,MAAM,EAAE,mBAAmB,EAAE,KAAK,IAAI,GAChD,MAAM,IAAI,CAGZ"}
1
+ {"version":3,"file":"listeners.d.ts","sourceRoot":"","sources":["../../../../../packages/session/src/lib/session/listeners.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC9D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC9D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAAC,QAAQ,EAAE,YAAY,GAAG,kBAAkB,GAAG,IAAI,CAS/F;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,YAAY,GAAG,IAAI,CASpE;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,YAAY,GAAG,IAAI,CAQjE;AAED;;;;GAIG;AACH,wBAAgB,+BAA+B,CAAC,QAAQ,EAAE,YAAY,GAAG,IAAI,CAG5E;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,YAAY,EACtB,QAAQ,EAAE,CAAC,QAAQ,EAAE,kBAAkB,GAAG,IAAI,KAAK,IAAI,GACtD,MAAM,IAAI,CAGZ;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,YAAY,EACtB,QAAQ,EAAE,CAAC,MAAM,EAAE,mBAAmB,EAAE,KAAK,IAAI,GAChD,MAAM,IAAI,CAGZ"}
@@ -7,7 +7,13 @@
7
7
  export function buildSnapshotFromCurrentState(internal) {
8
8
  if (!internal.currentView || !internal.currentData)
9
9
  return null;
10
- return { view: internal.currentView, data: internal.currentData };
10
+ const snapshot = {
11
+ view: { ...internal.currentView },
12
+ data: { ...internal.currentData },
13
+ };
14
+ Object.freeze(snapshot.view);
15
+ Object.freeze(snapshot.data);
16
+ return Object.freeze(snapshot);
11
17
  }
12
18
  /**
13
19
  * Notifies all snapshot listeners with the latest snapshot value.
@@ -1 +1 @@
1
- {"version":3,"file":"persistence.d.ts","sourceRoot":"","sources":["../../../../../packages/session/src/lib/session/persistence.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAIvD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AAkB7D;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,YAAY,EACtB,OAAO,EAAE,yBAAyB,GACjC,MAAM,IAAI,CAuEZ"}
1
+ {"version":3,"file":"persistence.d.ts","sourceRoot":"","sources":["../../../../../packages/session/src/lib/session/persistence.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAIvD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AAkB7D;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,YAAY,EACtB,OAAO,EAAE,yBAAyB,GACjC,MAAM,IAAI,CAmFZ"}
@@ -31,36 +31,43 @@ export function attachPersistence(internal, options) {
31
31
  let timeout;
32
32
  let isApplyingRemote = false;
33
33
  const encoder = new TextEncoder();
34
+ const flushNow = () => {
35
+ if (timeout) {
36
+ clearTimeout(timeout);
37
+ timeout = undefined;
38
+ }
39
+ try {
40
+ const payload = JSON.stringify(serializeSession(internal));
41
+ const attemptedBytes = encoder.encode(payload).byteLength;
42
+ if (typeof options.maxBytes === 'number'
43
+ && Number.isFinite(options.maxBytes)
44
+ && options.maxBytes >= 0
45
+ && attemptedBytes > options.maxBytes) {
46
+ options.onError?.({
47
+ reason: 'size_limit',
48
+ key,
49
+ attemptedBytes,
50
+ maxBytes: options.maxBytes,
51
+ });
52
+ return;
53
+ }
54
+ storage.setItem(key, payload);
55
+ }
56
+ catch (cause) {
57
+ options.onError?.({
58
+ reason: 'storage_error',
59
+ key,
60
+ cause,
61
+ });
62
+ }
63
+ };
34
64
  const unsubscribe = subscribeSnapshot(internal, () => {
35
65
  if (isApplyingRemote)
36
66
  return;
37
67
  if (timeout)
38
68
  clearTimeout(timeout);
39
69
  timeout = setTimeout(() => {
40
- try {
41
- const payload = JSON.stringify(serializeSession(internal));
42
- const attemptedBytes = encoder.encode(payload).byteLength;
43
- if (typeof options.maxBytes === 'number'
44
- && Number.isFinite(options.maxBytes)
45
- && options.maxBytes >= 0
46
- && attemptedBytes > options.maxBytes) {
47
- options.onError?.({
48
- reason: 'size_limit',
49
- key,
50
- attemptedBytes,
51
- maxBytes: options.maxBytes,
52
- });
53
- return;
54
- }
55
- storage.setItem(key, payload);
56
- }
57
- catch (cause) {
58
- options.onError?.({
59
- reason: 'storage_error',
60
- key,
61
- cause,
62
- });
63
- }
70
+ flushNow();
64
71
  }, 200);
65
72
  });
66
73
  const onStorage = (event) => {
@@ -75,26 +82,29 @@ export function attachPersistence(internal, options) {
75
82
  });
76
83
  isApplyingRemote = true;
77
84
  replaceInternalState(internal, next);
85
+ isApplyingRemote = false;
78
86
  notifySnapshotAndIssueListeners(internal);
79
87
  }
80
88
  catch {
81
- return;
82
- }
83
- finally {
84
89
  isApplyingRemote = false;
90
+ return;
85
91
  }
86
92
  };
87
93
  const maybeAdd = globalThis.addEventListener;
88
94
  const maybeRemove = globalThis.removeEventListener;
89
95
  if (maybeAdd) {
90
96
  maybeAdd('storage', onStorage);
97
+ maybeAdd('beforeunload', flushNow);
91
98
  }
92
99
  return () => {
93
- if (timeout)
100
+ if (timeout) {
94
101
  clearTimeout(timeout);
102
+ timeout = undefined;
103
+ }
95
104
  unsubscribe();
96
105
  if (maybeRemove) {
97
106
  maybeRemove('storage', onStorage);
107
+ maybeRemove('beforeunload', flushNow);
98
108
  }
99
109
  };
100
110
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@continuum-dev/session",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -40,7 +40,7 @@
40
40
  "LICENSE*"
41
41
  ],
42
42
  "dependencies": {
43
- "@continuum-dev/contract": "^0.1.2",
44
- "@continuum-dev/runtime": "^0.1.2"
43
+ "@continuum-dev/contract": "^0.1.3",
44
+ "@continuum-dev/runtime": "^0.1.3"
45
45
  }
46
46
  }