@dfosco/storyboard 0.6.11 → 0.6.13

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.
@@ -377,3 +377,229 @@ describe('POST /batch', () => {
377
377
  expect(refs['1']).toMatch(/^connector-/)
378
378
  })
379
379
  })
380
+
381
+ describe('POST /undo and POST /redo (event-id targeting)', () => {
382
+ let root, canvasDir, invoke, lastResponse
383
+
384
+ beforeEach(() => {
385
+ ({ invoke, root, canvasDir, lastResponse } = setup())
386
+ writeCanvas(canvasDir, 'undo-canvas', { title: 'Undo Canvas' })
387
+ })
388
+
389
+ afterEach(() => {
390
+ fs.rmSync(root, { recursive: true, force: true })
391
+ })
392
+
393
+ async function addSticky(text) {
394
+ await invoke('/widget', 'POST', {
395
+ name: 'undo-canvas',
396
+ type: 'sticky-note',
397
+ props: { text },
398
+ position: { x: 0, y: 0 },
399
+ })
400
+ return { widgetId: lastResponse.body.widget.id, eventId: lastResponse.body.eventId }
401
+ }
402
+
403
+ async function readState() {
404
+ await invoke('/read?name=undo-canvas', 'GET', {})
405
+ return lastResponse.body
406
+ }
407
+
408
+ it('appends an event id on every mutation', async () => {
409
+ const { eventId } = await addSticky('a')
410
+ expect(eventId).toMatch(/^evt_/)
411
+ })
412
+
413
+ it('reproduces the cq-enablement scenario: 3 deletes + undo restores only the most recent', async () => {
414
+ const a = await addSticky('a')
415
+ const b = await addSticky('b')
416
+ const c = await addSticky('c')
417
+
418
+ await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId: a.widgetId })
419
+ const delAEventId = lastResponse.body.eventId
420
+ await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId: b.widgetId })
421
+ const delBEventId = lastResponse.body.eventId
422
+ await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId: c.widgetId })
423
+ const delCEventId = lastResponse.body.eventId
424
+
425
+ expect((await readState()).widgets).toEqual([])
426
+
427
+ // Undo only the LAST delete (c).
428
+ await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: delCEventId })
429
+ expect(lastResponse.status).toBe(200)
430
+ expect(lastResponse.body.inverseEvent.event).toBe('widget_added')
431
+ expect(lastResponse.body.inverseEvent.meta).toEqual({ kind: 'undo', of: delCEventId })
432
+
433
+ const after = await readState()
434
+ expect(after.widgets).toHaveLength(1)
435
+ expect(after.widgets[0].id).toBe(c.widgetId)
436
+ // a and b stay deleted — the bug class would have resurrected them too.
437
+ expect(after.widgets.map((w) => w.id)).not.toContain(a.widgetId)
438
+ expect(after.widgets.map((w) => w.id)).not.toContain(b.widgetId)
439
+
440
+ // Tracking that we don't leave delAEventId / delBEventId mentioned.
441
+ void delAEventId
442
+ void delBEventId
443
+ })
444
+
445
+ it('undo of widget_updated reverts the props using prevProps', async () => {
446
+ const { widgetId } = await addSticky('original')
447
+
448
+ await invoke('/widget', 'PATCH', {
449
+ name: 'undo-canvas',
450
+ widgetId,
451
+ props: { text: 'edited' },
452
+ })
453
+ const editEventId = lastResponse.body.eventIds[0]
454
+
455
+ let state = await readState()
456
+ expect(state.widgets[0].props.text).toBe('edited')
457
+
458
+ await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: editEventId })
459
+ state = await readState()
460
+ expect(state.widgets[0].props.text).toBe('original')
461
+ })
462
+
463
+ it('undo of widget_moved reverts the position using prevPosition', async () => {
464
+ const { widgetId } = await addSticky('a')
465
+
466
+ await invoke('/widget', 'PATCH', {
467
+ name: 'undo-canvas',
468
+ widgetId,
469
+ position: { x: 500, y: 600 },
470
+ })
471
+ const moveEventId = lastResponse.body.eventIds[0]
472
+
473
+ let state = await readState()
474
+ expect(state.widgets[0].position).toEqual({ x: 500, y: 600 })
475
+
476
+ await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: moveEventId })
477
+ state = await readState()
478
+ expect(state.widgets[0].position).toEqual({ x: 0, y: 0 })
479
+ })
480
+
481
+ it('redo of an undone delete re-removes the widget', async () => {
482
+ const { widgetId } = await addSticky('a')
483
+ await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId })
484
+ const delEventId = lastResponse.body.eventId
485
+
486
+ await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: delEventId })
487
+ const undoEventId = lastResponse.body.eventId
488
+ expect((await readState()).widgets).toHaveLength(1)
489
+
490
+ // Redo with the undo event id — server emits the inverse of the undo,
491
+ // which is a fresh widget_removed.
492
+ await invoke('/redo', 'POST', { name: 'undo-canvas', eventId: undoEventId })
493
+ expect(lastResponse.body.inverseEvent.event).toBe('widget_removed')
494
+ expect(lastResponse.body.inverseEvent.meta).toEqual({ kind: 'redo', of: undoEventId })
495
+
496
+ expect((await readState()).widgets).toEqual([])
497
+ })
498
+
499
+ it('rejects undo on an unknown event id with 404', async () => {
500
+ await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: 'evt_does_not_exist' })
501
+ expect(lastResponse.status).toBe(404)
502
+ })
503
+
504
+ it('rejects undo on a non-undoable event (widgets_replaced) with 400', async () => {
505
+ // Trigger a widgets_replaced via PUT /update with replaceAll
506
+ await invoke('/update', 'PUT', {
507
+ name: 'undo-canvas',
508
+ widgets: [],
509
+ replaceAll: true,
510
+ })
511
+ // Read the file to get the event id of the widgets_replaced event.
512
+ const filePath = path.join(canvasDir, 'undo-canvas.canvas.jsonl')
513
+ const lines = fs.readFileSync(filePath, 'utf-8').trim().split('\n').map((l) => JSON.parse(l))
514
+ const replacedEvt = lines.find((l) => l.event === 'widgets_replaced')
515
+ expect(replacedEvt?.id).toBeTruthy()
516
+
517
+ await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: replacedEvt.id })
518
+ expect(lastResponse.status).toBe(400)
519
+ expect(lastResponse.body.error).toMatch(/cannot be undone/i)
520
+ })
521
+ })
522
+
523
+ describe('runaway-session hard ceiling compaction', () => {
524
+ let root, canvasDir, invoke, lastResponse
525
+
526
+ beforeEach(() => {
527
+ ({ invoke, root, canvasDir, lastResponse } = setup())
528
+ writeCanvas(canvasDir, 'big-canvas', { title: 'Big Canvas' })
529
+ })
530
+
531
+ afterEach(() => {
532
+ fs.rmSync(root, { recursive: true, force: true })
533
+ })
534
+
535
+ // setImmediate-based test helper
536
+ function flushSetImmediate() {
537
+ return new Promise((resolve) => setImmediate(resolve))
538
+ }
539
+
540
+ it('does not compact a small file (under the 2 MB threshold)', async () => {
541
+ const filePath = path.join(canvasDir, 'big-canvas.canvas.jsonl')
542
+ await invoke('/widget', 'POST', {
543
+ name: 'big-canvas',
544
+ type: 'sticky-note',
545
+ props: { text: 'hello' },
546
+ position: { x: 0, y: 0 },
547
+ })
548
+ await flushSetImmediate()
549
+ const sizeAfter = fs.statSync(filePath).size
550
+ expect(sizeAfter).toBeLessThan(2 * 1024 * 1024)
551
+ // File should still contain both canvas_created + widget_added events.
552
+ const events = fs.readFileSync(filePath, 'utf-8').trim().split('\n').map((l) => JSON.parse(l))
553
+ expect(events.length).toBe(2)
554
+ expect(events[0].event).toBe('canvas_created')
555
+ expect(events[1].event).toBe('widget_added')
556
+ })
557
+
558
+ it('compacts the file once it exceeds the runaway threshold', async () => {
559
+ const filePath = path.join(canvasDir, 'big-canvas.canvas.jsonl')
560
+
561
+ // Pre-bloat the file past 2 MB by appending raw garbage events. Each
562
+ // event has a unique id so the materializer treats it as valid.
563
+ const padding = 'x'.repeat(4000)
564
+ const bloatLines = []
565
+ for (let i = 0; i < 600; i++) {
566
+ bloatLines.push(JSON.stringify({
567
+ id: `evt_bloat_${i}`,
568
+ event: 'widget_added',
569
+ timestamp: '2026-01-01',
570
+ widget: { id: `bloat-${i}`, type: 'sticky-note', position: { x: 0, y: 0 }, props: { text: padding } },
571
+ }))
572
+ }
573
+ fs.appendFileSync(filePath, bloatLines.join('\n') + '\n')
574
+ const sizeBefore = fs.statSync(filePath).size
575
+ expect(sizeBefore).toBeGreaterThan(2 * 1024 * 1024)
576
+
577
+ // Trigger appendEvent — this should detect the runaway size and kick
578
+ // off a compaction.
579
+ await invoke('/widget', 'POST', {
580
+ name: 'big-canvas',
581
+ type: 'sticky-note',
582
+ props: { text: 'trigger' },
583
+ position: { x: 0, y: 0 },
584
+ })
585
+ // Compaction runs on setImmediate; flush it.
586
+ await flushSetImmediate()
587
+ await flushSetImmediate()
588
+
589
+ const sizeAfter = fs.statSync(filePath).size
590
+ expect(sizeAfter).toBeLessThan(sizeBefore)
591
+
592
+ // After compaction, file should be a single canvas_created event
593
+ // containing all the materialized widgets.
594
+ const lines = fs.readFileSync(filePath, 'utf-8').trim().split('\n')
595
+ expect(lines.length).toBe(1)
596
+ const baseline = JSON.parse(lines[0])
597
+ expect(baseline.event).toBe('canvas_created')
598
+ // 600 bloat widgets + 1 from the triggering append = 601.
599
+ expect(baseline.widgets.length).toBe(601)
600
+ // Sanity: state still reads cleanly via the normal /read endpoint.
601
+ await invoke('/read?name=big-canvas', 'GET', {})
602
+ expect(lastResponse.status).toBe(200)
603
+ expect(lastResponse.body.widgets.length).toBe(601)
604
+ })
605
+ })
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Canvas undo/redo: build inverse forward events.
3
+ *
4
+ * Pure module — given a target canvas event (with inverse payload `prev*`
5
+ * fields or full `widget`/`connector` objects on removes), returns the
6
+ * inverse event that, when appended to the JSONL and materialized, restores
7
+ * the state to what it was before the target event was applied.
8
+ *
9
+ * The inverse events are real, valid forward events (widget_added,
10
+ * widget_removed, widget_updated, etc.) — the materializer folds them with
11
+ * no special handling. We tag them with `meta.kind = 'undo' | 'redo'` and
12
+ * `meta.of = <targetEventId>` purely for human readability and DX.
13
+ */
14
+
15
+ /**
16
+ * Compute the inverse forward event for a given canvas event.
17
+ *
18
+ * @param {object} targetEvent — the event being undone/redone (must have id)
19
+ * @param {'undo'|'redo'} kind — meta.kind to stamp on the returned event
20
+ * @returns {object|null} the inverse event (without timestamp/id — the
21
+ * caller stamps those via appendEvent), or null if the event is not
22
+ * undoable (legacy events without inverse payloads, widgets_replaced,
23
+ * connectors_replaced, canvas_created, etc.).
24
+ */
25
+ export function buildInverseEvent(targetEvent, kind) {
26
+ if (!targetEvent || typeof targetEvent !== 'object') return null
27
+ if (!targetEvent.id) return null
28
+
29
+ const meta = { kind, of: targetEvent.id }
30
+
31
+ switch (targetEvent.event) {
32
+ case 'widget_added': {
33
+ const widgetId = targetEvent.widget?.id
34
+ if (!widgetId) return null
35
+ // Inverse of add is remove. Carry the full widget so the inverse
36
+ // event itself is undoable (redo-of-undo brings it back).
37
+ return {
38
+ event: 'widget_removed',
39
+ widgetId,
40
+ widget: targetEvent.widget,
41
+ meta,
42
+ }
43
+ }
44
+
45
+ case 'widget_removed': {
46
+ // Inverse of remove is add. Requires the full widget payload.
47
+ const widget = targetEvent.widget
48
+ if (!widget || !widget.id) return null
49
+ return {
50
+ event: 'widget_added',
51
+ widget,
52
+ meta,
53
+ }
54
+ }
55
+
56
+ case 'widget_updated': {
57
+ if (!targetEvent.widgetId || !targetEvent.prevProps) return null
58
+ return {
59
+ event: 'widget_updated',
60
+ widgetId: targetEvent.widgetId,
61
+ props: targetEvent.prevProps,
62
+ // The inverse of the inverse is the original — capture so redo works.
63
+ prevProps: targetEvent.props,
64
+ meta,
65
+ }
66
+ }
67
+
68
+ case 'widget_moved': {
69
+ if (!targetEvent.widgetId || !targetEvent.prevPosition) return null
70
+ return {
71
+ event: 'widget_moved',
72
+ widgetId: targetEvent.widgetId,
73
+ position: targetEvent.prevPosition,
74
+ prevPosition: targetEvent.position,
75
+ meta,
76
+ }
77
+ }
78
+
79
+ case 'connector_added': {
80
+ const connectorId = targetEvent.connector?.id
81
+ if (!connectorId) return null
82
+ return {
83
+ event: 'connector_removed',
84
+ connectorId,
85
+ connector: targetEvent.connector,
86
+ meta,
87
+ }
88
+ }
89
+
90
+ case 'connector_removed': {
91
+ const connector = targetEvent.connector
92
+ if (!connector || !connector.id) return null
93
+ return {
94
+ event: 'connector_added',
95
+ connector,
96
+ meta,
97
+ }
98
+ }
99
+
100
+ case 'connector_updated': {
101
+ if (!targetEvent.connectorId || !targetEvent.prevUpdates) return null
102
+ return {
103
+ event: 'connector_updated',
104
+ connectorId: targetEvent.connectorId,
105
+ updates: targetEvent.prevUpdates,
106
+ prevUpdates: targetEvent.updates,
107
+ meta,
108
+ }
109
+ }
110
+
111
+ case 'connector_waypoints_set': {
112
+ if (!targetEvent.connectorId) return null
113
+ const prev = targetEvent.prevWaypoints
114
+ // prevWaypoints null means there were no manual waypoints before —
115
+ // inverse is `cleared` (which itself carries the current waypoints
116
+ // as prevWaypoints so redo can re-set them).
117
+ if (prev === null) {
118
+ return {
119
+ event: 'connector_waypoints_cleared',
120
+ connectorId: targetEvent.connectorId,
121
+ prevWaypoints: targetEvent.waypoints,
122
+ meta,
123
+ }
124
+ }
125
+ if (!Array.isArray(prev)) return null
126
+ return {
127
+ event: 'connector_waypoints_set',
128
+ connectorId: targetEvent.connectorId,
129
+ waypoints: prev,
130
+ prevWaypoints: targetEvent.waypoints,
131
+ meta,
132
+ }
133
+ }
134
+
135
+ case 'connector_waypoints_cleared': {
136
+ if (!targetEvent.connectorId) return null
137
+ // Clearing had no prior manual routing: there's nothing to restore.
138
+ const prev = targetEvent.prevWaypoints
139
+ if (!Array.isArray(prev)) return null
140
+ return {
141
+ event: 'connector_waypoints_set',
142
+ connectorId: targetEvent.connectorId,
143
+ waypoints: prev,
144
+ prevWaypoints: null,
145
+ meta,
146
+ }
147
+ }
148
+
149
+ case 'source_updated': {
150
+ if (!Array.isArray(targetEvent.prevSources)) return null
151
+ return {
152
+ event: 'source_updated',
153
+ sources: targetEvent.prevSources,
154
+ prevSources: targetEvent.sources,
155
+ meta,
156
+ }
157
+ }
158
+
159
+ case 'settings_updated': {
160
+ if (!targetEvent.prevSettings || typeof targetEvent.prevSettings !== 'object') return null
161
+ return {
162
+ event: 'settings_updated',
163
+ settings: targetEvent.prevSettings,
164
+ prevSettings: targetEvent.settings,
165
+ meta,
166
+ }
167
+ }
168
+
169
+ // widgets_replaced, connectors_replaced, canvas_created are compaction
170
+ // / baseline events and intentionally not undoable.
171
+ default:
172
+ return null
173
+ }
174
+ }
@@ -0,0 +1,250 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { buildInverseEvent } from './undoRedo.js'
3
+ import { materialize } from './materializer.js'
4
+
5
+ const widget = (overrides = {}) => ({
6
+ id: 'sticky-1',
7
+ type: 'sticky-note',
8
+ position: { x: 10, y: 20 },
9
+ props: { text: 'hello' },
10
+ ...overrides,
11
+ })
12
+
13
+ describe('buildInverseEvent', () => {
14
+ it('returns null for events without an id', () => {
15
+ expect(buildInverseEvent({ event: 'widget_added', widget: widget() }, 'undo')).toBeNull()
16
+ })
17
+
18
+ it('returns null for non-undoable event types', () => {
19
+ expect(buildInverseEvent({ id: 'x', event: 'widgets_replaced', widgets: [] }, 'undo')).toBeNull()
20
+ expect(buildInverseEvent({ id: 'x', event: 'connectors_replaced', connectors: [] }, 'undo')).toBeNull()
21
+ expect(buildInverseEvent({ id: 'x', event: 'canvas_created', widgets: [] }, 'undo')).toBeNull()
22
+ })
23
+
24
+ it('inverts widget_added into widget_removed carrying the widget', () => {
25
+ const target = { id: 'evt_1', event: 'widget_added', widget: widget() }
26
+ const inv = buildInverseEvent(target, 'undo')
27
+ expect(inv).toEqual({
28
+ event: 'widget_removed',
29
+ widgetId: 'sticky-1',
30
+ widget: widget(),
31
+ meta: { kind: 'undo', of: 'evt_1' },
32
+ })
33
+ })
34
+
35
+ it('inverts widget_removed into widget_added when the widget payload is present', () => {
36
+ const target = { id: 'evt_2', event: 'widget_removed', widgetId: 'sticky-1', widget: widget() }
37
+ const inv = buildInverseEvent(target, 'undo')
38
+ expect(inv).toEqual({
39
+ event: 'widget_added',
40
+ widget: widget(),
41
+ meta: { kind: 'undo', of: 'evt_2' },
42
+ })
43
+ })
44
+
45
+ it('returns null for legacy widget_removed events that lack the full widget payload', () => {
46
+ const target = { id: 'evt_old', event: 'widget_removed', widgetId: 'sticky-1' }
47
+ expect(buildInverseEvent(target, 'undo')).toBeNull()
48
+ })
49
+
50
+ it('inverts widget_updated using prevProps and mirrors current props for redo', () => {
51
+ const target = {
52
+ id: 'evt_3',
53
+ event: 'widget_updated',
54
+ widgetId: 'sticky-1',
55
+ props: { text: 'new', color: 'red' },
56
+ prevProps: { text: 'old', color: undefined },
57
+ }
58
+ const inv = buildInverseEvent(target, 'undo')
59
+ expect(inv).toEqual({
60
+ event: 'widget_updated',
61
+ widgetId: 'sticky-1',
62
+ props: { text: 'old', color: undefined },
63
+ prevProps: { text: 'new', color: 'red' },
64
+ meta: { kind: 'undo', of: 'evt_3' },
65
+ })
66
+ })
67
+
68
+ it('inverts widget_moved using prevPosition', () => {
69
+ const target = {
70
+ id: 'evt_4',
71
+ event: 'widget_moved',
72
+ widgetId: 'sticky-1',
73
+ position: { x: 200, y: 200 },
74
+ prevPosition: { x: 10, y: 20 },
75
+ }
76
+ const inv = buildInverseEvent(target, 'undo')
77
+ expect(inv).toEqual({
78
+ event: 'widget_moved',
79
+ widgetId: 'sticky-1',
80
+ position: { x: 10, y: 20 },
81
+ prevPosition: { x: 200, y: 200 },
82
+ meta: { kind: 'undo', of: 'evt_4' },
83
+ })
84
+ })
85
+
86
+ it('inverts connector_added into connector_removed', () => {
87
+ const connector = {
88
+ id: 'connector-1',
89
+ type: 'connector',
90
+ connectorType: 'default',
91
+ start: { widgetId: 'sticky-1', anchor: 'right' },
92
+ end: { widgetId: 'sticky-2', anchor: 'left' },
93
+ meta: {},
94
+ }
95
+ const inv = buildInverseEvent({ id: 'evt_5', event: 'connector_added', connector }, 'undo')
96
+ expect(inv).toEqual({
97
+ event: 'connector_removed',
98
+ connectorId: 'connector-1',
99
+ connector,
100
+ meta: { kind: 'undo', of: 'evt_5' },
101
+ })
102
+ })
103
+
104
+ it('inverts connector_removed into connector_added', () => {
105
+ const connector = {
106
+ id: 'connector-1',
107
+ type: 'connector',
108
+ connectorType: 'default',
109
+ start: { widgetId: 'sticky-1', anchor: 'right' },
110
+ end: { widgetId: 'sticky-2', anchor: 'left' },
111
+ meta: {},
112
+ }
113
+ const inv = buildInverseEvent({ id: 'evt_6', event: 'connector_removed', connectorId: 'connector-1', connector }, 'undo')
114
+ expect(inv).toEqual({
115
+ event: 'connector_added',
116
+ connector,
117
+ meta: { kind: 'undo', of: 'evt_6' },
118
+ })
119
+ })
120
+
121
+ it('inverts connector_waypoints_set into connector_waypoints_cleared when there were no prior waypoints', () => {
122
+ const target = {
123
+ id: 'evt_7',
124
+ event: 'connector_waypoints_set',
125
+ connectorId: 'connector-1',
126
+ waypoints: [{ dx: 10, dy: 10 }],
127
+ prevWaypoints: null,
128
+ }
129
+ const inv = buildInverseEvent(target, 'undo')
130
+ expect(inv).toEqual({
131
+ event: 'connector_waypoints_cleared',
132
+ connectorId: 'connector-1',
133
+ prevWaypoints: [{ dx: 10, dy: 10 }],
134
+ meta: { kind: 'undo', of: 'evt_7' },
135
+ })
136
+ })
137
+
138
+ it('inverts source_updated using prevSources', () => {
139
+ const target = {
140
+ id: 'evt_8',
141
+ event: 'source_updated',
142
+ sources: [{ export: 'B', position: { x: 0, y: 0 } }],
143
+ prevSources: [{ export: 'A', position: { x: 1, y: 1 } }],
144
+ }
145
+ const inv = buildInverseEvent(target, 'undo')
146
+ expect(inv).toEqual({
147
+ event: 'source_updated',
148
+ sources: [{ export: 'A', position: { x: 1, y: 1 } }],
149
+ prevSources: [{ export: 'B', position: { x: 0, y: 0 } }],
150
+ meta: { kind: 'undo', of: 'evt_8' },
151
+ })
152
+ })
153
+
154
+ it('tags redo emissions with meta.kind="redo"', () => {
155
+ const target = { id: 'evt_9', event: 'widget_added', widget: widget() }
156
+ const inv = buildInverseEvent(target, 'redo')
157
+ expect(inv?.meta).toEqual({ kind: 'redo', of: 'evt_9' })
158
+ })
159
+ })
160
+
161
+ describe('materialize folds undo events like normal forward events', () => {
162
+ it('a widget_removed undo emission as widget_added re-introduces the widget', () => {
163
+ const w = widget()
164
+ const events = [
165
+ { event: 'canvas_created', timestamp: '1', widgets: [] },
166
+ { id: 'evt_add', event: 'widget_added', timestamp: '2', widget: w },
167
+ { id: 'evt_del', event: 'widget_removed', timestamp: '3', widgetId: w.id, widget: w },
168
+ {
169
+ id: 'evt_undo',
170
+ event: 'widget_added',
171
+ timestamp: '4',
172
+ widget: w,
173
+ meta: { kind: 'undo', of: 'evt_del' },
174
+ },
175
+ ]
176
+ const state = materialize(events)
177
+ expect(state.widgets).toHaveLength(1)
178
+ expect(state.widgets[0].id).toBe(w.id)
179
+ })
180
+
181
+ it('back-compat: legacy widget_removed event with only widgetId still removes', () => {
182
+ const w = widget()
183
+ const events = [
184
+ { event: 'canvas_created', timestamp: '1', widgets: [w] },
185
+ { event: 'widget_removed', timestamp: '2', widgetId: w.id },
186
+ ]
187
+ const state = materialize(events)
188
+ expect(state.widgets).toEqual([])
189
+ })
190
+
191
+ it('back-compat: legacy connector_removed event with only connectorId still removes', () => {
192
+ const connector = {
193
+ id: 'connector-1',
194
+ type: 'connector',
195
+ connectorType: 'default',
196
+ start: { widgetId: 'a', anchor: 'right' },
197
+ end: { widgetId: 'b', anchor: 'left' },
198
+ }
199
+ const events = [
200
+ { event: 'canvas_created', timestamp: '1', widgets: [], connectors: [connector] },
201
+ { event: 'connector_removed', timestamp: '2', connectorId: connector.id },
202
+ ]
203
+ const state = materialize(events)
204
+ expect(state.connectors).toEqual([])
205
+ })
206
+ })
207
+
208
+ describe('end-to-end: cq-enablement bug scenario', () => {
209
+ it('deleting 5 widgets then undoing once only restores the most recent delete', () => {
210
+ // Reproduces the rollback bug. In the old model an undo emitted a
211
+ // widgets_replaced event that resurrected ALL deleted widgets. In the
212
+ // new model the undo targets the most recent widget_removed event and
213
+ // re-adds only that widget.
214
+ const widgets = [
215
+ widget({ id: 'a' }),
216
+ widget({ id: 'b' }),
217
+ widget({ id: 'c' }),
218
+ widget({ id: 'd' }),
219
+ widget({ id: 'e' }),
220
+ ]
221
+ const removeEvents = widgets.map((w, i) => ({
222
+ id: `evt_del_${i}`,
223
+ event: 'widget_removed',
224
+ timestamp: `${10 + i}`,
225
+ widgetId: w.id,
226
+ widget: w,
227
+ }))
228
+
229
+ const events = [
230
+ { event: 'canvas_created', timestamp: '1', widgets },
231
+ ...removeEvents,
232
+ ]
233
+
234
+ // Sanity: after all 5 deletes, the canvas is empty.
235
+ expect(materialize(events).widgets).toEqual([])
236
+
237
+ // Undo the LAST delete only (evt_del_4).
238
+ const inverse = buildInverseEvent(removeEvents[4], 'undo')
239
+ expect(inverse?.event).toBe('widget_added')
240
+ expect(inverse?.widget?.id).toBe('e')
241
+
242
+ const undoneEvents = [...events, { ...inverse, id: 'evt_undo_1', timestamp: '20' }]
243
+ const undoneState = materialize(undoneEvents)
244
+
245
+ // Only widget 'e' is restored — a, b, c, d remain deleted.
246
+ expect(undoneState.widgets).toHaveLength(1)
247
+ expect(undoneState.widgets[0].id).toBe('e')
248
+ })
249
+ })
250
+
@@ -0,0 +1,44 @@
1
+ /**
2
+ * storyboard canvas redo — re-apply a previously-undone event.
3
+ *
4
+ * Usage:
5
+ * storyboard canvas redo --canvas my-canvas --event evt_xxx
6
+ *
7
+ * The --event is the id of the *undo event* (the one with meta.kind = 'undo')
8
+ * that you want to cancel — the server emits the inverse of that, restoring
9
+ * the original change.
10
+ *
11
+ * Flags:
12
+ * -c, --canvas Canvas name (required)
13
+ * -e, --event Event ID (the undo event) to redo (required)
14
+ * --json Output as JSON
15
+ */
16
+
17
+ import { post, parseSimpleArgs, jsonOut, die } from './cliHelpers.js'
18
+
19
+ const args = process.argv.slice(4) // skip: node, sb, canvas, redo
20
+
21
+ if (args.includes('--help') || args.includes('-h')) {
22
+ console.log(`
23
+ canvas redo flags:
24
+
25
+ -c, --canvas Canvas name (required)
26
+ -e, --event Event ID (the undo event) to redo (required)
27
+ --json Output as JSON
28
+ `)
29
+ process.exit(0)
30
+ }
31
+
32
+ const { flags } = parseSimpleArgs(args)
33
+ const canvas = flags.canvas || flags.c
34
+ const eventId = flags.event || flags.e
35
+
36
+ if (!canvas) die('--canvas is required')
37
+ if (!eventId) die('--event is required')
38
+
39
+ try {
40
+ const result = await post('/_storyboard/canvas/redo', { name: canvas, eventId })
41
+ if (flags.json) { jsonOut(result) } else {
42
+ console.log(`Redid event ${eventId} → appended ${result.eventId} (${result.inverseEvent?.event})`)
43
+ }
44
+ } catch (err) { die(err.message) }