@businessmaps/metaontology-nuxt 0.63.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.
@@ -0,0 +1,246 @@
1
+ import { ref, readonly } from 'vue'
2
+ import { nanoid } from 'nanoid'
3
+ import type { Commit } from '@businessmaps/metaontology/types/commits'
4
+ import { useCommitLog } from './useCommitLog'
5
+ import { useSyncEngine } from './useSyncEngine'
6
+
7
+ // ── Per-tab identity ────────────────────────────────────────────────────────
8
+ //
9
+ // `tabId` is unique per JavaScript context - i.e., per browser tab. It is
10
+ // generated once when this module is first imported and never changes for the
11
+ // life of the tab. This is the loop-prevention discriminator on cross-tab
12
+ // broadcast envelopes.
13
+ //
14
+ // Important: this is NOT the same as `useCommitLog().getDeviceId()`. `deviceId`
15
+ // is per-DEVICE (read from IDB config) and is shared across tabs of the same
16
+ // browser. Sync uses `deviceId` for cloud-sync echo detection, where peers are
17
+ // distinct devices. Cross-tab sees both tabs as the same device, so it needs
18
+ // its own per-tab identifier.
19
+ //
20
+
21
+ const tabId = nanoid()
22
+
23
+ // ── Module-level singleton ──────────────────────────────────────────────────
24
+ //
25
+ // One `useCrossTab` per tab. The composable owns at most one `BroadcastChannel`
26
+ // at a time - opened in `activate(mapId, host)`, closed in `deactivate()`.
27
+ // Switching maps deactivates the previous channel and opens a new one.
28
+
29
+ const enabled = ref(false)
30
+ const activeMapId = ref<string | null>(null)
31
+
32
+ let channel: BroadcastChannel | null = null
33
+ let activeHost: CrossTabHost | null = null
34
+ let unbindAppendListener: (() => void) | null = null
35
+
36
+ /**
37
+ * IDs of commits this tab received from a sibling tab via the broadcast
38
+ * channel. The sync engine consults this set when deciding whether to push a
39
+ * commit to remote: if a commit was received cross-tab, the originating tab
40
+ * is responsible for pushing it, and this tab must NOT re-push.
41
+ *
42
+ * The set grows for the life of the activated channel and is cleared on
43
+ * deactivate. In normal operation it stays small (commits flow through and
44
+ * out as the originating tab pushes them); growth is bounded by the
45
+ * checkpoint interval - old commits become checkpoint state and are no
46
+ * longer push candidates.
47
+ */
48
+ const crossTabReceivedIds = new Set<string>()
49
+
50
+ // ── Host: app-side coupling via dependency injection ───────────────────────
51
+ //
52
+ // Mirrors the `SyncHost` pattern from `useSyncEngine.ts`. The cross-tab
53
+ // composable cannot import from the consuming app's code. The consuming app's
54
+ // store implements this interface and passes an instance to
55
+ // `activate(mapId, host)`. When a commit arrives from another tab, the
56
+ // composable calls `host.applyRemoteCommit(commit)` - the same path the
57
+ // consuming app uses for WebRTC peer commits.
58
+
59
+ export interface CrossTabHost {
60
+ /**
61
+ * Apply a commit received from another tab on the same device.
62
+ *
63
+ * The host MUST:
64
+ * - Apply the command to the model
65
+ * - Append the commit to `commitLog.commits.value` (via the existing
66
+ * `replayRemoteCommit` path)
67
+ * - NOT call `appendCommit` (which would re-broadcast and re-push)
68
+ * - NOT add to the local undo/redo stack
69
+ */
70
+ applyRemoteCommit(commit: Commit): void
71
+ }
72
+
73
+ // ── Wire envelope ──────────────────────────────────────────────────────────
74
+
75
+ interface CrossTabMessage {
76
+ type: 'commit'
77
+ /** The originating tab's `tabId`. Used by receivers to skip echoes. */
78
+ fromTabId: string
79
+ commit: Commit
80
+ }
81
+
82
+ // ── Composable ─────────────────────────────────────────────────────────────
83
+
84
+ export function useCrossTab() {
85
+ return {
86
+ enabled: readonly(enabled),
87
+ activeMapId: readonly(activeMapId),
88
+ /** The per-tab identifier. Stable for the life of this tab. */
89
+ getTabId,
90
+ activate,
91
+ deactivate,
92
+ /**
93
+ * True if the named commit was received via cross-tab broadcast (i.e., it
94
+ * originated on a sibling tab on this device). The sync engine uses this
95
+ * to skip pushing commits that another tab will push.
96
+ */
97
+ wasReceivedFromAnotherTab,
98
+ }
99
+ }
100
+
101
+ function getTabId(): string {
102
+ return tabId
103
+ }
104
+
105
+ function wasReceivedFromAnotherTab(commitId: string): boolean {
106
+ return crossTabReceivedIds.has(commitId)
107
+ }
108
+
109
+ /**
110
+ * Activate cross-tab broadcast for a map.
111
+ *
112
+ * Opens a `BroadcastChannel` named `bm-crosstab-${mapId}`, registers a hook
113
+ * on `useCommitLog` so newly-appended local commits are broadcast, and
114
+ * installs a push filter on `useSyncEngine` so commits received from sibling
115
+ * tabs are not pushed twice.
116
+ *
117
+ * Calling `activate()` while already active for a different mapId
118
+ * deactivates the previous channel first.
119
+ */
120
+ function activate(mapId: string, host: CrossTabHost): void {
121
+ if (enabled.value && activeMapId.value === mapId) return
122
+ if (enabled.value) deactivate()
123
+
124
+ // BroadcastChannel may not exist in non-browser test runners. Treat its
125
+ // absence as "cross-tab disabled" rather than throwing - the tab still
126
+ // works in single-tab mode.
127
+ if (typeof BroadcastChannel === 'undefined') {
128
+ console.warn('[useCrossTab] BroadcastChannel not available - cross-tab disabled')
129
+ return
130
+ }
131
+
132
+ channel = new BroadcastChannel(`bm-crosstab-${mapId}`)
133
+ activeHost = host
134
+ activeMapId.value = mapId
135
+ enabled.value = true
136
+ crossTabReceivedIds.clear()
137
+
138
+ channel.addEventListener('message', handleMessage)
139
+
140
+ // Subscribe to local commit appends - broadcast each one.
141
+ const commitLog = useCommitLog()
142
+ unbindAppendListener = commitLog.onAppend((commit) => {
143
+ publishCommit(commit)
144
+ })
145
+
146
+ // Install the sync engine push filter so commits received cross-tab are
147
+ // not re-pushed by this tab.
148
+ const sync = useSyncEngine()
149
+ sync.setPushFilter(commit => crossTabReceivedIds.has(commit.id))
150
+ }
151
+
152
+ /** Tear down the channel and unregister hooks. */
153
+ function deactivate(): void {
154
+ if (!enabled.value) return
155
+
156
+ if (unbindAppendListener) {
157
+ unbindAppendListener()
158
+ unbindAppendListener = null
159
+ }
160
+
161
+ // Clear the sync engine push filter.
162
+ const sync = useSyncEngine()
163
+ sync.setPushFilter(null)
164
+
165
+ if (channel) {
166
+ channel.removeEventListener('message', handleMessage)
167
+ channel.close()
168
+ channel = null
169
+ }
170
+
171
+ activeHost = null
172
+ activeMapId.value = null
173
+ enabled.value = false
174
+ crossTabReceivedIds.clear()
175
+ }
176
+
177
+ /** Broadcast a locally-appended commit to sibling tabs. */
178
+ function publishCommit(commit: Commit): void {
179
+ if (!channel) return
180
+ // Only broadcast commits whose mapId matches the active channel. The
181
+ // commit log can be reused across maps; the channel is per-map.
182
+ if (commit.mapId !== activeMapId.value) return
183
+
184
+ const message: CrossTabMessage = {
185
+ type: 'commit',
186
+ fromTabId: tabId,
187
+ commit,
188
+ }
189
+ try {
190
+ channel.postMessage(message)
191
+ } catch (e) {
192
+ console.warn('[useCrossTab] postMessage failed:', e)
193
+ }
194
+ }
195
+
196
+ function handleMessage(event: MessageEvent<CrossTabMessage>): void {
197
+ const msg = event.data
198
+ if (!msg || msg.type !== 'commit') return
199
+
200
+ // Echo prevention: skip our own broadcasts.
201
+ if (msg.fromTabId === tabId) return
202
+
203
+ // Map mismatch: BroadcastChannel scoping should prevent this, but defend
204
+ // against the channel being shared by mistake.
205
+ if (msg.commit.mapId !== activeMapId.value) return
206
+
207
+ // Idempotent receive: if this tab has already seen this commit id, drop
208
+ // the duplicate. BroadcastChannel ordinarily delivers each message once,
209
+ // but redelivery can happen during channel re-attachment, multi-window
210
+ // edge cases, or test fixtures that post directly.
211
+ if (crossTabReceivedIds.has(msg.commit.id)) return
212
+
213
+ // Mark this commit as "received from another tab" before delivering it to
214
+ // the host. The host will append it to `commitLog.commits.value`; the sync
215
+ // engine will then consult `crossTabReceivedIds` and skip pushing it.
216
+ crossTabReceivedIds.add(msg.commit.id)
217
+
218
+ if (activeHost) {
219
+ try {
220
+ activeHost.applyRemoteCommit(msg.commit)
221
+ } catch (e) {
222
+ console.error('[useCrossTab] host.applyRemoteCommit failed:', e)
223
+ // The commit landed in `crossTabReceivedIds` regardless. The sync
224
+ // engine will still skip it. The local state is just out of sync -
225
+ // the next pull from cloud (if any) will reconcile.
226
+ }
227
+ }
228
+ }
229
+
230
+ // ── Test-only: reset singleton state ───────────────────────────────────────
231
+
232
+ export function resetCrossTabSingleton(): void {
233
+ if (channel) {
234
+ channel.removeEventListener('message', handleMessage)
235
+ channel.close()
236
+ channel = null
237
+ }
238
+ if (unbindAppendListener) {
239
+ unbindAppendListener()
240
+ unbindAppendListener = null
241
+ }
242
+ activeHost = null
243
+ activeMapId.value = null
244
+ enabled.value = false
245
+ crossTabReceivedIds.clear()
246
+ }
@@ -0,0 +1,174 @@
1
+ import { ref, computed, type Ref } from 'vue'
2
+ import { useCommitLog } from './useCommitLog'
3
+ import { getDb } from './idbConnection'
4
+ import type { RootContext } from '@businessmaps/metaontology/types/context'
5
+ import type { Commit, Checkpoint } from '@businessmaps/metaontology/types/commits'
6
+
7
+ // ── useModelStore ────────────────────────────────────────────────────────
8
+ //
9
+ // The layer's model persistence surface. Wraps `useCommitLog` with
10
+ // load / save / list / delete operations. Intended for headless
11
+ // consumers (scripts, migrations, generated businesses). The consuming
12
+ // app typically wraps this with its own store surface for domain-specific
13
+ // operations.
14
+
15
+ let _singleton: ReturnType<typeof createModelStore> | null = null
16
+
17
+ /**
18
+ * Singleton accessor. Returns the same store instance across calls so
19
+ * consumers in the same Vue app share state.
20
+ */
21
+ export function useModelStore() {
22
+ if (!_singleton) _singleton = createModelStore()
23
+ return _singleton
24
+ }
25
+
26
+ /** Test-only: reset the singleton between test runs. */
27
+ export function resetModelStoreSingleton(): void {
28
+ _singleton = null
29
+ }
30
+
31
+ function createModelStore() {
32
+ const commitLog = useCommitLog()
33
+
34
+ // Reactive view of the current map's model. Set on load/init,
35
+ // mutated implicitly by commits dispatched through the engine.
36
+ const root = ref<RootContext | null>(null) as Ref<RootContext | null>
37
+ const loading = ref(false)
38
+ const error = ref<string | null>(null)
39
+
40
+ const isLoaded = computed(() => root.value !== null)
41
+ const currentMapId = computed(() => commitLog.mapId.value)
42
+ const currentBranchId = computed(() => commitLog.activeBranchId.value)
43
+
44
+ /**
45
+ * Load a map's model from storage. Replays commits since the latest
46
+ * checkpoint, runs migration on the loaded checkpoint (model-only -
47
+ * layout is discarded if present).
48
+ */
49
+ async function loadModel(mapId: string, branchId: string = 'main'): Promise<RootContext | null> {
50
+ loading.value = true
51
+ error.value = null
52
+ try {
53
+ const result = await commitLog.loadFromStorage(mapId, branchId)
54
+ if (!result) {
55
+ root.value = null
56
+ return null
57
+ }
58
+ root.value = result.model
59
+ return result.model
60
+ } catch (e) {
61
+ error.value = e instanceof Error ? e.message : String(e)
62
+ throw e
63
+ } finally {
64
+ loading.value = false
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Initialize a new model with a genesis checkpoint. The map id should
70
+ * already match the model's `id`.
71
+ */
72
+ async function saveModel(model: RootContext): Promise<void> {
73
+ await commitLog.initFromSnapshot(model.uri, model)
74
+ root.value = model
75
+ }
76
+
77
+ /**
78
+ * List the map ids that have any persisted state in IDB. Drains the
79
+ * `heads` store, which holds one row per (map, branch) pair.
80
+ */
81
+ async function listMaps(): Promise<string[]> {
82
+ const db = await getDb()
83
+ const allHeads = await db.getAll('heads')
84
+ const ids = new Set<string>()
85
+ for (const head of allHeads) {
86
+ ids.add(head.mapId)
87
+ }
88
+ return Array.from(ids)
89
+ }
90
+
91
+ /**
92
+ * Delete a map's commits, checkpoints, and branch heads from IDB.
93
+ * Does not touch legacy `documents` / `branches` stores - the consuming
94
+ * app is responsible for cleaning those up alongside this call.
95
+ */
96
+ async function deleteMap(mapId: string): Promise<void> {
97
+ const db = await getDb()
98
+
99
+ // Delete all commits for this map across all branches
100
+ const commitsTx = db.transaction('commits', 'readwrite')
101
+ const commitsIndex = commitsTx.store.index('by-map-branch-seq')
102
+ const commitsRange = IDBKeyRange.bound(
103
+ [mapId, '', 0],
104
+ [mapId, '\uffff', Number.MAX_SAFE_INTEGER],
105
+ )
106
+ let cursor = await commitsIndex.openCursor(commitsRange)
107
+ while (cursor) {
108
+ await cursor.delete()
109
+ cursor = await cursor.continue()
110
+ }
111
+ await commitsTx.done
112
+
113
+ // Delete all checkpoints for this map across all branches
114
+ const cpTx = db.transaction('checkpoints', 'readwrite')
115
+ const cpIndex = cpTx.store.index('by-map-branch-seq')
116
+ const cpRange = IDBKeyRange.bound(
117
+ [mapId, '', 0],
118
+ [mapId, '\uffff', Number.MAX_SAFE_INTEGER],
119
+ )
120
+ let cpCursor = await cpIndex.openCursor(cpRange)
121
+ while (cpCursor) {
122
+ await cpCursor.delete()
123
+ cpCursor = await cpCursor.continue()
124
+ }
125
+ await cpTx.done
126
+
127
+ // Delete all branch heads for this map
128
+ const headsTx = db.transaction('heads', 'readwrite')
129
+ const allHeads = await headsTx.store.getAll()
130
+ for (const head of allHeads) {
131
+ if (head.mapId === mapId) {
132
+ await headsTx.store.delete([head.mapId, head.branchId])
133
+ }
134
+ }
135
+ await headsTx.done
136
+
137
+ // If we just deleted the currently-loaded map, clear local state
138
+ if (commitLog.mapId.value === mapId) {
139
+ commitLog.reset()
140
+ root.value = null
141
+ }
142
+ }
143
+
144
+ /** Test-only: reset all in-memory state. */
145
+ function reset(): void {
146
+ commitLog.reset()
147
+ root.value = null
148
+ loading.value = false
149
+ error.value = null
150
+ }
151
+
152
+ return {
153
+ // Reactive state
154
+ root,
155
+ loading,
156
+ error,
157
+ isLoaded,
158
+ currentMapId,
159
+ currentBranchId,
160
+
161
+ // Operations
162
+ loadModel,
163
+ saveModel,
164
+ listMaps,
165
+ deleteMap,
166
+ reset,
167
+
168
+ // Underlying commit log (for advanced consumers - sync, branches, etc.)
169
+ commitLog,
170
+ }
171
+ }
172
+
173
+ // Re-export types for consumers
174
+ export type { RootContext, Commit, Checkpoint }