js-bao 0.4.1 → 0.5.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/dist/browser.cjs CHANGED
@@ -2572,6 +2572,9 @@ var init_BaseModel = __esm({
2572
2572
  static connectedDocuments = /* @__PURE__ */ new Map();
2573
2573
  static documentYMaps = /* @__PURE__ */ new Map();
2574
2574
  // Maps docId to YMap for that document
2575
+ // Tracks nested-Y.Map stringset fields we've already attached observers to,
2576
+ // so attachStringSetObserversToRecord() is idempotent across re-entry.
2577
+ static _observedStringSetMaps = /* @__PURE__ */ new WeakSet();
2575
2578
  // Copy-on-write state management
2576
2579
  _localChanges = null;
2577
2580
  _isDirty = false;
@@ -3031,7 +3034,7 @@ var init_BaseModel = __esm({
3031
3034
  validateBeforeSave() {
3032
3035
  const schema = this.constructor.getSchema();
3033
3036
  for (const [fieldKey, fieldOptions] of schema.fields.entries()) {
3034
- const currentValue = this.getValue(fieldKey);
3037
+ const currentValue = fieldKey === "id" ? this.id : this.getValue(fieldKey);
3035
3038
  if (fieldOptions.required && (currentValue === null || currentValue === void 0)) {
3036
3039
  throw new Error(`Field ${fieldKey} is required before save`);
3037
3040
  }
@@ -3091,16 +3094,15 @@ var init_BaseModel = __esm({
3091
3094
  this._isDirty = true;
3092
3095
  }
3093
3096
  getStringSetCurrentValues(fieldName) {
3094
- const yjsData = this.getFromYjs(fieldName);
3095
- if (yjsData && typeof yjsData === "object") {
3096
- return Object.keys(yjsData);
3097
- }
3098
- return [];
3097
+ return this.getStringSetFromYjs(fieldName);
3099
3098
  }
3100
3099
  getStringSetFromYjs(fieldName) {
3101
3100
  const yjsData = this.getFromYjs(fieldName);
3101
+ if (yjsData instanceof Y2.Map) {
3102
+ return Array.from(yjsData.keys());
3103
+ }
3102
3104
  if (yjsData && typeof yjsData === "object") {
3103
- return Object.keys(yjsData);
3105
+ return Object.keys(yjsData).filter((k) => Boolean(yjsData[k]));
3104
3106
  }
3105
3107
  return [];
3106
3108
  }
@@ -3711,7 +3713,7 @@ var init_BaseModel = __esm({
3711
3713
  Logger.debug(
3712
3714
  `[_diffWithYjsData] No unsaved changes, returning empty diff`
3713
3715
  );
3714
- return { added: {}, modified: {}, removed: [] };
3716
+ return { added: {}, modified: {}, removed: [], stringSetChanges: {} };
3715
3717
  }
3716
3718
  const modelConstructor = this.constructor;
3717
3719
  const schema = modelConstructor.getSchema();
@@ -3742,6 +3744,7 @@ var init_BaseModel = __esm({
3742
3744
  const added = {};
3743
3745
  const modified = {};
3744
3746
  const removed = [];
3747
+ const stringSetChanges = {};
3745
3748
  if (!recordYMap) {
3746
3749
  Logger.debug(
3747
3750
  `[_diffWithYjsData] No existing recordYMap, treating all local changes as 'added'`
@@ -3750,11 +3753,12 @@ var init_BaseModel = __esm({
3750
3753
  for (const [key, value] of Object.entries(this._localChanges)) {
3751
3754
  Logger.debug(`[_diffWithYjsData] Adding field '${key}': ${value}`);
3752
3755
  if (value && value.type === "stringset") {
3753
- const stringSetData = {};
3754
- for (const addition of value.additions) {
3755
- stringSetData[addition] = true;
3756
+ if (value.additions.size > 0 || value.removals.size > 0) {
3757
+ stringSetChanges[key] = {
3758
+ additions: value.additions,
3759
+ removals: value.removals
3760
+ };
3756
3761
  }
3757
- added[key] = stringSetData;
3758
3762
  } else {
3759
3763
  added[key] = value;
3760
3764
  }
@@ -3763,9 +3767,10 @@ var init_BaseModel = __esm({
3763
3767
  Logger.debug(`[_diffWithYjsData] Final diff for new record:`, {
3764
3768
  added,
3765
3769
  modified: {},
3766
- removed: []
3770
+ removed: [],
3771
+ stringSetChanges
3767
3772
  });
3768
- return { added, modified, removed: [] };
3773
+ return { added, modified, removed: [], stringSetChanges };
3769
3774
  }
3770
3775
  Logger.debug(
3771
3776
  `[_diffWithYjsData] Existing record found, comparing local changes with Y.js data`
@@ -3782,21 +3787,11 @@ var init_BaseModel = __esm({
3782
3787
  `[_diffWithYjsData] Processing field '${key}' with local value: ${localValue}`
3783
3788
  );
3784
3789
  if (localValue && localValue.type === "stringset") {
3785
- const currentYjsData = recordYMap.get(key) || {};
3786
- const newStringSetData = {
3787
- ...currentYjsData
3788
- };
3789
- for (const addition of localValue.additions) {
3790
- newStringSetData[addition] = true;
3791
- }
3792
- for (const removal of localValue.removals) {
3793
- delete newStringSetData[removal];
3794
- }
3795
- const yjsValue = recordYMap.get(key);
3796
- if (yjsValue === void 0) {
3797
- added[key] = newStringSetData;
3798
- } else if (!this._deepEqual(yjsValue, newStringSetData)) {
3799
- modified[key] = newStringSetData;
3790
+ if (localValue.additions.size > 0 || localValue.removals.size > 0) {
3791
+ stringSetChanges[key] = {
3792
+ additions: localValue.additions,
3793
+ removals: localValue.removals
3794
+ };
3800
3795
  }
3801
3796
  } else {
3802
3797
  const yjsValue = recordYMap.get(key);
@@ -3822,14 +3817,43 @@ var init_BaseModel = __esm({
3822
3817
  Logger.debug(`[_diffWithYjsData] Final diff result:`, {
3823
3818
  added,
3824
3819
  modified,
3825
- removed
3820
+ removed,
3821
+ stringSetChanges
3826
3822
  });
3827
3823
  Logger.verbose(`[${modelConstructor.name}] Diff for ${this.id}:`, {
3828
3824
  added: Object.keys(added),
3829
3825
  modified: Object.keys(modified),
3830
- removed
3826
+ removed,
3827
+ stringSetChanges: Object.keys(stringSetChanges)
3831
3828
  });
3832
- return { added, modified, removed };
3829
+ return { added, modified, removed, stringSetChanges };
3830
+ }
3831
+ /**
3832
+ * Apply a stringset change set to the parent record's Y.Map by mutating a
3833
+ * nested Y.Map keyed by member. Migrates from a legacy plain-object value
3834
+ * if the field hasn't been touched since the wire-format change in #561.
3835
+ */
3836
+ applyStringSetChangeToYMap(recordYMap, fieldName, change) {
3837
+ let nested = recordYMap.get(fieldName);
3838
+ if (!(nested instanceof Y2.Map)) {
3839
+ const migrated = new Y2.Map();
3840
+ if (nested && typeof nested === "object") {
3841
+ for (const [member, marker] of Object.entries(
3842
+ nested
3843
+ )) {
3844
+ if (Boolean(marker)) migrated.set(member, true);
3845
+ }
3846
+ }
3847
+ recordYMap.set(fieldName, migrated);
3848
+ nested = migrated;
3849
+ }
3850
+ const target = nested;
3851
+ for (const member of change.additions) {
3852
+ target.set(member, true);
3853
+ }
3854
+ for (const member of change.removals) {
3855
+ target.delete(member);
3856
+ }
3833
3857
  }
3834
3858
  /**
3835
3859
  * Deep equality check for comparing field values
@@ -4068,7 +4092,7 @@ var init_BaseModel = __esm({
4068
4092
  Logger.debug(`[${modelName}.save] About to calculate diff`);
4069
4093
  const diff = this._diffWithYjsData();
4070
4094
  Logger.debug(`[${modelName}.save] Diff result:`, diff);
4071
- const hasChanges = Object.keys(diff.added).length > 0 || Object.keys(diff.modified).length > 0 || diff.removed.length > 0;
4095
+ const hasChanges = Object.keys(diff.added).length > 0 || Object.keys(diff.modified).length > 0 || diff.removed.length > 0 || Object.keys(diff.stringSetChanges).length > 0;
4072
4096
  Logger.debug(`[${modelName}.save] hasChanges: ${hasChanges}`);
4073
4097
  if (!hasChanges && isUpdate) {
4074
4098
  Logger.verbose(
@@ -4186,6 +4210,16 @@ var init_BaseModel = __esm({
4186
4210
  Logger.debug(`[${modelName}.save] Removing field '${key}'`);
4187
4211
  recordYMap.delete(key);
4188
4212
  }
4213
+ for (const [fieldName, change] of Object.entries(diff.stringSetChanges)) {
4214
+ Logger.debug(
4215
+ `[${modelName}.save] Applying stringset change to field '${fieldName}':`,
4216
+ {
4217
+ additions: Array.from(change.additions),
4218
+ removals: Array.from(change.removals)
4219
+ }
4220
+ );
4221
+ this.applyStringSetChangeToYMap(recordYMap, fieldName, change);
4222
+ }
4189
4223
  Logger.debug(
4190
4224
  `[${modelName}.save] After applying changes, recordYMap contents:`,
4191
4225
  Object.fromEntries(recordYMap.entries())
@@ -5370,6 +5404,39 @@ var init_BaseModel = __esm({
5370
5404
  _BaseModel.prototype.setValue = originalSetValue;
5371
5405
  }
5372
5406
  }
5407
+ /**
5408
+ * Attach a one-shot observer to a nested-Y.Map stringset field. Calls
5409
+ * notifyListeners() when the nested map's keys change (i.e. when a remote
5410
+ * member arrives via Y.applyUpdate, or a local per-member set/delete).
5411
+ * Idempotent — re-calling on the same Y.Map is a no-op.
5412
+ */
5413
+ static observeStringSetMapOnce(nestedMap) {
5414
+ if (_BaseModel._observedStringSetMaps.has(nestedMap)) return;
5415
+ _BaseModel._observedStringSetMaps.add(nestedMap);
5416
+ const modelConstructor = this;
5417
+ nestedMap.observe(() => {
5418
+ Logger.verbose(
5419
+ `[${modelConstructor.name}] Nested stringset Y.Map changed; notifying listeners`
5420
+ );
5421
+ modelConstructor.notifyListeners();
5422
+ });
5423
+ }
5424
+ /**
5425
+ * Walk the schema's stringset fields on `recordYMap` and observe any nested
5426
+ * Y.Map values. Called from observer setup and from the parent observer body
5427
+ * so newly-arrived stringset Y.Maps (local migration or remote create) get
5428
+ * an observer too.
5429
+ */
5430
+ static attachStringSetObserversToRecord(recordYMap, schema) {
5431
+ if (!schema?.fields) return;
5432
+ for (const [fieldKey, fieldOptions] of schema.fields) {
5433
+ if (fieldOptions?.type !== "stringset") continue;
5434
+ const value = recordYMap.get(fieldKey);
5435
+ if (value instanceof Y2.Map) {
5436
+ this.observeStringSetMapOnce(value);
5437
+ }
5438
+ }
5439
+ }
5373
5440
  /**
5374
5441
  * Sets up deep observation on a nested YMap to sync field-level changes to the database
5375
5442
  */
@@ -5386,11 +5453,13 @@ var init_BaseModel = __esm({
5386
5453
  Logger.verbose(
5387
5454
  `[${modelName}] Setting up nested YMap observer for record ${recordId}`
5388
5455
  );
5456
+ modelConstructor.attachStringSetObserversToRecord(recordYMap, schema);
5389
5457
  recordYMap.observe(async (event) => {
5390
5458
  Logger.verbose(
5391
5459
  `[${modelName}] Nested YMap change detected for record ${recordId}:`,
5392
5460
  event
5393
5461
  );
5462
+ modelConstructor.attachStringSetObserversToRecord(recordYMap, schema);
5394
5463
  const currentDbInstance = _BaseModel.dbInstance;
5395
5464
  if (!currentDbInstance) {
5396
5465
  Logger.error(
@@ -5464,11 +5533,13 @@ var init_BaseModel = __esm({
5464
5533
  Logger.verbose(
5465
5534
  `[${modelName}] Setting up nested YMap observer for record ${recordId} in document ${docId}`
5466
5535
  );
5536
+ modelConstructor.attachStringSetObserversToRecord(recordYMap, schema);
5467
5537
  recordYMap.observe(async (event) => {
5468
5538
  Logger.verbose(
5469
5539
  `[${modelName}] Nested YMap change detected for record ${recordId} in document ${docId}:`,
5470
5540
  event
5471
5541
  );
5542
+ modelConstructor.attachStringSetObserversToRecord(recordYMap, schema);
5472
5543
  const currentDbInstance = _BaseModel.dbInstance;
5473
5544
  if (!currentDbInstance) {
5474
5545
  Logger.error(
@@ -454,6 +454,7 @@ declare class BaseModel implements StringSetChangeTracker {
454
454
  permissionHint: DocumentPermissionHint;
455
455
  }>;
456
456
  protected static documentYMaps: Map<string, Y.Map<any>>;
457
+ private static _observedStringSetMaps;
457
458
  private _localChanges;
458
459
  private _isDirty;
459
460
  private _isLoadingFromYjs;
@@ -528,7 +529,17 @@ declare class BaseModel implements StringSetChangeTracker {
528
529
  added: Record<string, any>;
529
530
  modified: Record<string, any>;
530
531
  removed: string[];
532
+ stringSetChanges: Record<string, {
533
+ additions: Set<string>;
534
+ removals: Set<string>;
535
+ }>;
531
536
  };
537
+ /**
538
+ * Apply a stringset change set to the parent record's Y.Map by mutating a
539
+ * nested Y.Map keyed by member. Migrates from a legacy plain-object value
540
+ * if the field hasn't been touched since the wire-format change in #561.
541
+ */
542
+ private applyStringSetChangeToYMap;
532
543
  /**
533
544
  * Deep equality check for comparing field values
534
545
  */
@@ -630,6 +641,20 @@ declare class BaseModel implements StringSetChangeTracker {
630
641
  * Execute a callback with automatic transaction handling for all modified models
631
642
  */
632
643
  static withTransaction<T>(callback: () => Promise<T> | T): Promise<T>;
644
+ /**
645
+ * Attach a one-shot observer to a nested-Y.Map stringset field. Calls
646
+ * notifyListeners() when the nested map's keys change (i.e. when a remote
647
+ * member arrives via Y.applyUpdate, or a local per-member set/delete).
648
+ * Idempotent — re-calling on the same Y.Map is a no-op.
649
+ */
650
+ protected static observeStringSetMapOnce(nestedMap: Y.Map<any>): void;
651
+ /**
652
+ * Walk the schema's stringset fields on `recordYMap` and observe any nested
653
+ * Y.Map values. Called from observer setup and from the parent observer body
654
+ * so newly-arrived stringset Y.Maps (local migration or remote create) get
655
+ * an observer too.
656
+ */
657
+ protected static attachStringSetObserversToRecord(recordYMap: Y.Map<any>, schema: any): void;
633
658
  /**
634
659
  * Sets up deep observation on a nested YMap to sync field-level changes to the database
635
660
  */
package/dist/browser.d.ts CHANGED
@@ -454,6 +454,7 @@ declare class BaseModel implements StringSetChangeTracker {
454
454
  permissionHint: DocumentPermissionHint;
455
455
  }>;
456
456
  protected static documentYMaps: Map<string, Y.Map<any>>;
457
+ private static _observedStringSetMaps;
457
458
  private _localChanges;
458
459
  private _isDirty;
459
460
  private _isLoadingFromYjs;
@@ -528,7 +529,17 @@ declare class BaseModel implements StringSetChangeTracker {
528
529
  added: Record<string, any>;
529
530
  modified: Record<string, any>;
530
531
  removed: string[];
532
+ stringSetChanges: Record<string, {
533
+ additions: Set<string>;
534
+ removals: Set<string>;
535
+ }>;
531
536
  };
537
+ /**
538
+ * Apply a stringset change set to the parent record's Y.Map by mutating a
539
+ * nested Y.Map keyed by member. Migrates from a legacy plain-object value
540
+ * if the field hasn't been touched since the wire-format change in #561.
541
+ */
542
+ private applyStringSetChangeToYMap;
532
543
  /**
533
544
  * Deep equality check for comparing field values
534
545
  */
@@ -630,6 +641,20 @@ declare class BaseModel implements StringSetChangeTracker {
630
641
  * Execute a callback with automatic transaction handling for all modified models
631
642
  */
632
643
  static withTransaction<T>(callback: () => Promise<T> | T): Promise<T>;
644
+ /**
645
+ * Attach a one-shot observer to a nested-Y.Map stringset field. Calls
646
+ * notifyListeners() when the nested map's keys change (i.e. when a remote
647
+ * member arrives via Y.applyUpdate, or a local per-member set/delete).
648
+ * Idempotent — re-calling on the same Y.Map is a no-op.
649
+ */
650
+ protected static observeStringSetMapOnce(nestedMap: Y.Map<any>): void;
651
+ /**
652
+ * Walk the schema's stringset fields on `recordYMap` and observe any nested
653
+ * Y.Map values. Called from observer setup and from the parent observer body
654
+ * so newly-arrived stringset Y.Maps (local migration or remote create) get
655
+ * an observer too.
656
+ */
657
+ protected static attachStringSetObserversToRecord(recordYMap: Y.Map<any>, schema: any): void;
633
658
  /**
634
659
  * Sets up deep observation on a nested YMap to sync field-level changes to the database
635
660
  */
package/dist/browser.js CHANGED
@@ -2550,6 +2550,9 @@ var init_BaseModel = __esm({
2550
2550
  static connectedDocuments = /* @__PURE__ */ new Map();
2551
2551
  static documentYMaps = /* @__PURE__ */ new Map();
2552
2552
  // Maps docId to YMap for that document
2553
+ // Tracks nested-Y.Map stringset fields we've already attached observers to,
2554
+ // so attachStringSetObserversToRecord() is idempotent across re-entry.
2555
+ static _observedStringSetMaps = /* @__PURE__ */ new WeakSet();
2553
2556
  // Copy-on-write state management
2554
2557
  _localChanges = null;
2555
2558
  _isDirty = false;
@@ -3009,7 +3012,7 @@ var init_BaseModel = __esm({
3009
3012
  validateBeforeSave() {
3010
3013
  const schema = this.constructor.getSchema();
3011
3014
  for (const [fieldKey, fieldOptions] of schema.fields.entries()) {
3012
- const currentValue = this.getValue(fieldKey);
3015
+ const currentValue = fieldKey === "id" ? this.id : this.getValue(fieldKey);
3013
3016
  if (fieldOptions.required && (currentValue === null || currentValue === void 0)) {
3014
3017
  throw new Error(`Field ${fieldKey} is required before save`);
3015
3018
  }
@@ -3069,16 +3072,15 @@ var init_BaseModel = __esm({
3069
3072
  this._isDirty = true;
3070
3073
  }
3071
3074
  getStringSetCurrentValues(fieldName) {
3072
- const yjsData = this.getFromYjs(fieldName);
3073
- if (yjsData && typeof yjsData === "object") {
3074
- return Object.keys(yjsData);
3075
- }
3076
- return [];
3075
+ return this.getStringSetFromYjs(fieldName);
3077
3076
  }
3078
3077
  getStringSetFromYjs(fieldName) {
3079
3078
  const yjsData = this.getFromYjs(fieldName);
3079
+ if (yjsData instanceof Y2.Map) {
3080
+ return Array.from(yjsData.keys());
3081
+ }
3080
3082
  if (yjsData && typeof yjsData === "object") {
3081
- return Object.keys(yjsData);
3083
+ return Object.keys(yjsData).filter((k) => Boolean(yjsData[k]));
3082
3084
  }
3083
3085
  return [];
3084
3086
  }
@@ -3689,7 +3691,7 @@ var init_BaseModel = __esm({
3689
3691
  Logger.debug(
3690
3692
  `[_diffWithYjsData] No unsaved changes, returning empty diff`
3691
3693
  );
3692
- return { added: {}, modified: {}, removed: [] };
3694
+ return { added: {}, modified: {}, removed: [], stringSetChanges: {} };
3693
3695
  }
3694
3696
  const modelConstructor = this.constructor;
3695
3697
  const schema = modelConstructor.getSchema();
@@ -3720,6 +3722,7 @@ var init_BaseModel = __esm({
3720
3722
  const added = {};
3721
3723
  const modified = {};
3722
3724
  const removed = [];
3725
+ const stringSetChanges = {};
3723
3726
  if (!recordYMap) {
3724
3727
  Logger.debug(
3725
3728
  `[_diffWithYjsData] No existing recordYMap, treating all local changes as 'added'`
@@ -3728,11 +3731,12 @@ var init_BaseModel = __esm({
3728
3731
  for (const [key, value] of Object.entries(this._localChanges)) {
3729
3732
  Logger.debug(`[_diffWithYjsData] Adding field '${key}': ${value}`);
3730
3733
  if (value && value.type === "stringset") {
3731
- const stringSetData = {};
3732
- for (const addition of value.additions) {
3733
- stringSetData[addition] = true;
3734
+ if (value.additions.size > 0 || value.removals.size > 0) {
3735
+ stringSetChanges[key] = {
3736
+ additions: value.additions,
3737
+ removals: value.removals
3738
+ };
3734
3739
  }
3735
- added[key] = stringSetData;
3736
3740
  } else {
3737
3741
  added[key] = value;
3738
3742
  }
@@ -3741,9 +3745,10 @@ var init_BaseModel = __esm({
3741
3745
  Logger.debug(`[_diffWithYjsData] Final diff for new record:`, {
3742
3746
  added,
3743
3747
  modified: {},
3744
- removed: []
3748
+ removed: [],
3749
+ stringSetChanges
3745
3750
  });
3746
- return { added, modified, removed: [] };
3751
+ return { added, modified, removed: [], stringSetChanges };
3747
3752
  }
3748
3753
  Logger.debug(
3749
3754
  `[_diffWithYjsData] Existing record found, comparing local changes with Y.js data`
@@ -3760,21 +3765,11 @@ var init_BaseModel = __esm({
3760
3765
  `[_diffWithYjsData] Processing field '${key}' with local value: ${localValue}`
3761
3766
  );
3762
3767
  if (localValue && localValue.type === "stringset") {
3763
- const currentYjsData = recordYMap.get(key) || {};
3764
- const newStringSetData = {
3765
- ...currentYjsData
3766
- };
3767
- for (const addition of localValue.additions) {
3768
- newStringSetData[addition] = true;
3769
- }
3770
- for (const removal of localValue.removals) {
3771
- delete newStringSetData[removal];
3772
- }
3773
- const yjsValue = recordYMap.get(key);
3774
- if (yjsValue === void 0) {
3775
- added[key] = newStringSetData;
3776
- } else if (!this._deepEqual(yjsValue, newStringSetData)) {
3777
- modified[key] = newStringSetData;
3768
+ if (localValue.additions.size > 0 || localValue.removals.size > 0) {
3769
+ stringSetChanges[key] = {
3770
+ additions: localValue.additions,
3771
+ removals: localValue.removals
3772
+ };
3778
3773
  }
3779
3774
  } else {
3780
3775
  const yjsValue = recordYMap.get(key);
@@ -3800,14 +3795,43 @@ var init_BaseModel = __esm({
3800
3795
  Logger.debug(`[_diffWithYjsData] Final diff result:`, {
3801
3796
  added,
3802
3797
  modified,
3803
- removed
3798
+ removed,
3799
+ stringSetChanges
3804
3800
  });
3805
3801
  Logger.verbose(`[${modelConstructor.name}] Diff for ${this.id}:`, {
3806
3802
  added: Object.keys(added),
3807
3803
  modified: Object.keys(modified),
3808
- removed
3804
+ removed,
3805
+ stringSetChanges: Object.keys(stringSetChanges)
3809
3806
  });
3810
- return { added, modified, removed };
3807
+ return { added, modified, removed, stringSetChanges };
3808
+ }
3809
+ /**
3810
+ * Apply a stringset change set to the parent record's Y.Map by mutating a
3811
+ * nested Y.Map keyed by member. Migrates from a legacy plain-object value
3812
+ * if the field hasn't been touched since the wire-format change in #561.
3813
+ */
3814
+ applyStringSetChangeToYMap(recordYMap, fieldName, change) {
3815
+ let nested = recordYMap.get(fieldName);
3816
+ if (!(nested instanceof Y2.Map)) {
3817
+ const migrated = new Y2.Map();
3818
+ if (nested && typeof nested === "object") {
3819
+ for (const [member, marker] of Object.entries(
3820
+ nested
3821
+ )) {
3822
+ if (Boolean(marker)) migrated.set(member, true);
3823
+ }
3824
+ }
3825
+ recordYMap.set(fieldName, migrated);
3826
+ nested = migrated;
3827
+ }
3828
+ const target = nested;
3829
+ for (const member of change.additions) {
3830
+ target.set(member, true);
3831
+ }
3832
+ for (const member of change.removals) {
3833
+ target.delete(member);
3834
+ }
3811
3835
  }
3812
3836
  /**
3813
3837
  * Deep equality check for comparing field values
@@ -4046,7 +4070,7 @@ var init_BaseModel = __esm({
4046
4070
  Logger.debug(`[${modelName}.save] About to calculate diff`);
4047
4071
  const diff = this._diffWithYjsData();
4048
4072
  Logger.debug(`[${modelName}.save] Diff result:`, diff);
4049
- const hasChanges = Object.keys(diff.added).length > 0 || Object.keys(diff.modified).length > 0 || diff.removed.length > 0;
4073
+ const hasChanges = Object.keys(diff.added).length > 0 || Object.keys(diff.modified).length > 0 || diff.removed.length > 0 || Object.keys(diff.stringSetChanges).length > 0;
4050
4074
  Logger.debug(`[${modelName}.save] hasChanges: ${hasChanges}`);
4051
4075
  if (!hasChanges && isUpdate) {
4052
4076
  Logger.verbose(
@@ -4164,6 +4188,16 @@ var init_BaseModel = __esm({
4164
4188
  Logger.debug(`[${modelName}.save] Removing field '${key}'`);
4165
4189
  recordYMap.delete(key);
4166
4190
  }
4191
+ for (const [fieldName, change] of Object.entries(diff.stringSetChanges)) {
4192
+ Logger.debug(
4193
+ `[${modelName}.save] Applying stringset change to field '${fieldName}':`,
4194
+ {
4195
+ additions: Array.from(change.additions),
4196
+ removals: Array.from(change.removals)
4197
+ }
4198
+ );
4199
+ this.applyStringSetChangeToYMap(recordYMap, fieldName, change);
4200
+ }
4167
4201
  Logger.debug(
4168
4202
  `[${modelName}.save] After applying changes, recordYMap contents:`,
4169
4203
  Object.fromEntries(recordYMap.entries())
@@ -5348,6 +5382,39 @@ var init_BaseModel = __esm({
5348
5382
  _BaseModel.prototype.setValue = originalSetValue;
5349
5383
  }
5350
5384
  }
5385
+ /**
5386
+ * Attach a one-shot observer to a nested-Y.Map stringset field. Calls
5387
+ * notifyListeners() when the nested map's keys change (i.e. when a remote
5388
+ * member arrives via Y.applyUpdate, or a local per-member set/delete).
5389
+ * Idempotent — re-calling on the same Y.Map is a no-op.
5390
+ */
5391
+ static observeStringSetMapOnce(nestedMap) {
5392
+ if (_BaseModel._observedStringSetMaps.has(nestedMap)) return;
5393
+ _BaseModel._observedStringSetMaps.add(nestedMap);
5394
+ const modelConstructor = this;
5395
+ nestedMap.observe(() => {
5396
+ Logger.verbose(
5397
+ `[${modelConstructor.name}] Nested stringset Y.Map changed; notifying listeners`
5398
+ );
5399
+ modelConstructor.notifyListeners();
5400
+ });
5401
+ }
5402
+ /**
5403
+ * Walk the schema's stringset fields on `recordYMap` and observe any nested
5404
+ * Y.Map values. Called from observer setup and from the parent observer body
5405
+ * so newly-arrived stringset Y.Maps (local migration or remote create) get
5406
+ * an observer too.
5407
+ */
5408
+ static attachStringSetObserversToRecord(recordYMap, schema) {
5409
+ if (!schema?.fields) return;
5410
+ for (const [fieldKey, fieldOptions] of schema.fields) {
5411
+ if (fieldOptions?.type !== "stringset") continue;
5412
+ const value = recordYMap.get(fieldKey);
5413
+ if (value instanceof Y2.Map) {
5414
+ this.observeStringSetMapOnce(value);
5415
+ }
5416
+ }
5417
+ }
5351
5418
  /**
5352
5419
  * Sets up deep observation on a nested YMap to sync field-level changes to the database
5353
5420
  */
@@ -5364,11 +5431,13 @@ var init_BaseModel = __esm({
5364
5431
  Logger.verbose(
5365
5432
  `[${modelName}] Setting up nested YMap observer for record ${recordId}`
5366
5433
  );
5434
+ modelConstructor.attachStringSetObserversToRecord(recordYMap, schema);
5367
5435
  recordYMap.observe(async (event) => {
5368
5436
  Logger.verbose(
5369
5437
  `[${modelName}] Nested YMap change detected for record ${recordId}:`,
5370
5438
  event
5371
5439
  );
5440
+ modelConstructor.attachStringSetObserversToRecord(recordYMap, schema);
5372
5441
  const currentDbInstance = _BaseModel.dbInstance;
5373
5442
  if (!currentDbInstance) {
5374
5443
  Logger.error(
@@ -5442,11 +5511,13 @@ var init_BaseModel = __esm({
5442
5511
  Logger.verbose(
5443
5512
  `[${modelName}] Setting up nested YMap observer for record ${recordId} in document ${docId}`
5444
5513
  );
5514
+ modelConstructor.attachStringSetObserversToRecord(recordYMap, schema);
5445
5515
  recordYMap.observe(async (event) => {
5446
5516
  Logger.verbose(
5447
5517
  `[${modelName}] Nested YMap change detected for record ${recordId} in document ${docId}:`,
5448
5518
  event
5449
5519
  );
5520
+ modelConstructor.attachStringSetObserversToRecord(recordYMap, schema);
5450
5521
  const currentDbInstance = _BaseModel.dbInstance;
5451
5522
  if (!currentDbInstance) {
5452
5523
  Logger.error(
package/dist/client.d.cts CHANGED
@@ -353,6 +353,7 @@ declare class BaseModel implements StringSetChangeTracker {
353
353
  permissionHint: DocumentPermissionHint;
354
354
  }>;
355
355
  protected static documentYMaps: Map<string, Y.Map<any>>;
356
+ private static _observedStringSetMaps;
356
357
  private _localChanges;
357
358
  private _isDirty;
358
359
  private _isLoadingFromYjs;
@@ -427,7 +428,17 @@ declare class BaseModel implements StringSetChangeTracker {
427
428
  added: Record<string, any>;
428
429
  modified: Record<string, any>;
429
430
  removed: string[];
431
+ stringSetChanges: Record<string, {
432
+ additions: Set<string>;
433
+ removals: Set<string>;
434
+ }>;
430
435
  };
436
+ /**
437
+ * Apply a stringset change set to the parent record's Y.Map by mutating a
438
+ * nested Y.Map keyed by member. Migrates from a legacy plain-object value
439
+ * if the field hasn't been touched since the wire-format change in #561.
440
+ */
441
+ private applyStringSetChangeToYMap;
431
442
  /**
432
443
  * Deep equality check for comparing field values
433
444
  */
@@ -529,6 +540,20 @@ declare class BaseModel implements StringSetChangeTracker {
529
540
  * Execute a callback with automatic transaction handling for all modified models
530
541
  */
531
542
  static withTransaction<T>(callback: () => Promise<T> | T): Promise<T>;
543
+ /**
544
+ * Attach a one-shot observer to a nested-Y.Map stringset field. Calls
545
+ * notifyListeners() when the nested map's keys change (i.e. when a remote
546
+ * member arrives via Y.applyUpdate, or a local per-member set/delete).
547
+ * Idempotent — re-calling on the same Y.Map is a no-op.
548
+ */
549
+ protected static observeStringSetMapOnce(nestedMap: Y.Map<any>): void;
550
+ /**
551
+ * Walk the schema's stringset fields on `recordYMap` and observe any nested
552
+ * Y.Map values. Called from observer setup and from the parent observer body
553
+ * so newly-arrived stringset Y.Maps (local migration or remote create) get
554
+ * an observer too.
555
+ */
556
+ protected static attachStringSetObserversToRecord(recordYMap: Y.Map<any>, schema: any): void;
532
557
  /**
533
558
  * Sets up deep observation on a nested YMap to sync field-level changes to the database
534
559
  */