@nyaruka/temba-components 0.170.1 → 0.172.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.
@@ -15,10 +15,60 @@ import { immer } from 'zustand/middleware/immer';
15
15
  import { subscribeWithSelector } from 'zustand/middleware';
16
16
  import { property } from 'lit/decorators.js';
17
17
  import { produce } from 'immer';
18
+ import {
19
+ FlowDependency,
20
+ replaceDependencies,
21
+ resolveDependencyNames
22
+ } from '../flow/dependencies';
18
23
 
19
24
  export const FLOW_SPEC_VERSION = '14.3';
20
25
  const CANVAS_PADDING = 800;
21
26
 
27
+ // how long a revision load will wait on canonical names before rendering the
28
+ // names embedded in the definition, which the editor heals asynchronously
29
+ const DEPENDENCY_RESOLVE_TIMEOUT = 3000;
30
+
31
+ export type DependencyResolver = (
32
+ dependencies: FlowDependency[]
33
+ ) => Promise<FlowDependency[]>;
34
+
35
+ let dependencyResolver: DependencyResolver | null = null;
36
+
37
+ /** Installs the page-level canonical-name resolver and returns the previous
38
+ * resolver so a disconnected store can restore it in tests. */
39
+ export const setDependencyResolver = (
40
+ resolver: DependencyResolver | null
41
+ ): DependencyResolver | null => {
42
+ const previous = dependencyResolver;
43
+ dependencyResolver = resolver;
44
+ return previous;
45
+ };
46
+
47
+ /** The currently installed resolver, so a store can tell whether it is still
48
+ * the owner before restoring the one it replaced. */
49
+ export const getDependencyResolver = (): DependencyResolver | null =>
50
+ dependencyResolver;
51
+
52
+ /** Resolves with null if the given promise hasn't settled in time. */
53
+ const withTimeout = async <T>(
54
+ promise: Promise<T>,
55
+ timeout: number
56
+ ): Promise<T | null> => {
57
+ let timer: any = null;
58
+ try {
59
+ return await Promise.race([
60
+ promise,
61
+ new Promise<null>((resolve) => {
62
+ timer = setTimeout(() => resolve(null), timeout);
63
+ })
64
+ ]);
65
+ } finally {
66
+ if (timer !== null) {
67
+ clearTimeout(timer);
68
+ }
69
+ }
70
+ };
71
+
22
72
  /**
23
73
  * Temporary: Reclassify nodes based on whether they contain terminal actions.
24
74
  * - execute_actions nodes with a terminal action become "terminal"
@@ -105,15 +155,6 @@ export interface InfoResult {
105
155
  node_uuids: string[];
106
156
  }
107
157
 
108
- export interface ObjectRef {
109
- uuid: string;
110
- name: string;
111
- }
112
-
113
- export interface TypedObjectRef extends ObjectRef {
114
- type: string;
115
- }
116
-
117
158
  export interface Language {
118
159
  code: string;
119
160
  name: string;
@@ -133,7 +174,7 @@ export interface FlowIssue {
133
174
 
134
175
  export interface FlowInfo {
135
176
  results: InfoResult[];
136
- dependencies: TypedObjectRef[];
177
+ dependencies: FlowDependency[];
137
178
  counts: { nodes: number; languages: number };
138
179
  locals: string[];
139
180
  issues?: FlowIssue[];
@@ -220,6 +261,7 @@ export interface AppState {
220
261
 
221
262
  setFlowContents: (flow: FlowContents) => void;
222
263
  setFlowInfo: (info: FlowInfo) => void;
264
+ updateDependencyNames: (dependencies: FlowDependency[]) => void;
223
265
  setRevision: (revision: number) => void;
224
266
  setLanguageCode: (languageCode: string) => void;
225
267
  setDirtyDate: (date: Date) => void;
@@ -305,6 +347,31 @@ export const zustand = createStore<AppState>()(
305
347
  throw new Error('Network response was not ok');
306
348
  }
307
349
  const data = (await response.json()) as FlowContents;
350
+ if (dependencyResolver && data.info?.dependencies?.length) {
351
+ try {
352
+ // resolving before the first paint avoids a visible flash of stale
353
+ // names, but a slow endpoint must never hold the editor hostage -
354
+ // past the timeout we render and let the editor's watcher heal it
355
+ const canonical = await withTimeout(
356
+ dependencyResolver(data.info.dependencies),
357
+ DEPENDENCY_RESOLVE_TIMEOUT
358
+ );
359
+ const dependencies = canonical
360
+ ? replaceDependencies(data.info.dependencies, canonical)
361
+ : null;
362
+ if (dependencies) {
363
+ data.info = { ...data.info, dependencies };
364
+ data.definition = resolveDependencyNames(
365
+ data.definition,
366
+ canonical
367
+ );
368
+ }
369
+ } catch (error) {
370
+ // A name lookup shouldn't make an otherwise valid flow unusable.
371
+ // Keep its embedded names and let a later socket/reconnect heal it.
372
+ console.error('failed to resolve flow dependency names', error);
373
+ }
374
+ }
308
375
  reclassifyTerminalNodes(data.definition);
309
376
  reclassifyVoiceWaitNodes(data.definition);
310
377
  const issueMaps = buildIssueMaps(data.info?.issues);
@@ -438,6 +505,26 @@ export const zustand = createStore<AppState>()(
438
505
  });
439
506
  },
440
507
 
508
+ updateDependencyNames: (changed: FlowDependency[]) => {
509
+ set((state: AppState) => {
510
+ if (!state.flowInfo || !state.flowDefinition) {
511
+ return;
512
+ }
513
+ const dependencies = replaceDependencies(
514
+ state.flowInfo.dependencies,
515
+ changed
516
+ );
517
+ if (!dependencies) {
518
+ return;
519
+ }
520
+ state.flowInfo.dependencies = dependencies;
521
+ state.flowDefinition = resolveDependencyNames(
522
+ state.flowDefinition,
523
+ changed
524
+ );
525
+ });
526
+ },
527
+
441
528
  setRevision: (revision: number) => {
442
529
  set((state: AppState) => {
443
530
  state.flowDefinition.revision = revision;
@@ -22,15 +22,28 @@ import {
22
22
  DirtyTrackable
23
23
  } from '../interfaces';
24
24
  import { RapidElement } from '../RapidElement';
25
- import { setRealtimeContext } from '../live/Realtime';
25
+ import {
26
+ RealtimeSubscription,
27
+ setRealtimeContext,
28
+ subscribeToOrganization,
29
+ OrganizationEvent
30
+ } from '../live/Realtime';
31
+ import { Watchers } from '../live/Watchers';
26
32
  import { lru } from 'tiny-lru';
27
33
  import { DateTime } from 'luxon';
28
34
  import { css, html } from 'lit';
29
35
  import { configureLocalization } from '@lit/localize';
30
36
  import { sourceLocale, targetLocales } from '../locales/locale-codes';
31
37
  import { getFullName } from '../display/TembaUser';
32
- import { AppState, zustand } from './AppState';
38
+ import {
39
+ AppState,
40
+ DependencyResolver,
41
+ getDependencyResolver,
42
+ setDependencyResolver,
43
+ zustand
44
+ } from './AppState';
33
45
  import { StoreApi } from 'zustand/vanilla';
46
+ import { normalizeUuid } from './identity';
34
47
 
35
48
  const { setLocale } = configureLocalization({
36
49
  sourceLocale,
@@ -42,6 +55,85 @@ export const getStore = () => {
42
55
  return document.querySelector('temba-store') as Store;
43
56
  };
44
57
 
58
+ export const STORE_ASSET_TYPES = [
59
+ 'channel',
60
+ 'contact',
61
+ 'field',
62
+ 'flow',
63
+ 'global',
64
+ 'group',
65
+ 'label',
66
+ 'llm',
67
+ 'optin',
68
+ 'template',
69
+ 'topic',
70
+ 'user'
71
+ ] as const;
72
+
73
+ export type StoreAssetType = (typeof STORE_ASSET_TYPES)[number];
74
+
75
+ export interface StoreAssetReference {
76
+ type: StoreAssetType;
77
+ uuid?: string;
78
+ key?: string;
79
+ }
80
+
81
+ export interface StoreAsset extends StoreAssetReference {
82
+ name: string;
83
+ }
84
+
85
+ export interface StoreAssetChangedEvent {
86
+ type: 'asset_changed';
87
+ asset: StoreAsset;
88
+ }
89
+
90
+ export type StoreAssetHandler = (event: StoreAssetChangedEvent | null) => void;
91
+
92
+ interface AssetWatcher {
93
+ interests: StoreAssetReference[];
94
+ onEvent: StoreAssetHandler;
95
+ }
96
+
97
+ const STORE_ASSET_TYPE_SET = new Set<string>(STORE_ASSET_TYPES);
98
+
99
+ // the endpoint rejects a request carrying more than this many identifiers
100
+ const ASSET_BATCH_SIZE = 100;
101
+
102
+ // long-lived pages (a flow list paged through many times) would otherwise
103
+ // accumulate an entry for every asset ever seen. Once an entry is evicted the
104
+ // component falls back to the name its own response carried, and the identity
105
+ // is fetched again next time it is asked for.
106
+ export const ASSET_CACHE_SIZE = 500;
107
+
108
+ /**
109
+ * Versions are held well above the asset cache because losing one doesn't just
110
+ * forget a name, it drops the in-flight guard for that identity - and `assets`
111
+ * is bumped by reads that never touch `assetVersions`, so the two ages drift
112
+ * apart. Entries are a single number, so the extra headroom is cheap.
113
+ */
114
+ export const ASSET_VERSION_CACHE_SIZE = ASSET_CACHE_SIZE * 8;
115
+
116
+ const isStoreAssetType = (type: string): type is StoreAssetType =>
117
+ STORE_ASSET_TYPE_SET.has(type);
118
+
119
+ const assetIdentity = (
120
+ asset: { type?: string; uuid?: string; key?: string } | null
121
+ ): string | null => {
122
+ if (!asset || !isStoreAssetType(asset.type)) {
123
+ return null;
124
+ }
125
+ const identity = asset.uuid ? normalizeUuid(asset.uuid) : asset.key;
126
+ return identity ? `${asset.type}:${identity}` : null;
127
+ };
128
+
129
+ const watchesAsset = (watcher: AssetWatcher, asset: StoreAsset): boolean => {
130
+ const identity = assetIdentity(asset);
131
+ return (
132
+ !!identity &&
133
+ watcher.interests.some((interest) => assetIdentity(interest) === identity)
134
+ );
135
+ };
136
+
45
137
  declare const __TEMBA_DEV_SERVER__: boolean;
46
138
 
47
139
  /**
@@ -100,6 +192,9 @@ export class Store extends RapidElement {
100
192
  @property({ type: String, attribute: 'groups' })
101
193
  groupsEndpoint: string;
102
194
 
195
+ @property({ type: String, attribute: 'assets' })
196
+ assetsEndpoint: string;
197
+
103
198
  @property({ type: String, attribute: 'globals' })
104
199
  globalsEndpoint: string;
105
200
 
@@ -136,6 +231,24 @@ export class Store extends RapidElement {
136
231
  private shortcuts: Shortcut[] = [];
137
232
  private workspace: Workspace;
138
233
  private featuredFields: ContactField[] = [];
234
+ private assetWatchers = new Watchers<AssetWatcher>('store asset watcher');
235
+ // canonical names, the identities we've already asked about (including ones
236
+ // the endpoint had no asset for) and their last-write versions, all bounded
237
+ // so a long-lived page doesn't keep every asset it has ever rendered
238
+ private assets = lru<StoreAsset>(ASSET_CACHE_SIZE);
239
+ private resolvedAssetIdentities = lru<boolean>(ASSET_CACHE_SIZE);
240
+ private assetVersions = lru<number>(ASSET_VERSION_CACHE_SIZE);
241
+ private pendingAssetRequests = new Map<string, Promise<void>>();
242
+ private assetVersion = 0;
243
+ // bumped by reset(), which only firstUpdated() calls today - the guard exists
244
+ // so a future caller that resets a live store can't have the cleared cache
245
+ // repopulated by a batch that was already in flight
246
+ private assetGeneration = 0;
247
+ private organizationWatch: RealtimeSubscription = null;
248
+ private organizationSubscribed = false;
249
+ private previousDependencyResolver: DependencyResolver = null;
250
+ private dependencyResolver: DependencyResolver = (dependencies) =>
251
+ this.resolveAssets(dependencies);
139
252
 
140
253
  // http promise to monitor for completeness
141
254
  public initialHttpComplete: Promise<void | WebResponse[]>;
@@ -198,6 +311,13 @@ export class Store extends RapidElement {
198
311
  this.clearCache();
199
312
  this.settings = JSON.parse(getCookie('settings') || '{}');
200
313
  zustand.setState({ brand: this.brand });
314
+ this.groups = {};
315
+ this.assets.clear();
316
+ this.resolvedAssetIdentities.clear();
317
+ this.pendingAssetRequests.clear();
318
+ this.assetVersions.clear();
319
+ this.assetVersion = 0;
320
+ this.assetGeneration++;
201
321
 
202
322
  /*
203
323
  // This will create a shorthand unit
@@ -238,10 +358,10 @@ export class Store extends RapidElement {
238
358
 
239
359
  if (this.groupsEndpoint) {
240
360
  fetches.push(
241
- getAssets(this.groupsEndpoint).then((groups: any[]) => {
242
- groups.forEach((group: any) => {
361
+ getAssets<ContactGroup>(this.groupsEndpoint).then((groups) => {
362
+ for (const group of groups) {
243
363
  this.groups[group.uuid] = group;
244
- });
364
+ }
245
365
  })
246
366
  );
247
367
  }
@@ -274,11 +394,61 @@ export class Store extends RapidElement {
274
394
  return this.shortcuts || [];
275
395
  }
276
396
 
397
+ public connectedCallback(): void {
398
+ super.connectedCallback();
399
+ // lit only runs firstUpdated once, so a store that is detached and
400
+ // re-attached has to reinstall its page hooks here
401
+ if (this.hasUpdated) {
402
+ this.installPageHooks();
403
+ }
404
+ }
405
+
277
406
  public firstUpdated() {
407
+ this.installPageHooks();
408
+ this.reset();
409
+ }
410
+
411
+ /**
412
+ * Installs the hooks this store owns on behalf of the page: the canonical
413
+ * name resolver and the workspace realtime subscription. Idempotent so it
414
+ * can run again when a store is re-attached.
415
+ */
416
+ private installPageHooks(): void {
417
+ if (getDependencyResolver() !== this.dependencyResolver) {
418
+ this.previousDependencyResolver = setDependencyResolver(
419
+ this.dependencyResolver
420
+ );
421
+ }
278
422
  if (this.org && this.user) {
279
423
  setRealtimeContext({ org: this.org, user: this.user });
424
+ if (!this.organizationWatch) {
425
+ this.organizationWatch = subscribeToOrganization(
426
+ (event) => this.handleOrganizationEvent(event),
427
+ () => {
428
+ if (this.organizationSubscribed) {
429
+ this.refreshAssetCache().catch((error) => {
430
+ console.error('failed to refresh store assets', error);
431
+ });
432
+ }
433
+ this.organizationSubscribed = true;
434
+ }
435
+ );
436
+ }
437
+ }
438
+ }
439
+
440
+ public disconnectedCallback(): void {
441
+ super.disconnectedCallback();
442
+ // only hand the resolver back if we're still the installed one, otherwise
443
+ // a store that never installed it would clear the live store's resolver
444
+ if (getDependencyResolver() === this.dependencyResolver) {
445
+ setDependencyResolver(this.previousDependencyResolver);
446
+ }
447
+ this.previousDependencyResolver = null;
448
+ if (this.organizationWatch) {
449
+ this.organizationWatch.unsubscribe();
450
+ this.organizationWatch = null;
280
451
  }
281
- this.reset();
282
452
  }
283
453
 
284
454
  public getLanguageCode() {
@@ -431,6 +601,308 @@ export class Store extends RapidElement {
431
601
  return this.featuredFields;
432
602
  }
433
603
 
604
+ public getAsset(type: string, identity: string): StoreAsset | null {
605
+ if (!isStoreAssetType(type) || !identity) {
606
+ return null;
607
+ }
608
+ // the identity may be a key (cached verbatim, and case-sensitive) or a uuid
609
+ // (cached under its canonical form), so try verbatim first — a canonical
610
+ // uuid already hits on that pass, and keys never reach the normalizing one
611
+ return (
612
+ this.assets.get(`${type}:${identity}`) ||
613
+ this.assets.get(`${type}:${normalizeUuid(identity)}`) ||
614
+ null
615
+ );
616
+ }
617
+
618
+ /** Adds server-authoritative assets to the page cache. Components whose
619
+ * own response already contains canonical names can seed the same cache
620
+ * without making another request. */
621
+ public cacheAssets(assets: StoreAsset[]): void {
622
+ for (const asset of assets) {
623
+ this.cacheAsset(asset);
624
+ }
625
+ }
626
+
627
+ /** Resolves every requested reference from the cache, fetching only the
628
+ * identities this page hasn't resolved before. Concurrent callers share
629
+ * each in-flight batch and absent assets are negatively cached. */
630
+ public async resolveAssets(
631
+ requested: { type: string; uuid?: string; key?: string }[],
632
+ force = false
633
+ ): Promise<StoreAsset[]> {
634
+ const references = new Map<string, StoreAssetReference>();
635
+ for (const candidate of requested || []) {
636
+ if (!isStoreAssetType(candidate.type)) {
637
+ continue;
638
+ }
639
+ const reference: StoreAssetReference = {
640
+ type: candidate.type,
641
+ ...(candidate.uuid ? { uuid: candidate.uuid } : {}),
642
+ ...(candidate.key ? { key: candidate.key } : {})
643
+ };
644
+ const identity = assetIdentity(reference);
645
+ if (identity) {
646
+ references.set(identity, reference);
647
+ }
648
+ }
649
+
650
+ const waits = new Set<Promise<void>>();
651
+ const missing: StoreAssetReference[] = [];
652
+ for (const [identity, reference] of references) {
653
+ const pending = this.pendingAssetRequests.get(identity);
654
+ if (pending) {
655
+ waits.add(pending);
656
+ } else if (force || !this.resolvedAssetIdentities.has(identity)) {
657
+ missing.push(reference);
658
+ }
659
+ }
660
+
661
+ if (this.assetsEndpoint) {
662
+ for (
663
+ let offset = 0;
664
+ offset < missing.length;
665
+ offset += ASSET_BATCH_SIZE
666
+ ) {
667
+ const batch = missing.slice(offset, offset + ASSET_BATCH_SIZE);
668
+ const pending = this.fetchAssetBatch(batch);
669
+ waits.add(pending);
670
+ for (const reference of batch) {
671
+ const identity = assetIdentity(reference);
672
+ if (identity) {
673
+ this.pendingAssetRequests.set(identity, pending);
674
+ }
675
+ }
676
+ const cleanup = () => {
677
+ for (const reference of batch) {
678
+ const identity = assetIdentity(reference);
679
+ if (
680
+ identity &&
681
+ this.pendingAssetRequests.get(identity) === pending
682
+ ) {
683
+ this.pendingAssetRequests.delete(identity);
684
+ }
685
+ }
686
+ };
687
+ void pending.then(cleanup, cleanup);
688
+ }
689
+ }
690
+
691
+ // one failed batch mustn't discard the names the others resolved
692
+ await Promise.all(
693
+ [...waits].map((wait) =>
694
+ wait.catch((error) => {
695
+ console.error('failed to resolve assets', error);
696
+ })
697
+ )
698
+ );
699
+
700
+ const resolved: StoreAsset[] = [];
701
+ for (const identity of references.keys()) {
702
+ const asset = this.assets.get(identity);
703
+ if (asset) {
704
+ resolved.push(asset);
705
+ }
706
+ }
707
+ return resolved;
708
+ }
709
+
710
+ /**
711
+ * Registers interest in workspace asset changes. An eventless delivery
712
+ * lets the watcher apply anything already cached and is repeated after a
713
+ * reconnect refresh; live changes carry the changed asset.
714
+ */
715
+ public watchAssets(
716
+ requested: { type: string; uuid?: string; key?: string }[],
717
+ onEvent: StoreAssetHandler
718
+ ): RealtimeSubscription {
719
+ const watcher: AssetWatcher = {
720
+ // an interest without an identifier is dropped rather than treated as a
721
+ // type wildcard, matching how resolveAssets ignores those references
722
+ interests: (requested || [])
723
+ .filter(
724
+ (interest) =>
725
+ isStoreAssetType(interest.type) && !!(interest.uuid || interest.key)
726
+ )
727
+ .map((interest) => ({
728
+ type: interest.type as StoreAssetType,
729
+ ...(interest.uuid ? { uuid: interest.uuid } : {}),
730
+ ...(interest.key ? { key: interest.key } : {})
731
+ })),
732
+ onEvent
733
+ };
734
+ this.assetWatchers.add(watcher);
735
+ this.assetWatchers.prime(watcher, () => watcher.onEvent(null));
736
+
737
+ return {
738
+ unsubscribe: () => {
739
+ this.assetWatchers.remove(watcher);
740
+ }
741
+ };
742
+ }
743
+
744
+ /**
745
+ * Refetches the assets the page is currently displaying after a reconnect,
746
+ * when we may have missed changes. Only live interests are refreshed - the
747
+ * identities we resolved for content that has since scrolled away would
748
+ * otherwise fan out into a request per hundred for no visible benefit.
749
+ */
750
+ private async refreshAssetCache(): Promise<void> {
751
+ const interests = new Map<string, StoreAssetReference>();
752
+ for (const watcher of this.assetWatchers.all()) {
753
+ for (const interest of watcher.interests) {
754
+ const identity = assetIdentity(interest);
755
+ if (identity) {
756
+ interests.set(identity, interest);
757
+ }
758
+ }
759
+ }
760
+ if (interests.size > 0) {
761
+ await this.resolveAssets([...interests.values()], true);
762
+ }
763
+ this.assetWatchers.each((watcher) => watcher.onEvent(null));
764
+ }
765
+
766
+ private async fetchAssetBatch(
767
+ references: StoreAssetReference[]
768
+ ): Promise<void> {
769
+ if (!this.assetsEndpoint || references.length === 0) {
770
+ return;
771
+ }
772
+
773
+ const generation = this.assetGeneration;
774
+ // undefined means "no write recorded", which is not the same as version 0:
775
+ // assetVersions is bounded, so an evicted entry must not be read as a
776
+ // newer write and throw away a perfectly fresh name
777
+ const startVersions = new Map<string, number | undefined>();
778
+ const requested = new Set<string>();
779
+ const payload: Partial<Record<StoreAssetType, string[]>> = {};
780
+ for (const reference of references) {
781
+ const identity = assetIdentity(reference);
782
+ const value = reference.uuid || reference.key;
783
+ if (!identity || !value) {
784
+ continue;
785
+ }
786
+ requested.add(identity);
787
+ startVersions.set(identity, this.assetVersions.get(identity));
788
+ (payload[reference.type] ||= []).push(value);
789
+ }
790
+
791
+ const response = await postJSON(this.assetsEndpoint, payload);
792
+ if (generation !== this.assetGeneration) {
793
+ return;
794
+ }
795
+ // postJSON only rejects on a server error, so a 4xx arrives here as an
796
+ // empty body - bail before the bookkeeping below negatively caches every
797
+ // requested identity for the life of the page
798
+ if (response.status < 200 || response.status >= 300) {
799
+ console.warn(
800
+ `asset request failed with status ${response.status}`,
801
+ payload
802
+ );
803
+ return;
804
+ }
805
+
806
+ for (const candidate of response.json?.results || []) {
807
+ // the endpoint echoes normalized uuids, which assetIdentity matches back
808
+ // to whatever form the reference was embedded in
809
+ const identity = assetIdentity(candidate);
810
+ if (
811
+ identity &&
812
+ requested.has(identity) &&
813
+ typeof candidate.name === 'string' &&
814
+ this.isCurrentAssetVersion(identity, startVersions.get(identity))
815
+ ) {
816
+ this.cacheAsset(candidate as StoreAsset);
817
+ }
818
+ }
819
+ // Remember every requested identity, including assets the endpoint
820
+ // omitted, so deleted/missing references aren't repeatedly fetched.
821
+ for (const identity of requested) {
822
+ this.resolvedAssetIdentities.set(identity, true);
823
+ }
824
+ }
825
+
826
+ /**
827
+ * True when nothing has written this identity since the given version was
828
+ * read. A version we no longer have (evicted from the bounded map) can't
829
+ * prove a newer write, so the response is allowed through - keeping a name
830
+ * we know is stale forever is the worse failure.
831
+ *
832
+ * Note what that costs when it happens: the write it can't see may be a live
833
+ * socket rename that landed mid-batch, so this doesn't merely fail to protect
834
+ * a name we've forgotten, it can overwrite a fresher one we still hold. That
835
+ * needs an eviction inside a single batch's flight time, which is why
836
+ * ASSET_VERSION_CACHE_SIZE is kept well clear of the asset cache; the next
837
+ * socket event or reconnect refresh heals it either way.
838
+ */
839
+ private isCurrentAssetVersion(
840
+ identity: string,
841
+ startVersion: number | undefined
842
+ ): boolean {
843
+ const current = this.assetVersions.get(identity);
844
+ return current === undefined || current === startVersion;
845
+ }
846
+
847
+ private cacheAsset(asset: StoreAsset): void {
848
+ const identity = assetIdentity(asset);
849
+ if (!identity || typeof asset.name !== 'string') {
850
+ return;
851
+ }
852
+ this.assets.set(identity, { ...asset });
853
+ this.resolvedAssetIdentities.set(identity, true);
854
+ // every direct write is newer than any batch already in flight, so bump
855
+ // the version to discard a response that read an older name
856
+ this.assetVersions.set(identity, ++this.assetVersion);
857
+ }
858
+
859
+ private handleOrganizationEvent(event: OrganizationEvent): void {
860
+ const asset = event?.asset;
861
+ const identity = assetIdentity(asset);
862
+ if (
863
+ event?.type !== 'asset_changed' ||
864
+ !identity ||
865
+ typeof asset.name !== 'string'
866
+ ) {
867
+ return;
868
+ }
869
+
870
+ const changed = { ...asset };
871
+ const interested = this.assetWatchers.some((watcher) =>
872
+ watchesAsset(watcher, changed)
873
+ );
874
+ const previouslyResolved = this.resolvedAssetIdentities.has(identity);
875
+ const pending = this.pendingAssetRequests.has(identity);
876
+ const legacyGroup =
877
+ changed.type === 'group' &&
878
+ changed.uuid &&
879
+ Object.prototype.hasOwnProperty.call(this.groups, changed.uuid);
880
+
881
+ if (legacyGroup) {
882
+ this.groups[changed.uuid] = {
883
+ ...this.groups[changed.uuid],
884
+ uuid: changed.uuid,
885
+ name: changed.name
886
+ };
887
+ }
888
+ if (!previouslyResolved && !pending && !interested) {
889
+ return;
890
+ }
891
+
892
+ // cacheAsset records the write version, discarding any batch in flight
893
+ // for this identity that read the name before this change
894
+ this.cacheAsset(changed);
895
+
896
+ const publication: StoreAssetChangedEvent = {
897
+ type: 'asset_changed',
898
+ asset: changed
899
+ };
900
+ this.assetWatchers.each(
901
+ (watcher) => watcher.onEvent(publication),
902
+ (watcher) => watchesAsset(watcher, changed)
903
+ );
904
+ }
905
+
434
906
  public isDynamicGroup(uuid: string): boolean {
435
907
  const group = this.groups[uuid];
436
908
  // we treat missing groups as dynamic since the