@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,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
|
+
}
|