@opendaw/studio-core 0.0.130 → 0.0.132

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.
Files changed (71) hide show
  1. package/dist/AssetService.d.ts.map +1 -1
  2. package/dist/AssetService.js +11 -2
  3. package/dist/AudioOfflineRenderer.js +1 -1
  4. package/dist/EffectBox.d.ts +2 -2
  5. package/dist/EffectBox.d.ts.map +1 -1
  6. package/dist/EffectFactories.d.ts +3 -0
  7. package/dist/EffectFactories.d.ts.map +1 -1
  8. package/dist/EffectFactories.js +19 -3
  9. package/dist/Engine.d.ts +1 -1
  10. package/dist/Engine.d.ts.map +1 -1
  11. package/dist/EngineFacade.d.ts +1 -1
  12. package/dist/EngineFacade.d.ts.map +1 -1
  13. package/dist/EngineFacade.js +2 -2
  14. package/dist/EngineWorklet.d.ts +1 -1
  15. package/dist/EngineWorklet.d.ts.map +1 -1
  16. package/dist/EngineWorklet.js +9 -46
  17. package/dist/MonitoringRouter.d.ts +10 -0
  18. package/dist/MonitoringRouter.d.ts.map +1 -0
  19. package/dist/MonitoringRouter.js +89 -0
  20. package/dist/capture/CaptureAudio.d.ts +10 -0
  21. package/dist/capture/CaptureAudio.d.ts.map +1 -1
  22. package/dist/capture/CaptureAudio.js +105 -30
  23. package/dist/capture/RecordAudio.d.ts.map +1 -1
  24. package/dist/capture/RecordAudio.js +10 -2
  25. package/dist/capture/Recording.js +1 -1
  26. package/dist/processors.js +28 -28
  27. package/dist/processors.js.map +4 -4
  28. package/dist/project/AudioWavExport.d.ts +6 -0
  29. package/dist/project/AudioWavExport.d.ts.map +1 -0
  30. package/dist/project/AudioWavExport.js +15 -0
  31. package/dist/project/NoteMidiExport.d.ts +9 -0
  32. package/dist/project/NoteMidiExport.d.ts.map +1 -0
  33. package/dist/project/NoteMidiExport.js +27 -0
  34. package/dist/project/Project.d.ts.map +1 -1
  35. package/dist/project/Project.js +1 -2
  36. package/dist/project/ProjectApi.d.ts +3 -1
  37. package/dist/project/ProjectApi.d.ts.map +1 -1
  38. package/dist/project/ProjectApi.js +8 -0
  39. package/dist/project/ProjectStorage.d.ts.map +1 -1
  40. package/dist/project/ProjectStorage.js +1 -0
  41. package/dist/project/audio/AudioContentModifier.d.ts.map +1 -1
  42. package/dist/project/audio/AudioContentModifier.js +43 -3
  43. package/dist/project/index.d.ts +2 -0
  44. package/dist/project/index.d.ts.map +1 -1
  45. package/dist/project/index.js +2 -0
  46. package/dist/project/migration/MigrateValueEventCollection.test.js +3 -3
  47. package/dist/samples/OpenSampleAPI.d.ts +1 -0
  48. package/dist/samples/OpenSampleAPI.d.ts.map +1 -1
  49. package/dist/samples/OpenSampleAPI.js +1 -0
  50. package/dist/samples/SampleService.js +1 -1
  51. package/dist/soundfont/DefaultSoundfontLoader.d.ts.map +1 -1
  52. package/dist/soundfont/DefaultSoundfontLoader.js +3 -0
  53. package/dist/soundfont/OpenSoundfontAPI.d.ts +1 -0
  54. package/dist/soundfont/OpenSoundfontAPI.d.ts.map +1 -1
  55. package/dist/soundfont/OpenSoundfontAPI.js +1 -0
  56. package/dist/soundfont/SoundfontService.js +1 -1
  57. package/dist/sync-log/SyncLogWriter.d.ts.map +1 -1
  58. package/dist/sync-log/SyncLogWriter.js +3 -2
  59. package/dist/ui/clipboard/ClipboardUtils.js +1 -1
  60. package/dist/ui/clipboard/types/DevicesClipboardHandler.d.ts.map +1 -1
  61. package/dist/ui/clipboard/types/DevicesClipboardHandler.js +22 -12
  62. package/dist/ui/clipboard/types/DevicesClipboardHandler.test.js +124 -4
  63. package/dist/ysync/YService.d.ts.map +1 -1
  64. package/dist/ysync/YService.js +0 -5
  65. package/dist/ysync/YSync.d.ts +1 -0
  66. package/dist/ysync/YSync.d.ts.map +1 -1
  67. package/dist/ysync/YSync.js +127 -84
  68. package/dist/ysync/YSync.test.d.ts +2 -0
  69. package/dist/ysync/YSync.test.d.ts.map +1 -0
  70. package/dist/ysync/YSync.test.js +259 -0
  71. package/package.json +16 -15
@@ -195,9 +195,10 @@ describe("DevicesClipboardHandler", () => {
195
195
  .map(pointer => pointer.box);
196
196
  const mandatoryDeps = Array.from(boxGraph.dependenciesOf(deviceBox, {
197
197
  alwaysFollowMandatory: true,
198
+ stopAtResources: true,
198
199
  excludeBox: (dep) => dep.ephemeral || DeviceBoxUtils.isDeviceBox(dep)
199
200
  || dep.name === RootBox.ClassName
200
- }).boxes).filter(dep => !isDefined(dep.resource));
201
+ }).boxes).filter(dep => dep.resource !== "preserved");
201
202
  const preserved = [deviceBox, ...ownedChildren].flatMap(root => Array.from(boxGraph.dependenciesOf(root, {
202
203
  alwaysFollowMandatory: true,
203
204
  excludeBox: (dep) => dep.ephemeral || DeviceBoxUtils.isDeviceBox(dep)
@@ -215,6 +216,7 @@ describe("DevicesClipboardHandler", () => {
215
216
  trackContent.push(regionPointer.box);
216
217
  const regionDeps = Array.from(boxGraph.dependenciesOf(regionPointer.box, {
217
218
  alwaysFollowMandatory: true,
219
+ stopAtResources: true,
218
220
  excludeBox: (dep) => dep.ephemeral
219
221
  || isInstanceOf(dep, TrackBox)
220
222
  || DeviceBoxUtils.isDeviceBox(dep)
@@ -232,7 +234,7 @@ describe("DevicesClipboardHandler", () => {
232
234
  return true;
233
235
  });
234
236
  };
235
- const makePasteMapper = (targetAudioUnit, replaceInstrument) => ({
237
+ const makePasteMapper = (targetAudioUnit, replaceInstrument, hasInstrument = true) => ({
236
238
  mapPointer: (pointer) => {
237
239
  if (pointer.pointerType === Pointers.InstrumentHost && replaceInstrument) {
238
240
  return Option.wrap(targetAudioUnit.input.address);
@@ -243,7 +245,7 @@ describe("DevicesClipboardHandler", () => {
243
245
  if (pointer.pointerType === Pointers.MIDIEffectHost) {
244
246
  return Option.wrap(targetAudioUnit.midiEffects.address);
245
247
  }
246
- if (pointer.pointerType === Pointers.TrackCollection && replaceInstrument) {
248
+ if (pointer.pointerType === Pointers.TrackCollection) {
247
249
  return Option.wrap(targetAudioUnit.tracks.address);
248
250
  }
249
251
  if (pointer.pointerType === Pointers.Automation && replaceInstrument) {
@@ -251,7 +253,18 @@ describe("DevicesClipboardHandler", () => {
251
253
  }
252
254
  return Option.None;
253
255
  },
254
- excludeBox: (box) => !replaceInstrument && (DeviceBoxUtils.isInstrumentDeviceBox(box) || isInstanceOf(box, TrackBox))
256
+ excludeBox: (box) => {
257
+ if (replaceInstrument) {
258
+ return false;
259
+ }
260
+ if (DeviceBoxUtils.isInstrumentDeviceBox(box)) {
261
+ return true;
262
+ }
263
+ if (isInstanceOf(box, TrackBox)) {
264
+ return hasInstrument;
265
+ }
266
+ return false;
267
+ }
255
268
  });
256
269
  // ─────────────────────────────────────────────────────────
257
270
  // Audio effect paste
@@ -284,6 +297,113 @@ describe("DevicesClipboardHandler", () => {
284
297
  });
285
298
  });
286
299
  // ─────────────────────────────────────────────────────────
300
+ // Audio effect with automation: copy scope + paste round-trip
301
+ // ─────────────────────────────────────────────────────────
302
+ describe("audio effect with automation", () => {
303
+ it("includes ValueEventCollectionBox when copying effect with automation events", () => {
304
+ const audioUnit = createAudioUnit(source);
305
+ const effect = addAudioEffect(source, audioUnit, "Comp", 0);
306
+ const autoTrack = addAutomationTrack(source, audioUnit, effect.threshold, 0);
307
+ addValueRegion(source, autoTrack, 0, 960);
308
+ const deps = collectDeviceDependencies(effect, source.boxGraph);
309
+ expect(deps.filter(box => isInstanceOf(box, TrackBox)).length).toBe(1);
310
+ expect(deps.filter(box => isInstanceOf(box, ValueRegionBox)).length).toBe(1);
311
+ expect(deps.filter(box => isInstanceOf(box, ValueEventCollectionBox)).length).toBe(1);
312
+ });
313
+ it("includes mirror regions on different tracks of the same device", () => {
314
+ const audioUnit = createAudioUnit(source);
315
+ const effect = addAudioEffect(source, audioUnit, "Comp", 0);
316
+ const trackA = addAutomationTrack(source, audioUnit, effect.threshold, 0);
317
+ const trackB = addAutomationTrack(source, audioUnit, effect.ratio, 1);
318
+ const regionA = addValueRegion(source, trackA, 0, 960);
319
+ const sharedEvents = regionA.events.targetVertex.unwrap().box;
320
+ let regionB;
321
+ source.boxGraph.beginTransaction();
322
+ regionB = ValueRegionBox.create(source.boxGraph, UUID.generate(), box => {
323
+ box.regions.refer(trackB.regions);
324
+ box.events.refer(sharedEvents.owners);
325
+ box.position.setValue(0);
326
+ box.duration.setValue(960);
327
+ });
328
+ source.boxGraph.endTransaction();
329
+ const deps = collectDeviceDependencies(effect, source.boxGraph);
330
+ const regions = deps.filter(box => isInstanceOf(box, ValueRegionBox));
331
+ expect(regions.length).toBe(2);
332
+ expect(regions).toContain(regionA);
333
+ expect(regions).toContain(regionB);
334
+ expect(deps.filter(box => isInstanceOf(box, ValueEventCollectionBox)).length).toBe(1);
335
+ expect(deps.filter(box => isInstanceOf(box, TrackBox)).length).toBe(2);
336
+ });
337
+ it("does not pull in regions from unrelated devices that mirror the same collection", () => {
338
+ const audioUnitA = createAudioUnit(source, 1);
339
+ const effectA = addAudioEffect(source, audioUnitA, "CompA", 0);
340
+ const trackA = addAutomationTrack(source, audioUnitA, effectA.threshold, 0);
341
+ const regionA = addValueRegion(source, trackA, 0, 960);
342
+ const sharedEvents = regionA.events.targetVertex.unwrap().box;
343
+ const audioUnitB = createAudioUnit(source, 2);
344
+ const effectB = addAudioEffect(source, audioUnitB, "CompB", 0);
345
+ const trackB = addAutomationTrack(source, audioUnitB, effectB.threshold, 0);
346
+ source.boxGraph.beginTransaction();
347
+ const unrelatedRegion = ValueRegionBox.create(source.boxGraph, UUID.generate(), box => {
348
+ box.regions.refer(trackB.regions);
349
+ box.events.refer(sharedEvents.owners);
350
+ box.position.setValue(0);
351
+ box.duration.setValue(960);
352
+ });
353
+ source.boxGraph.endTransaction();
354
+ const deps = collectDeviceDependencies(effectA, source.boxGraph);
355
+ const regions = deps.filter(box => isInstanceOf(box, ValueRegionBox));
356
+ expect(regions).toContain(regionA);
357
+ expect(regions).not.toContain(unrelatedRegion);
358
+ const tracks = deps.filter(box => isInstanceOf(box, TrackBox));
359
+ expect(tracks).toContain(trackA);
360
+ expect(tracks).not.toContain(trackB);
361
+ });
362
+ it("round-trip paste of effect with automation events does not throw", () => {
363
+ const sourceAU = createAudioUnit(source);
364
+ const effect = addAudioEffect(source, sourceAU, "Comp", 0);
365
+ const autoTrack = addAutomationTrack(source, sourceAU, effect.threshold, 0);
366
+ addValueRegion(source, autoTrack, 0, 960);
367
+ const deps = collectDeviceDependencies(effect, source.boxGraph);
368
+ const data = ClipboardUtils.serializeBoxes([effect, ...deps]);
369
+ const targetAU = createAudioUnit(target);
370
+ const editing = new BoxEditing(target.boxGraph);
371
+ expect(() => {
372
+ editing.modify(() => {
373
+ ClipboardUtils.deserializeBoxes(data, target.boxGraph, makePasteMapper(targetAU, false, false));
374
+ });
375
+ }).not.toThrow();
376
+ const pastedEffects = targetAU.audioEffects.pointerHub.incoming()
377
+ .filter(pointer => isInstanceOf(pointer.box, CompressorDeviceBox));
378
+ expect(pastedEffects.length).toBe(1);
379
+ const pastedTracks = targetAU.tracks.pointerHub.filter(Pointers.TrackCollection)
380
+ .filter(pointer => isInstanceOf(pointer.box, TrackBox));
381
+ expect(pastedTracks.length).toBe(1);
382
+ });
383
+ it("pasted automation track targets the pasted device's parameter", () => {
384
+ const sourceAU = createAudioUnit(source);
385
+ const effect = addAudioEffect(source, sourceAU, "Comp", 0);
386
+ const autoTrack = addAutomationTrack(source, sourceAU, effect.threshold, 0);
387
+ addValueRegion(source, autoTrack, 0, 960);
388
+ const deps = collectDeviceDependencies(effect, source.boxGraph);
389
+ const data = ClipboardUtils.serializeBoxes([effect, ...deps]);
390
+ const targetAU = createAudioUnit(target);
391
+ const editing = new BoxEditing(target.boxGraph);
392
+ editing.modify(() => {
393
+ ClipboardUtils.deserializeBoxes(data, target.boxGraph, makePasteMapper(targetAU, false, false));
394
+ });
395
+ const pastedEffect = targetAU.audioEffects.pointerHub.incoming()
396
+ .filter(pointer => isInstanceOf(pointer.box, CompressorDeviceBox))
397
+ .map(pointer => pointer.box)[0];
398
+ const pastedTrack = targetAU.tracks.pointerHub.filter(Pointers.TrackCollection)
399
+ .filter(pointer => isInstanceOf(pointer.box, TrackBox))
400
+ .map(pointer => pointer.box)[0];
401
+ expect(pastedTrack).toBeDefined();
402
+ const targetVertex = pastedTrack.target.targetVertex.unwrap();
403
+ expect(targetVertex.box).toBe(pastedEffect);
404
+ });
405
+ });
406
+ // ─────────────────────────────────────────────────────────
287
407
  // Instrument paste
288
408
  // ─────────────────────────────────────────────────────────
289
409
  describe("paste instrument", () => {
@@ -1 +1 @@
1
- {"version":3,"file":"YService.d.ts","sourceRoot":"","sources":["../../src/ysync/YService.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,MAAM,EAAyC,MAAM,kBAAkB,CAAA;AAKvF,OAAO,EAAC,OAAO,EAAE,UAAU,EAAmB,MAAM,YAAY,CAAA;AAGhE,OAAO,EAAC,iBAAiB,EAAC,MAAM,aAAa,CAAA;AAI7C,yBAAiB,QAAQ,CAAC;IAMtB,KAAY,UAAU,GAAG;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,QAAQ,EAAE,iBAAiB,CAAA;KAAE,CAAA;IAEnE,MAAM,eAAe,GAAU,YAAY,MAAM,CAAC,OAAO,CAAC,EAC3B,KAAK,UAAU,EACf,UAAU,MAAM,KAAG,OAAO,CAAC,UAAU,CA+D1E,CAAA;CACJ"}
1
+ {"version":3,"file":"YService.d.ts","sourceRoot":"","sources":["../../src/ysync/YService.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,MAAM,EAAyC,MAAM,kBAAkB,CAAA;AAKvF,OAAO,EAAC,OAAO,EAAE,UAAU,EAAmB,MAAM,YAAY,CAAA;AAGhE,OAAO,EAAC,iBAAiB,EAAC,MAAM,aAAa,CAAA;AAI7C,yBAAiB,QAAQ,CAAC;IAMtB,KAAY,UAAU,GAAG;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,QAAQ,EAAE,iBAAiB,CAAA;KAAE,CAAA;IAEnE,MAAM,eAAe,GAAU,YAAY,MAAM,CAAC,OAAO,CAAC,EAC3B,KAAK,UAAU,EACf,UAAU,MAAM,KAAG,OAAO,CAAC,UAAU,CA0D1E,CAAA;CACJ"}
@@ -46,9 +46,6 @@ export var YService;
46
46
  conflict: () => project.invalid()
47
47
  });
48
48
  project.own(sync);
49
- // TODO Remove this cast at some point
50
- const editing = project.editing;
51
- editing.disable();
52
49
  return { project, provider };
53
50
  }
54
51
  else {
@@ -75,8 +72,6 @@ export var YService;
75
72
  boxGraph.endTransaction();
76
73
  project.follow(userInterfaceBox);
77
74
  project.own(sync);
78
- const editing = project.editing;
79
- editing.disable();
80
75
  return { project, provider };
81
76
  }
82
77
  };
@@ -8,6 +8,7 @@ export type Construct<T> = {
8
8
  };
9
9
  export declare class YSync<T> implements Terminable {
10
10
  #private;
11
+ static debugging: boolean;
11
12
  /** @internal */
12
13
  static isEmpty(doc: Y.Doc): boolean;
13
14
  static populateRoom<T>({ boxGraph, boxes }: Construct<T>): Promise<YSync<T>>;
@@ -1 +1 @@
1
- {"version":3,"file":"YSync.d.ts","sourceRoot":"","sources":["../../src/ysync/YSync.ts"],"names":[],"mappings":"AAAA,OAAO,EASH,QAAQ,EAER,UAAU,EAGb,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAa,QAAQ,EAA2D,MAAM,kBAAkB,CAAA;AAE/G,OAAO,KAAK,CAAC,MAAM,KAAK,CAAA;AAIxB,MAAM,MAAM,SAAS,CAAC,CAAC,IAAI;IACvB,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC;IACtB,KAAK,EAAE,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;IACrB,QAAQ,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAA;CAC/B,CAAA;AAED,qBAAa,KAAK,CAAC,CAAC,CAAE,YAAW,UAAU;;IACvC,gBAAgB;IAChB,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC,GAAG,GAAG,OAAO;WAItB,YAAY,CAAC,CAAC,EAAE,EAAC,QAAQ,EAAE,KAAK,EAAC,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;WAcnE,QAAQ,CAAC,CAAC,EAAE,EAAC,QAAQ,EAAE,KAAK,EAAC,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;gBAqChE,EAAC,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAC,EAAE,SAAS,CAAC,CAAC,CAAC;IASrD,SAAS,IAAI,IAAI;CA4LpB"}
1
+ {"version":3,"file":"YSync.d.ts","sourceRoot":"","sources":["../../src/ysync/YSync.ts"],"names":[],"mappings":"AAAA,OAAO,EAUH,QAAQ,EAER,UAAU,EAIb,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAEH,QAAQ,EAOX,MAAM,kBAAkB,CAAA;AAEzB,OAAO,KAAK,CAAC,MAAM,KAAK,CAAA;AAIxB,MAAM,MAAM,SAAS,CAAC,CAAC,IAAI;IACvB,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC;IACtB,KAAK,EAAE,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;IACrB,QAAQ,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAA;CAC/B,CAAA;AAED,qBAAa,KAAK,CAAC,CAAC,CAAE,YAAW,UAAU;;IACvC,MAAM,CAAC,SAAS,EAAE,OAAO,CAAQ;IAEjC,gBAAgB;IAChB,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC,GAAG,GAAG,OAAO;WAItB,YAAY,CAAC,CAAC,EAAE,EAAC,QAAQ,EAAE,KAAK,EAAC,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;WAcnE,QAAQ,CAAC,CAAC,EAAE,EAAC,QAAQ,EAAE,KAAK,EAAC,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;gBAqChE,EAAC,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAC,EAAE,SAAS,CAAC,CAAC,CAAC;IASrD,SAAS,IAAI,IAAI;CAiOpB"}
@@ -1,7 +1,9 @@
1
- import { asDefined, asInstanceOf, assert, EmptyExec, isUndefined, Option, panic, Terminable, Terminator, UUID } from "@opendaw/lib-std";
1
+ import { asDefined, asInstanceOf, assert, EmptyExec, isInstanceOf, isUndefined, Option, panic, Terminable, Terminator, tryCatch, UUID } from "@opendaw/lib-std";
2
+ import { optimizeUpdates } from "@opendaw/lib-box";
2
3
  import { YMapper } from "./YMapper";
3
4
  import * as Y from "yjs";
4
5
  export class YSync {
6
+ static debugging = false;
5
7
  /** @internal */
6
8
  static isEmpty(doc) {
7
9
  return doc.getMap("boxes").size === 0;
@@ -62,45 +64,51 @@ export class YSync {
62
64
  terminate() { this.#terminator.terminate(); }
63
65
  #setupYjs() {
64
66
  const eventHandler = (events, { origin, local }) => {
65
- const originLabel = typeof origin === "string" ? origin : "WebsocketProvider";
67
+ const originLabel = typeof origin === "string" ? origin : "Unkown Origin";
66
68
  const isOwnOrigin = typeof origin === "string" && origin.startsWith("[openDAW]");
67
69
  const isHistoryReplay = typeof origin === "string" && origin.startsWith("[history]");
68
- console.debug(`got ${events.length} ${local ? "local" : "external"} updates from '${originLabel}', isHistoryReplay: ${isHistoryReplay}`);
70
+ console.debug(`got ${events.length} ${local ? "local" : "external"} updates from '${originLabel}', isHistoryReplay: ${isHistoryReplay}, isOwnOrigin: ${isOwnOrigin}`);
69
71
  if (isOwnOrigin || (local && !isHistoryReplay)) {
70
72
  return;
71
73
  }
72
74
  this.#boxGraph.beginTransaction();
73
- for (const event of events) {
74
- const path = this.#normalizePath(event.path);
75
- const keys = event.changes.keys;
76
- for (const [key, change] of keys.entries()) {
77
- if (change.action === "add") {
78
- assert(path.length === 0, "'Add' cannot have a path");
79
- this.#createBox(key);
80
- }
81
- else if (change.action === "update") {
82
- if (path.length === 0) {
83
- continue;
75
+ const result = tryCatch(() => {
76
+ for (const event of events) {
77
+ const path = this.#normalizePath(event.path);
78
+ const keys = event.changes.keys;
79
+ for (const [key, change] of keys.entries()) {
80
+ if (YSync.debugging) {
81
+ console.debug(`${change.action} on ${path}:${key}`);
82
+ }
83
+ if (change.action === "add") {
84
+ assert(path.length === 0, "'Add' cannot have a path");
85
+ this.#createBox(key);
86
+ }
87
+ else if (change.action === "update") {
88
+ if (path.length === 0) {
89
+ continue;
90
+ }
91
+ assert(path.length >= 2, "Invalid path: must have at least 2 elements (uuid, 'fields').");
92
+ this.#updateValue(path, key);
93
+ }
94
+ else if (change.action === "delete") {
95
+ assert(path.length === 0, "'Delete' cannot have a path");
96
+ this.#deleteBox(key);
84
97
  }
85
- assert(path.length >= 2, "Invalid path: must have at least 2 elements (uuid, 'fields').");
86
- this.#updateValue(path, key);
87
- }
88
- else if (change.action === "delete") {
89
- assert(path.length === 0, "'Delete' cannot have a path");
90
- this.#deleteBox(key);
91
98
  }
92
99
  }
93
- }
94
- try {
95
100
  this.#ignoreUpdates = true;
96
101
  this.#boxGraph.endTransaction();
97
102
  this.#ignoreUpdates = false;
98
- // TODO Only in DEV-MODE
99
- // this.#boxGraph.verifyPointers()
100
- }
101
- catch (reason) {
102
- this.terminate();
103
- return panic(reason);
103
+ });
104
+ if (result.status === "failure") {
105
+ this.#ignoreUpdates = false;
106
+ if (this.#boxGraph.inTransaction()) {
107
+ this.#boxGraph.abortTransaction();
108
+ }
109
+ console.warn(`[YSync] Transaction rejected, rolling back:`, result.error);
110
+ this.#rollbackTransaction(events);
111
+ return;
104
112
  }
105
113
  const highLevelConflict = this.#conflict.mapOr(check => check(), false);
106
114
  if (highLevelConflict) {
@@ -137,13 +145,9 @@ export class YSync {
137
145
  return path.slice(prefix.length);
138
146
  }
139
147
  #updateValue(path, key) {
140
- console.debug(`#updateValue`, path, key);
141
- const vertexOption = this.#boxGraph.findVertex(YMapper.pathToAddress(path, key));
142
- if (vertexOption.isEmpty()) {
143
- console.debug(`Vertex at '${path}' does not exist. Ignoring.`);
144
- return;
145
- }
146
- const vertex = vertexOption.unwrap("Could not find field");
148
+ const address = YMapper.pathToAddress(path, key);
149
+ const vertex = this.#boxGraph.findVertex(address)
150
+ .unwrap(`Vertex at '${address.toString()}' does not exist.`);
147
151
  const [uuidAsString, fieldsKey, ...fieldKeys] = path;
148
152
  const targetMap = YMapper.findMap(this.#boxes
149
153
  .get(String(uuidAsString))
@@ -158,17 +162,11 @@ export class YSync {
158
162
  });
159
163
  }
160
164
  #deleteBox(key) {
161
- const optBox = this.#boxGraph.findBox(UUID.parse(key));
162
- if (optBox.isEmpty()) {
163
- console.debug(`Box '${key}' has already been deleted. Ignoring.`);
164
- }
165
- else {
166
- const box = optBox.unwrap();
167
- // It is possible that Yjs have swallowed the pointer releases since they were 'inside' the box.
168
- box.outgoingEdges().forEach(([pointer]) => pointer.defer());
169
- box.incomingEdges().forEach(pointer => pointer.defer());
170
- this.#boxGraph.unstageBox(box);
171
- }
165
+ const box = this.#boxGraph.findBox(UUID.parse(key))
166
+ .unwrap(`Box '${key}' does not exist.`);
167
+ box.outgoingEdges().forEach(([pointer]) => pointer.defer());
168
+ box.incomingEdges().forEach(pointer => pointer.defer());
169
+ this.#boxGraph.unstageBox(box);
172
170
  }
173
171
  #rollbackTransaction(events) {
174
172
  console.debug(`rollback ${events.length} events...`);
@@ -202,51 +200,96 @@ export class YSync {
202
200
  #setupOpenDAW() {
203
201
  return Terminable.many(this.#boxGraph.subscribeTransaction({
204
202
  onBeginTransaction: EmptyExec,
205
- onEndTransaction: () => {
206
- if (this.#ignoreUpdates) {
207
- this.#updates.length = 0;
203
+ onEndTransaction: (rolledBack) => {
204
+ const pending = this.#updates.splice(0);
205
+ if (this.#ignoreUpdates || rolledBack) {
208
206
  return;
209
207
  }
210
- this.#getDoc().transact(() => this.#updates.forEach(update => {
211
- /**
212
- * TRANSFER CHANGES FROM OPENDAW TO YJS
213
- */
214
- if (update.type === "new") {
215
- const uuid = update.uuid;
216
- const key = UUID.toString(uuid);
217
- const box = this.#boxGraph.findBox(uuid).unwrap();
218
- this.#boxes.set(key, YMapper.createBoxMap(box));
219
- }
220
- else if (update.type === "primitive") {
221
- const key = UUID.toString(update.address.uuid);
222
- const boxObject = asDefined(this.#boxes.get(key), "Could not find box");
223
- const { address: { fieldKeys }, newValue } = update;
224
- let field = boxObject.get("fields");
225
- for (let i = 0; i < fieldKeys.length - 1; i++) {
226
- field = asDefined(field.get(String(fieldKeys[i])), `No field at '${fieldKeys[i]}'`);
227
- }
228
- field.set(String(fieldKeys[fieldKeys.length - 1]), newValue);
229
- }
230
- else if (update.type === "pointer") {
231
- const key = UUID.toString(update.address.uuid);
232
- const boxObject = asDefined(this.#boxes.get(key), "Could not find box");
233
- const { address: { fieldKeys }, newAddress } = update;
234
- let field = boxObject.get("fields");
235
- for (let i = 0; i < fieldKeys.length - 1; i++) {
236
- field = asDefined(field.get(String(fieldKeys[i])), `No field at '${fieldKeys[i]}'`);
237
- }
238
- field.set(String(fieldKeys[fieldKeys.length - 1]), newAddress.mapOr(address => address.toString(), null));
239
- }
240
- else if (update.type === "delete") {
241
- this.#boxes.delete(UUID.toString(update.uuid));
242
- }
243
- }), "[openDAW] updates");
244
- this.#updates.length = 0;
208
+ const optimized = optimizeUpdates(pending);
209
+ if (optimized.length === 0) {
210
+ return;
211
+ }
212
+ const result = tryCatch(() => this.#getDoc()
213
+ .transact(() => optimized.forEach(update => this.#applyUpdate(update)), "[openDAW] updates"));
214
+ if (result.status === "failure") {
215
+ console.error("[YSync] flush failed, dropping updates", {
216
+ count: optimized.length,
217
+ error: result.error
218
+ });
219
+ throw result.error;
220
+ }
245
221
  }
246
222
  }), this.#boxGraph.subscribeToAllUpdatesImmediate({
247
223
  onUpdate: (update) => this.#updates.push(update)
248
224
  }));
249
225
  }
226
+ /**
227
+ * TRANSFER ONE CHANGE FROM OPENDAW TO YJS
228
+ */
229
+ #applyUpdate(update) {
230
+ if (update.type === "new") {
231
+ const uuid = update.uuid;
232
+ const key = UUID.toString(uuid);
233
+ const optBox = this.#boxGraph.findBox(uuid);
234
+ if (optBox.isEmpty()) {
235
+ // Phantom box: created and removed in same transaction.
236
+ // optimizeUpdates should have filtered this, but guard in case.
237
+ return;
238
+ }
239
+ this.#boxes.set(key, YMapper.createBoxMap(optBox.unwrap()));
240
+ }
241
+ else if (update.type === "primitive") {
242
+ const key = UUID.toString(update.address.uuid);
243
+ const boxObject = this.#boxes.get(key);
244
+ if (!isInstanceOf(boxObject, Y.Map)) {
245
+ console.warn(`[YSync] primitive update skipped: box '${key}' missing`);
246
+ return;
247
+ }
248
+ const field = this.#resolveFieldMap(boxObject, key, update.address.fieldKeys);
249
+ if (field === undefined) {
250
+ return;
251
+ }
252
+ field.set(String(update.address.fieldKeys[update.address.fieldKeys.length - 1]), update.newValue);
253
+ }
254
+ else if (update.type === "pointer") {
255
+ const key = UUID.toString(update.address.uuid);
256
+ const boxObject = this.#boxes.get(key);
257
+ if (!isInstanceOf(boxObject, Y.Map)) {
258
+ console.warn(`[YSync] pointer update skipped: box '${key}' missing`);
259
+ return;
260
+ }
261
+ const field = this.#resolveFieldMap(boxObject, key, update.address.fieldKeys);
262
+ if (field === undefined) {
263
+ return;
264
+ }
265
+ field.set(String(update.address.fieldKeys[update.address.fieldKeys.length - 1]), update.newAddress.mapOr(address => address.toString(), null));
266
+ }
267
+ else if (update.type === "delete") {
268
+ this.#boxes.delete(UUID.toString(update.uuid));
269
+ }
270
+ }
271
+ /**
272
+ * Walks from the box map down to the Y.Map that owns `fieldKeys[last]`.
273
+ * Returns `undefined` (with a warning) instead of throwing if the path
274
+ * cannot be resolved — protects the yjs transaction from partial writes.
275
+ */
276
+ #resolveFieldMap(boxObject, key, fieldKeys) {
277
+ const fieldsValue = boxObject.get("fields");
278
+ if (!isInstanceOf(fieldsValue, Y.Map)) {
279
+ console.warn(`[YSync] box '${key}' missing 'fields' Y.Map; skipping update`);
280
+ return undefined;
281
+ }
282
+ let field = fieldsValue;
283
+ for (let i = 0; i < fieldKeys.length - 1; i++) {
284
+ const next = field.get(String(fieldKeys[i]));
285
+ if (!isInstanceOf(next, Y.Map)) {
286
+ console.warn(`[YSync] box '${key}' field path broken at '${fieldKeys[i]}'; skipping update`);
287
+ return undefined;
288
+ }
289
+ field = next;
290
+ }
291
+ return field;
292
+ }
250
293
  #getDoc() {
251
294
  return asDefined(this.#boxes.doc, "Y.Map is not connect to Y.Doc");
252
295
  }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=YSync.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"YSync.test.d.ts","sourceRoot":"","sources":["../../src/ysync/YSync.test.ts"],"names":[],"mappings":""}