@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.
- package/LICENSE +201 -0
- package/README.md +430 -0
- package/composables/idbConnection.ts +81 -0
- package/composables/idbHelpers.ts +165 -0
- package/composables/idbSchema.ts +93 -0
- package/composables/syncTypes.ts +145 -0
- package/composables/useCommitLog.ts +521 -0
- package/composables/useCrossTab.ts +246 -0
- package/composables/useModelStore.ts +174 -0
- package/composables/useSyncEngine.ts +494 -0
- package/composables/useTripleStore.ts +135 -0
- package/index.ts +15 -0
- package/nuxt.config.ts +16 -0
- package/package.json +56 -0
|
@@ -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 }
|