@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,521 @@
1
+ import { ref, computed, toRaw } from 'vue'
2
+ import { nanoid } from 'nanoid'
3
+ import { useDebounceFn } from '@vueuse/core'
4
+ import { applyCommand, applyBatch } from '@businessmaps/metaontology/engine/apply'
5
+ import { applyM0Command } from '@businessmaps/metaontology/engine/applyM0'
6
+ import { migrateModel } from '@businessmaps/metaontology/migrations'
7
+ import type { RootContext } from '@businessmaps/metaontology/types/context'
8
+ import type { DispatchableCommand, Command, M0Command, BatchCommand } from '@businessmaps/metaontology/types/commands'
9
+ import { isM0Command } from '@businessmaps/metaontology/types/commands'
10
+ import { createEmptyM0State } from '@businessmaps/metaontology/types/m0'
11
+ import type { M0State } from '@businessmaps/metaontology/types/m0'
12
+ import type { Commit, Checkpoint, UndoEntry } from '@businessmaps/metaontology/types/commits'
13
+ import type { IDBCommitRecord, IDBCheckpointRecord } from './idbSchema'
14
+ import {
15
+ saveCommits,
16
+ loadCommitsSince,
17
+ saveCheckpoint as idbSaveCheckpoint,
18
+ loadLatestCheckpoint,
19
+ pruneOldCheckpoints,
20
+ saveBranchHead,
21
+ } from './idbHelpers'
22
+
23
+ const CHECKPOINT_INTERVAL = 100
24
+ const MAX_UNDO = 50
25
+
26
+ // Hard cap on how many commits may sit in the pending-flush buffer before we
27
+ // bypass the debounce and write immediately. The original debounce model
28
+ // waited 800ms from the last append - fine for a human typing one command at
29
+ // a time, catastrophic when the AI rapid-fires 200 tool calls in 10 seconds
30
+ // because the debouncer never fires (each append resets it). With 25 commits
31
+ // in the buffer we force a write so nothing builds up beyond a single IDB
32
+ // transaction's worth, and a page unload loses at most 24 commits instead of
33
+ // the whole run.
34
+ const PENDING_FLUSH_CAP = 25
35
+
36
+ // ── Module-level singleton ────────────────────────────────────────────────
37
+ //
38
+ // `useCommitLog` returns a shared singleton per JavaScript context (per browser
39
+ // tab). Multiple call sites (store, sync engine, collab wiring, UI components)
40
+ // all see the same state. Cross-tab isolation comes from the JavaScript module
41
+ // being loaded separately in each tab, not from per-call factoring.
42
+ //
43
+ // This is a singleton because the per-call factory pattern left sync, echo
44
+ // detection, and other cross-composable consumers reading from blank state.
45
+
46
+ let _singleton: ReturnType<typeof createCommitLog> | null = null
47
+
48
+ export function useCommitLog() {
49
+ if (!_singleton) _singleton = createCommitLog()
50
+ return _singleton
51
+ }
52
+
53
+ /** Test-only: reset the singleton between test runs. */
54
+ export function resetCommitLogSingleton(): void {
55
+ _singleton = null
56
+ }
57
+
58
+ function createCommitLog() {
59
+ // ── In-memory commit state ────────────────────────────────────────────
60
+ const mapId = ref<string>('')
61
+ const activeBranchId = ref<string>('main')
62
+ const commits = ref<Commit[]>([])
63
+ const latestCheckpoint = ref<Checkpoint | null>(null)
64
+ const nextSequence = ref(0)
65
+ const headCommitId = ref<string | null>(null)
66
+ const active = ref(false)
67
+
68
+ // Commits that haven't been flushed to IDB yet
69
+ const pendingCommits = ref<Commit[]>([])
70
+
71
+ // ── Undo/redo session state ───────────────────────────────────────────
72
+ const undoStack = ref<UndoEntry[]>([])
73
+ const redoStack = ref<UndoEntry[]>([])
74
+
75
+ const canUndo = computed(() => undoStack.value.length > 0)
76
+ const canRedo = computed(() => redoStack.value.length > 0)
77
+
78
+ // ── Device ID (lazy - filled by the store on init) ────────────────────
79
+ let deviceId = 'local'
80
+
81
+ function setDeviceId(id: string) {
82
+ deviceId = id
83
+ }
84
+
85
+ function getDeviceId(): string {
86
+ return deviceId
87
+ }
88
+
89
+ // ── Append listeners ──────────────────────────────────────────────────
90
+ //
91
+ // Sibling composables (cross-tab broadcast, awareness, telemetry) can
92
+ // subscribe to the moment a commit is appended. The listener fires
93
+ // synchronously after the commit lands in `commits.value` and before
94
+ // the debounced flush schedules. Listener errors are caught so a
95
+ // misbehaving subscriber cannot break the dispatch path.
96
+ //
97
+ // (Added for cross-tab broadcast support via `useCrossTab`.)
98
+ type AppendListener = (commit: Commit) => void
99
+ const appendListeners = new Set<AppendListener>()
100
+
101
+ function onAppend(listener: AppendListener): () => void {
102
+ appendListeners.add(listener)
103
+ return () => appendListeners.delete(listener)
104
+ }
105
+
106
+ // ── Core operations ───────────────────────────────────────────────────
107
+
108
+ function appendCommit(
109
+ command: DispatchableCommand,
110
+ inverse: DispatchableCommand,
111
+ ): Commit {
112
+ const commit: Commit = {
113
+ id: nanoid(),
114
+ mapId: mapId.value,
115
+ sequence: nextSequence.value++,
116
+ command,
117
+ inverse,
118
+ timestamp: new Date().toISOString(),
119
+ deviceId,
120
+ branchId: activeBranchId.value,
121
+ parentId: headCommitId.value,
122
+ }
123
+
124
+ commits.value.push(commit)
125
+ pendingCommits.value.push(commit)
126
+ headCommitId.value = commit.id
127
+
128
+ // Track for session undo (clear redo on new commit)
129
+ undoStack.value.push({
130
+ commitId: commit.id,
131
+ originalCommand: command,
132
+ inverseCommand: inverse,
133
+ })
134
+ if (undoStack.value.length > MAX_UNDO) undoStack.value.shift()
135
+ redoStack.value = []
136
+
137
+ // Maybe checkpoint
138
+ if (nextSequence.value % CHECKPOINT_INTERVAL === 0) {
139
+ scheduleCheckpoint()
140
+ }
141
+
142
+ // Notify subscribers (cross-tab broadcast, etc.). Catch listener errors
143
+ // so a buggy subscriber cannot break dispatch.
144
+ for (const listener of appendListeners) {
145
+ try {
146
+ listener(commit)
147
+ } catch (e) {
148
+ console.warn('[useCommitLog] append listener failed:', e)
149
+ }
150
+ }
151
+
152
+ // If the pending buffer has grown past the cap, bypass the debounce
153
+ // and write immediately. This prevents debounce starvation during rapid
154
+ // AI dispatches (see PENDING_FLUSH_CAP). Fire-and-forget: we do not
155
+ // await here because `appendCommit` is synchronous for its callers.
156
+ if (pendingCommits.value.length >= PENDING_FLUSH_CAP) {
157
+ void flushPendingNow().catch((e) => {
158
+ console.warn('[useCommitLog] cap-triggered flush failed:', e)
159
+ })
160
+ } else {
161
+ scheduleFlush()
162
+ }
163
+ return commit
164
+ }
165
+
166
+ /** Returns the inverse command to dispatch for undo, or null if nothing to undo. */
167
+ function popUndo(): UndoEntry | null {
168
+ const entry = undoStack.value.pop()
169
+ if (!entry) return null
170
+ redoStack.value.push(entry)
171
+ return entry
172
+ }
173
+
174
+ /** Returns the original command to re-dispatch for redo, or null if nothing to redo. */
175
+ function popRedo(): UndoEntry | null {
176
+ const entry = redoStack.value.pop()
177
+ if (!entry) return null
178
+ // Don't push back to undoStack here - the store will call appendCommit
179
+ // for the redo dispatch, which pushes a new entry to undoStack
180
+ return entry
181
+ }
182
+
183
+ // ── Replay: derive state from checkpoint + commits ────────────────────
184
+
185
+ /**
186
+ * Pure replay: project commands onto the checkpoint's state.
187
+ *
188
+ * Pure domain replay - no layout routing. Every command is applied
189
+ * via `applyCommand` or `applyBatch`. Returns `failures`: the number
190
+ * of commits whose apply step failed validation. Loaders use this to
191
+ * detect pre-existing broken commit logs and trigger a self-heal
192
+ * checkpoint - see `loadFromStorage` for the wiring.
193
+ */
194
+ function replayCommits(
195
+ checkpoint: Checkpoint,
196
+ replayable: Commit[],
197
+ commandFilter?: (cmd: DispatchableCommand) => boolean,
198
+ ): { model: RootContext; m0: M0State; failures: number } {
199
+ let model = checkpoint.model
200
+ let m0 = checkpoint.m0 ?? createEmptyM0State()
201
+ let failures = 0
202
+
203
+ for (const commit of replayable) {
204
+ const cmd = commit.command
205
+ if (commandFilter && !commandFilter(cmd)) continue
206
+
207
+ if (cmd.type === 'batch') {
208
+ const subCommands = commandFilter
209
+ ? cmd.payload.commands.filter(commandFilter)
210
+ : cmd.payload.commands
211
+ // Split M0 and M1 commands in the batch
212
+ const m1Commands = subCommands.filter(c => !isM0Command(c))
213
+ const m0Commands = subCommands.filter(c => isM0Command(c)) as M0Command[]
214
+ // Apply M1 batch first (model may update for cross-tier validation)
215
+ if (m1Commands.length > 0) {
216
+ const batch: BatchCommand = { type: 'batch', payload: { commands: m1Commands, label: cmd.payload.label } }
217
+ const result = applyBatch(model, batch)
218
+ if (result.success) {
219
+ model = result.state
220
+ } else {
221
+ failures++
222
+ console.warn(
223
+ `[replayCommits] batch commit ${commit.id} (seq ${commit.sequence}) failed: ${result.error}`,
224
+ { commit, sub: m1Commands.map(c => c.type) },
225
+ )
226
+ }
227
+ }
228
+ // Then apply M0 commands sequentially (using updated model for validation)
229
+ for (const m0cmd of m0Commands) {
230
+ const result = applyM0Command(m0, m0cmd, model)
231
+ if (result.success) {
232
+ m0 = result.state
233
+ } else {
234
+ failures++
235
+ console.warn(
236
+ `[replayCommits] M0 command in batch ${commit.id} (${m0cmd.type}) failed: ${result.error}`,
237
+ )
238
+ }
239
+ }
240
+ } else if (isM0Command(cmd)) {
241
+ // Standalone M0 command
242
+ const result = applyM0Command(m0, cmd, model)
243
+ if (result.success) {
244
+ m0 = result.state
245
+ } else {
246
+ failures++
247
+ console.warn(
248
+ `[replayCommits] M0 commit ${commit.id} (seq ${commit.sequence}, ${cmd.type}) failed: ${result.error}`,
249
+ { commit },
250
+ )
251
+ }
252
+ } else {
253
+ const result = applyCommand(model, cmd as Command)
254
+ if (result.success) {
255
+ model = result.state
256
+ } else {
257
+ failures++
258
+ console.warn(
259
+ `[replayCommits] commit ${commit.id} (seq ${commit.sequence}, ${cmd.type}) failed: ${result.error}`,
260
+ { commit },
261
+ )
262
+ }
263
+ }
264
+ }
265
+
266
+ return { model, m0, failures }
267
+ }
268
+
269
+ // ── Load from IDB ─────────────────────────────────────────────────────
270
+
271
+ async function loadFromStorage(
272
+ loadMapId: string,
273
+ branchId: string = 'main',
274
+ ): Promise<{ model: RootContext; m0: M0State; replayFailures: number } | null> {
275
+ const checkpoint = await loadLatestCheckpoint(loadMapId, branchId)
276
+ if (!checkpoint) return null
277
+
278
+ // Migrate legacy checkpoints to the current schema version. This is a
279
+ // one-shot policy applied as the checkpoint enters the system; replay
280
+ // itself stays pure.
281
+ migrateModel(checkpoint.model)
282
+
283
+ const sinceSeq = checkpoint.sequence
284
+ const storedCommits = await loadCommitsSince(loadMapId, branchId, sinceSeq)
285
+
286
+ // Convert IDB records to Commit objects
287
+ const replayable: Commit[] = storedCommits.map(r => ({
288
+ id: r.id,
289
+ mapId: r.mapId,
290
+ sequence: r.sequence,
291
+ command: r.command,
292
+ inverse: r.inverse,
293
+ timestamp: r.timestamp,
294
+ deviceId: r.deviceId,
295
+ branchId: r.branchId,
296
+ parentId: r.parentId,
297
+ }))
298
+
299
+ const cp: Checkpoint = {
300
+ id: checkpoint.id,
301
+ mapId: checkpoint.mapId,
302
+ commitId: checkpoint.commitId,
303
+ sequence: checkpoint.sequence,
304
+ branchId: checkpoint.branchId,
305
+ model: checkpoint.model,
306
+ m0: checkpoint.m0,
307
+ timestamp: checkpoint.timestamp,
308
+ }
309
+
310
+ const state = replayCommits(cp, replayable)
311
+
312
+ // Set internal state
313
+ mapId.value = loadMapId
314
+ activeBranchId.value = branchId
315
+ latestCheckpoint.value = cp
316
+ commits.value = replayable
317
+ nextSequence.value = replayable.length > 0
318
+ ? replayable[replayable.length - 1]!.sequence + 1
319
+ : cp.sequence + 1
320
+ headCommitId.value = replayable.length > 0
321
+ ? replayable[replayable.length - 1]!.id
322
+ : cp.commitId
323
+ pendingCommits.value = []
324
+ undoStack.value = []
325
+ redoStack.value = []
326
+ active.value = true
327
+
328
+ return { model: state.model, m0: state.m0, replayFailures: state.failures }
329
+ }
330
+
331
+ /**
332
+ * Initialize from an existing snapshot (for migration or fresh create).
333
+ * Creates a genesis checkpoint with no prior commits.
334
+ */
335
+ async function initFromSnapshot(
336
+ initMapId: string,
337
+ model: RootContext,
338
+ ): Promise<void> {
339
+ const genesisId = 'genesis'
340
+ const cp: Checkpoint = {
341
+ id: nanoid(),
342
+ mapId: initMapId,
343
+ commitId: genesisId,
344
+ sequence: 0,
345
+ branchId: 'main',
346
+ model: structuredClone(toRaw(model)),
347
+ timestamp: new Date().toISOString(),
348
+ }
349
+
350
+ await idbSaveCheckpoint(cp as IDBCheckpointRecord)
351
+ await saveBranchHead({
352
+ mapId: initMapId,
353
+ branchId: 'main',
354
+ name: 'main',
355
+ headCommitId: genesisId,
356
+ forkPointCommitId: genesisId,
357
+ parentBranchId: '',
358
+ createdAt: cp.timestamp,
359
+ })
360
+
361
+ mapId.value = initMapId
362
+ activeBranchId.value = 'main'
363
+ latestCheckpoint.value = cp
364
+ commits.value = []
365
+ nextSequence.value = 1
366
+ headCommitId.value = genesisId
367
+ pendingCommits.value = []
368
+ undoStack.value = []
369
+ redoStack.value = []
370
+ active.value = true
371
+ }
372
+
373
+ // ── Persistence ───────────────────────────────────────────────────────
374
+ //
375
+ // Two flush paths: a debounced one that fires ~800ms after the last
376
+ // append (good for normal human editing) and an immediate path used when
377
+ // `PENDING_FLUSH_CAP` is exceeded or the page is about to unload. Both
378
+ // funnel through `flushPendingNow()` which does the actual IDB write.
379
+
380
+ async function flushPendingNow(): Promise<void> {
381
+ if (!active.value || pendingCommits.value.length === 0) return
382
+ const toFlush = [...pendingCommits.value]
383
+ pendingCommits.value = []
384
+ await saveCommits(toFlush.map(c => JSON.parse(JSON.stringify(toRaw(c))) as IDBCommitRecord))
385
+
386
+ // Update branch head
387
+ const lastCommit = toFlush[toFlush.length - 1]!
388
+ await saveBranchHead({
389
+ mapId: mapId.value,
390
+ branchId: activeBranchId.value,
391
+ name: activeBranchId.value === 'main' ? 'main' : activeBranchId.value,
392
+ headCommitId: lastCommit.id,
393
+ forkPointCommitId: latestCheckpoint.value?.commitId ?? 'genesis',
394
+ parentBranchId: '',
395
+ createdAt: latestCheckpoint.value?.timestamp ?? new Date().toISOString(),
396
+ })
397
+ }
398
+
399
+ const scheduleFlush = useDebounceFn(flushPendingNow, 800)
400
+
401
+ let _getRoot: (() => RootContext) | null = null
402
+ let _getM0: (() => M0State) | null = null
403
+
404
+ function bindStateAccessors(
405
+ getRoot: () => RootContext,
406
+ getM0?: () => M0State,
407
+ ) {
408
+ _getRoot = getRoot
409
+ if (getM0) _getM0 = getM0
410
+ }
411
+
412
+ const scheduleCheckpoint = useDebounceFn(async () => {
413
+ if (!active.value || !_getRoot) return
414
+ await createCheckpointNow()
415
+ }, 100) // Short debounce - checkpoint is infrequent but should be fast
416
+
417
+ async function createCheckpointNow(): Promise<void> {
418
+ if (!_getRoot || !headCommitId.value) return
419
+
420
+ const cp: Checkpoint = {
421
+ id: nanoid(),
422
+ mapId: mapId.value,
423
+ commitId: headCommitId.value,
424
+ sequence: nextSequence.value - 1,
425
+ branchId: activeBranchId.value,
426
+ model: structuredClone(toRaw(_getRoot())),
427
+ m0: _getM0 ? structuredClone(toRaw(_getM0())) : undefined,
428
+ timestamp: new Date().toISOString(),
429
+ }
430
+
431
+ await idbSaveCheckpoint(cp as IDBCheckpointRecord)
432
+ latestCheckpoint.value = cp
433
+
434
+ // Prune old checkpoints - keep latest 3
435
+ await pruneOldCheckpoints(mapId.value, activeBranchId.value, 3)
436
+ }
437
+
438
+ async function forceFlush(): Promise<void> {
439
+ await flushPendingNow()
440
+ await createCheckpointNow()
441
+ }
442
+
443
+ // ── Unload handlers ───────────────────────────────────────────────────
444
+ //
445
+ // When the page is about to unload - tab close, navigation, visibility
446
+ // hidden - we fire-and-forget a flush so pending commits reach IDB before
447
+ // the process terminates. Browsers give unload handlers ~10-100ms to
448
+ // complete async work; for a small IDB transaction that's enough.
449
+ //
450
+ // Three signals are wired because each catches a different case:
451
+ // - `visibilitychange` (hidden): most reliable on mobile; fires when
452
+ // user backgrounds the tab or navigates away inside the browser
453
+ // - `pagehide`: fires on actual page unload including back/forward cache
454
+ // enter on iOS
455
+ // - `beforeunload`: legacy desktop path for window close and navigation
456
+ //
457
+ // Installed once per singleton. `reset()` does not uninstall because the
458
+ // singleton survives across map switches - the handlers are fine to keep.
459
+
460
+ function handleUnloadSignal() {
461
+ if (!active.value) return
462
+ if (pendingCommits.value.length === 0) return
463
+ // Fire and forget. The IDB transaction is started synchronously; the
464
+ // browser typically lets it complete before tearing down the page.
465
+ void flushPendingNow().catch(() => {
466
+ // Swallow - we're in an unload path, there's nowhere to surface this.
467
+ })
468
+ }
469
+
470
+ if (typeof document !== 'undefined') {
471
+ document.addEventListener('visibilitychange', () => {
472
+ if (document.visibilityState === 'hidden') handleUnloadSignal()
473
+ })
474
+ }
475
+ if (typeof window !== 'undefined') {
476
+ window.addEventListener('pagehide', handleUnloadSignal)
477
+ window.addEventListener('beforeunload', handleUnloadSignal)
478
+ }
479
+
480
+ // ── Reset ─────────────────────────────────────────────────────────────
481
+
482
+ function reset() {
483
+ mapId.value = ''
484
+ activeBranchId.value = 'main'
485
+ commits.value = []
486
+ latestCheckpoint.value = null
487
+ nextSequence.value = 0
488
+ headCommitId.value = null
489
+ pendingCommits.value = []
490
+ undoStack.value = []
491
+ redoStack.value = []
492
+ active.value = false
493
+ }
494
+
495
+ return {
496
+ mapId,
497
+ activeBranchId,
498
+ commits,
499
+ latestCheckpoint,
500
+ headCommitId,
501
+ active,
502
+ canUndo,
503
+ canRedo,
504
+ undoStack,
505
+ setDeviceId,
506
+ getDeviceId,
507
+ onAppend,
508
+ flushPending: flushPendingNow,
509
+ nextSequence,
510
+ appendCommit,
511
+ popUndo,
512
+ popRedo,
513
+ replayCommits,
514
+ loadFromStorage,
515
+ initFromSnapshot,
516
+ bindStateAccessors,
517
+ createCheckpointNow,
518
+ forceFlush,
519
+ reset,
520
+ }
521
+ }