@nyaruka/temba-components 0.170.0 → 0.171.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/CHANGELOG.md +8 -0
- package/dist/temba-components.js +766 -744
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/flow/Editor.ts +202 -42
- package/src/flow/dependencies.ts +115 -0
- package/src/form/RangePicker.ts +129 -97
- package/src/list/ContentList.ts +7 -1
- package/src/list/FlowList.ts +82 -0
- package/src/live/Realtime.ts +22 -0
- package/src/store/AppState.ts +97 -10
- package/src/store/Store.ts +495 -6
- package/src/store/identity.ts +28 -0
- package/src/utils.ts +10 -8
package/src/store/Store.ts
CHANGED
|
@@ -22,15 +22,26 @@ import {
|
|
|
22
22
|
DirtyTrackable
|
|
23
23
|
} from '../interfaces';
|
|
24
24
|
import { RapidElement } from '../RapidElement';
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
RealtimeSubscription,
|
|
27
|
+
setRealtimeContext,
|
|
28
|
+
subscribeToOrganization
|
|
29
|
+
} from '../live/Realtime';
|
|
26
30
|
import { lru } from 'tiny-lru';
|
|
27
31
|
import { DateTime } from 'luxon';
|
|
28
32
|
import { css, html } from 'lit';
|
|
29
33
|
import { configureLocalization } from '@lit/localize';
|
|
30
34
|
import { sourceLocale, targetLocales } from '../locales/locale-codes';
|
|
31
35
|
import { getFullName } from '../display/TembaUser';
|
|
32
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
AppState,
|
|
38
|
+
DependencyResolver,
|
|
39
|
+
getDependencyResolver,
|
|
40
|
+
setDependencyResolver,
|
|
41
|
+
zustand
|
|
42
|
+
} from './AppState';
|
|
33
43
|
import { StoreApi } from 'zustand/vanilla';
|
|
44
|
+
import { normalizeUuid } from './identity';
|
|
34
45
|
|
|
35
46
|
const { setLocale } = configureLocalization({
|
|
36
47
|
sourceLocale,
|
|
@@ -42,6 +53,85 @@ export const getStore = () => {
|
|
|
42
53
|
return document.querySelector('temba-store') as Store;
|
|
43
54
|
};
|
|
44
55
|
|
|
56
|
+
export const STORE_ASSET_TYPES = [
|
|
57
|
+
'channel',
|
|
58
|
+
'contact',
|
|
59
|
+
'field',
|
|
60
|
+
'flow',
|
|
61
|
+
'global',
|
|
62
|
+
'group',
|
|
63
|
+
'label',
|
|
64
|
+
'llm',
|
|
65
|
+
'optin',
|
|
66
|
+
'template',
|
|
67
|
+
'topic',
|
|
68
|
+
'user'
|
|
69
|
+
] as const;
|
|
70
|
+
|
|
71
|
+
export type StoreAssetType = (typeof STORE_ASSET_TYPES)[number];
|
|
72
|
+
|
|
73
|
+
export interface StoreAssetReference {
|
|
74
|
+
type: StoreAssetType;
|
|
75
|
+
uuid?: string;
|
|
76
|
+
key?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface StoreAsset extends StoreAssetReference {
|
|
80
|
+
name: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface StoreAssetChangedEvent {
|
|
84
|
+
type: 'asset_changed';
|
|
85
|
+
asset: StoreAsset;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type StoreAssetHandler = (event: StoreAssetChangedEvent | null) => void;
|
|
89
|
+
|
|
90
|
+
interface AssetWatcher {
|
|
91
|
+
interests: StoreAssetReference[];
|
|
92
|
+
onEvent: StoreAssetHandler;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const STORE_ASSET_TYPE_SET = new Set<string>(STORE_ASSET_TYPES);
|
|
96
|
+
|
|
97
|
+
// the endpoint rejects a request carrying more than this many identifiers
|
|
98
|
+
const ASSET_BATCH_SIZE = 100;
|
|
99
|
+
|
|
100
|
+
// long-lived pages (a flow list paged through many times) would otherwise
|
|
101
|
+
// accumulate an entry for every asset ever seen. Once an entry is evicted the
|
|
102
|
+
// component falls back to the name its own response carried, and the identity
|
|
103
|
+
// is fetched again next time it is asked for.
|
|
104
|
+
export const ASSET_CACHE_SIZE = 500;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Versions are held well above the asset cache because losing one doesn't just
|
|
108
|
+
* forget a name, it drops the in-flight guard for that identity - and `assets`
|
|
109
|
+
* is bumped by reads that never touch `assetVersions`, so the two ages drift
|
|
110
|
+
* apart. Entries are a single number, so the extra headroom is cheap.
|
|
111
|
+
*/
|
|
112
|
+
export const ASSET_VERSION_CACHE_SIZE = ASSET_CACHE_SIZE * 8;
|
|
113
|
+
|
|
114
|
+
const isStoreAssetType = (type: string): type is StoreAssetType =>
|
|
115
|
+
STORE_ASSET_TYPE_SET.has(type);
|
|
116
|
+
|
|
117
|
+
const assetIdentity = (
|
|
118
|
+
asset: { type?: string; uuid?: string; key?: string } | null
|
|
119
|
+
): string | null => {
|
|
120
|
+
if (!asset || !isStoreAssetType(asset.type)) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const identity = asset.uuid ? normalizeUuid(asset.uuid) : asset.key;
|
|
124
|
+
return identity ? `${asset.type}:${identity}` : null;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const watchesAsset = (watcher: AssetWatcher, asset: StoreAsset): boolean => {
|
|
128
|
+
const identity = assetIdentity(asset);
|
|
129
|
+
return (
|
|
130
|
+
!!identity &&
|
|
131
|
+
watcher.interests.some((interest) => assetIdentity(interest) === identity)
|
|
132
|
+
);
|
|
133
|
+
};
|
|
134
|
+
|
|
45
135
|
declare const __TEMBA_DEV_SERVER__: boolean;
|
|
46
136
|
|
|
47
137
|
/**
|
|
@@ -100,6 +190,9 @@ export class Store extends RapidElement {
|
|
|
100
190
|
@property({ type: String, attribute: 'groups' })
|
|
101
191
|
groupsEndpoint: string;
|
|
102
192
|
|
|
193
|
+
@property({ type: String, attribute: 'assets' })
|
|
194
|
+
assetsEndpoint: string;
|
|
195
|
+
|
|
103
196
|
@property({ type: String, attribute: 'globals' })
|
|
104
197
|
globalsEndpoint: string;
|
|
105
198
|
|
|
@@ -136,6 +229,24 @@ export class Store extends RapidElement {
|
|
|
136
229
|
private shortcuts: Shortcut[] = [];
|
|
137
230
|
private workspace: Workspace;
|
|
138
231
|
private featuredFields: ContactField[] = [];
|
|
232
|
+
private assetWatchers: AssetWatcher[] = [];
|
|
233
|
+
// canonical names, the identities we've already asked about (including ones
|
|
234
|
+
// the endpoint had no asset for) and their last-write versions, all bounded
|
|
235
|
+
// so a long-lived page doesn't keep every asset it has ever rendered
|
|
236
|
+
private assets = lru<StoreAsset>(ASSET_CACHE_SIZE);
|
|
237
|
+
private resolvedAssetIdentities = lru<boolean>(ASSET_CACHE_SIZE);
|
|
238
|
+
private assetVersions = lru<number>(ASSET_VERSION_CACHE_SIZE);
|
|
239
|
+
private pendingAssetRequests = new Map<string, Promise<void>>();
|
|
240
|
+
private assetVersion = 0;
|
|
241
|
+
// bumped by reset(), which only firstUpdated() calls today - the guard exists
|
|
242
|
+
// so a future caller that resets a live store can't have the cleared cache
|
|
243
|
+
// repopulated by a batch that was already in flight
|
|
244
|
+
private assetGeneration = 0;
|
|
245
|
+
private organizationWatch: RealtimeSubscription = null;
|
|
246
|
+
private organizationSubscribed = false;
|
|
247
|
+
private previousDependencyResolver: DependencyResolver = null;
|
|
248
|
+
private dependencyResolver: DependencyResolver = (dependencies) =>
|
|
249
|
+
this.resolveAssets(dependencies);
|
|
139
250
|
|
|
140
251
|
// http promise to monitor for completeness
|
|
141
252
|
public initialHttpComplete: Promise<void | WebResponse[]>;
|
|
@@ -198,6 +309,13 @@ export class Store extends RapidElement {
|
|
|
198
309
|
this.clearCache();
|
|
199
310
|
this.settings = JSON.parse(getCookie('settings') || '{}');
|
|
200
311
|
zustand.setState({ brand: this.brand });
|
|
312
|
+
this.groups = {};
|
|
313
|
+
this.assets.clear();
|
|
314
|
+
this.resolvedAssetIdentities.clear();
|
|
315
|
+
this.pendingAssetRequests.clear();
|
|
316
|
+
this.assetVersions.clear();
|
|
317
|
+
this.assetVersion = 0;
|
|
318
|
+
this.assetGeneration++;
|
|
201
319
|
|
|
202
320
|
/*
|
|
203
321
|
// This will create a shorthand unit
|
|
@@ -238,10 +356,10 @@ export class Store extends RapidElement {
|
|
|
238
356
|
|
|
239
357
|
if (this.groupsEndpoint) {
|
|
240
358
|
fetches.push(
|
|
241
|
-
getAssets(this.groupsEndpoint).then((groups
|
|
242
|
-
|
|
359
|
+
getAssets<ContactGroup>(this.groupsEndpoint).then((groups) => {
|
|
360
|
+
for (const group of groups) {
|
|
243
361
|
this.groups[group.uuid] = group;
|
|
244
|
-
}
|
|
362
|
+
}
|
|
245
363
|
})
|
|
246
364
|
);
|
|
247
365
|
}
|
|
@@ -274,11 +392,61 @@ export class Store extends RapidElement {
|
|
|
274
392
|
return this.shortcuts || [];
|
|
275
393
|
}
|
|
276
394
|
|
|
395
|
+
public connectedCallback(): void {
|
|
396
|
+
super.connectedCallback();
|
|
397
|
+
// lit only runs firstUpdated once, so a store that is detached and
|
|
398
|
+
// re-attached has to reinstall its page hooks here
|
|
399
|
+
if (this.hasUpdated) {
|
|
400
|
+
this.installPageHooks();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
277
404
|
public firstUpdated() {
|
|
405
|
+
this.installPageHooks();
|
|
406
|
+
this.reset();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Installs the hooks this store owns on behalf of the page: the canonical
|
|
411
|
+
* name resolver and the workspace realtime subscription. Idempotent so it
|
|
412
|
+
* can run again when a store is re-attached.
|
|
413
|
+
*/
|
|
414
|
+
private installPageHooks(): void {
|
|
415
|
+
if (getDependencyResolver() !== this.dependencyResolver) {
|
|
416
|
+
this.previousDependencyResolver = setDependencyResolver(
|
|
417
|
+
this.dependencyResolver
|
|
418
|
+
);
|
|
419
|
+
}
|
|
278
420
|
if (this.org && this.user) {
|
|
279
421
|
setRealtimeContext({ org: this.org, user: this.user });
|
|
422
|
+
if (!this.organizationWatch) {
|
|
423
|
+
this.organizationWatch = subscribeToOrganization(
|
|
424
|
+
(event) => this.handleOrganizationEvent(event),
|
|
425
|
+
() => {
|
|
426
|
+
if (this.organizationSubscribed) {
|
|
427
|
+
this.refreshAssetCache().catch((error) => {
|
|
428
|
+
console.error('failed to refresh store assets', error);
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
this.organizationSubscribed = true;
|
|
432
|
+
}
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
public disconnectedCallback(): void {
|
|
439
|
+
super.disconnectedCallback();
|
|
440
|
+
// only hand the resolver back if we're still the installed one, otherwise
|
|
441
|
+
// a store that never installed it would clear the live store's resolver
|
|
442
|
+
if (getDependencyResolver() === this.dependencyResolver) {
|
|
443
|
+
setDependencyResolver(this.previousDependencyResolver);
|
|
444
|
+
}
|
|
445
|
+
this.previousDependencyResolver = null;
|
|
446
|
+
if (this.organizationWatch) {
|
|
447
|
+
this.organizationWatch.unsubscribe();
|
|
448
|
+
this.organizationWatch = null;
|
|
280
449
|
}
|
|
281
|
-
this.reset();
|
|
282
450
|
}
|
|
283
451
|
|
|
284
452
|
public getLanguageCode() {
|
|
@@ -431,6 +599,327 @@ export class Store extends RapidElement {
|
|
|
431
599
|
return this.featuredFields;
|
|
432
600
|
}
|
|
433
601
|
|
|
602
|
+
public getAsset(type: string, identity: string): StoreAsset | null {
|
|
603
|
+
if (!isStoreAssetType(type) || !identity) {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
// the identity may be a key (cached verbatim, and case-sensitive) or a uuid
|
|
607
|
+
// (cached under its canonical form), so try verbatim first — a canonical
|
|
608
|
+
// uuid already hits on that pass, and keys never reach the normalizing one
|
|
609
|
+
return (
|
|
610
|
+
this.assets.get(`${type}:${identity}`) ||
|
|
611
|
+
this.assets.get(`${type}:${normalizeUuid(identity)}`) ||
|
|
612
|
+
null
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Adds server-authoritative assets to the page cache. Components whose
|
|
617
|
+
* own response already contains canonical names can seed the same cache
|
|
618
|
+
* without making another request. */
|
|
619
|
+
public cacheAssets(assets: StoreAsset[]): void {
|
|
620
|
+
for (const asset of assets) {
|
|
621
|
+
this.cacheAsset(asset);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Resolves every requested reference from the cache, fetching only the
|
|
626
|
+
* identities this page hasn't resolved before. Concurrent callers share
|
|
627
|
+
* each in-flight batch and absent assets are negatively cached. */
|
|
628
|
+
public async resolveAssets(
|
|
629
|
+
requested: { type: string; uuid?: string; key?: string }[],
|
|
630
|
+
force = false
|
|
631
|
+
): Promise<StoreAsset[]> {
|
|
632
|
+
const references = new Map<string, StoreAssetReference>();
|
|
633
|
+
for (const candidate of requested || []) {
|
|
634
|
+
if (!isStoreAssetType(candidate.type)) {
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
const reference: StoreAssetReference = {
|
|
638
|
+
type: candidate.type,
|
|
639
|
+
...(candidate.uuid ? { uuid: candidate.uuid } : {}),
|
|
640
|
+
...(candidate.key ? { key: candidate.key } : {})
|
|
641
|
+
};
|
|
642
|
+
const identity = assetIdentity(reference);
|
|
643
|
+
if (identity) {
|
|
644
|
+
references.set(identity, reference);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const waits = new Set<Promise<void>>();
|
|
649
|
+
const missing: StoreAssetReference[] = [];
|
|
650
|
+
for (const [identity, reference] of references) {
|
|
651
|
+
const pending = this.pendingAssetRequests.get(identity);
|
|
652
|
+
if (pending) {
|
|
653
|
+
waits.add(pending);
|
|
654
|
+
} else if (force || !this.resolvedAssetIdentities.has(identity)) {
|
|
655
|
+
missing.push(reference);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (this.assetsEndpoint) {
|
|
660
|
+
for (
|
|
661
|
+
let offset = 0;
|
|
662
|
+
offset < missing.length;
|
|
663
|
+
offset += ASSET_BATCH_SIZE
|
|
664
|
+
) {
|
|
665
|
+
const batch = missing.slice(offset, offset + ASSET_BATCH_SIZE);
|
|
666
|
+
const pending = this.fetchAssetBatch(batch);
|
|
667
|
+
waits.add(pending);
|
|
668
|
+
for (const reference of batch) {
|
|
669
|
+
const identity = assetIdentity(reference);
|
|
670
|
+
if (identity) {
|
|
671
|
+
this.pendingAssetRequests.set(identity, pending);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const cleanup = () => {
|
|
675
|
+
for (const reference of batch) {
|
|
676
|
+
const identity = assetIdentity(reference);
|
|
677
|
+
if (
|
|
678
|
+
identity &&
|
|
679
|
+
this.pendingAssetRequests.get(identity) === pending
|
|
680
|
+
) {
|
|
681
|
+
this.pendingAssetRequests.delete(identity);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
void pending.then(cleanup, cleanup);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// one failed batch mustn't discard the names the others resolved
|
|
690
|
+
await Promise.all(
|
|
691
|
+
[...waits].map((wait) =>
|
|
692
|
+
wait.catch((error) => {
|
|
693
|
+
console.error('failed to resolve assets', error);
|
|
694
|
+
})
|
|
695
|
+
)
|
|
696
|
+
);
|
|
697
|
+
|
|
698
|
+
const resolved: StoreAsset[] = [];
|
|
699
|
+
for (const identity of references.keys()) {
|
|
700
|
+
const asset = this.assets.get(identity);
|
|
701
|
+
if (asset) {
|
|
702
|
+
resolved.push(asset);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return resolved;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Registers interest in workspace asset changes. An eventless delivery
|
|
710
|
+
* lets the watcher apply anything already cached and is repeated after a
|
|
711
|
+
* reconnect refresh; live changes carry the changed asset.
|
|
712
|
+
*/
|
|
713
|
+
public watchAssets(
|
|
714
|
+
requested: { type: string; uuid?: string; key?: string }[],
|
|
715
|
+
onEvent: StoreAssetHandler
|
|
716
|
+
): RealtimeSubscription {
|
|
717
|
+
const watcher: AssetWatcher = {
|
|
718
|
+
// an interest without an identifier is dropped rather than treated as a
|
|
719
|
+
// type wildcard, matching how resolveAssets ignores those references
|
|
720
|
+
interests: (requested || [])
|
|
721
|
+
.filter(
|
|
722
|
+
(interest) =>
|
|
723
|
+
isStoreAssetType(interest.type) && !!(interest.uuid || interest.key)
|
|
724
|
+
)
|
|
725
|
+
.map((interest) => ({
|
|
726
|
+
type: interest.type as StoreAssetType,
|
|
727
|
+
...(interest.uuid ? { uuid: interest.uuid } : {}),
|
|
728
|
+
...(interest.key ? { key: interest.key } : {})
|
|
729
|
+
})),
|
|
730
|
+
onEvent
|
|
731
|
+
};
|
|
732
|
+
this.assetWatchers.push(watcher);
|
|
733
|
+
Promise.resolve().then(() => {
|
|
734
|
+
if (this.assetWatchers.includes(watcher)) {
|
|
735
|
+
this.deliverAssetEvent(watcher, null);
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
return {
|
|
740
|
+
unsubscribe: () => {
|
|
741
|
+
const index = this.assetWatchers.indexOf(watcher);
|
|
742
|
+
if (index >= 0) {
|
|
743
|
+
this.assetWatchers.splice(index, 1);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Refetches the assets the page is currently displaying after a reconnect,
|
|
751
|
+
* when we may have missed changes. Only live interests are refreshed - the
|
|
752
|
+
* identities we resolved for content that has since scrolled away would
|
|
753
|
+
* otherwise fan out into a request per hundred for no visible benefit.
|
|
754
|
+
*/
|
|
755
|
+
private async refreshAssetCache(): Promise<void> {
|
|
756
|
+
const interests = new Map<string, StoreAssetReference>();
|
|
757
|
+
for (const watcher of this.assetWatchers) {
|
|
758
|
+
for (const interest of watcher.interests) {
|
|
759
|
+
const identity = assetIdentity(interest);
|
|
760
|
+
if (identity) {
|
|
761
|
+
interests.set(identity, interest);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
if (interests.size > 0) {
|
|
766
|
+
await this.resolveAssets([...interests.values()], true);
|
|
767
|
+
}
|
|
768
|
+
for (const watcher of this.assetWatchers) {
|
|
769
|
+
this.deliverAssetEvent(watcher, null);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
private async fetchAssetBatch(
|
|
774
|
+
references: StoreAssetReference[]
|
|
775
|
+
): Promise<void> {
|
|
776
|
+
if (!this.assetsEndpoint || references.length === 0) {
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const generation = this.assetGeneration;
|
|
781
|
+
// undefined means "no write recorded", which is not the same as version 0:
|
|
782
|
+
// assetVersions is bounded, so an evicted entry must not be read as a
|
|
783
|
+
// newer write and throw away a perfectly fresh name
|
|
784
|
+
const startVersions = new Map<string, number | undefined>();
|
|
785
|
+
const requested = new Set<string>();
|
|
786
|
+
const payload: Partial<Record<StoreAssetType, string[]>> = {};
|
|
787
|
+
for (const reference of references) {
|
|
788
|
+
const identity = assetIdentity(reference);
|
|
789
|
+
const value = reference.uuid || reference.key;
|
|
790
|
+
if (!identity || !value) {
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
requested.add(identity);
|
|
794
|
+
startVersions.set(identity, this.assetVersions.get(identity));
|
|
795
|
+
(payload[reference.type] ||= []).push(value);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const response = await postJSON(this.assetsEndpoint, payload);
|
|
799
|
+
if (generation !== this.assetGeneration) {
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
// postJSON only rejects on a server error, so a 4xx arrives here as an
|
|
803
|
+
// empty body - bail before the bookkeeping below negatively caches every
|
|
804
|
+
// requested identity for the life of the page
|
|
805
|
+
if (response.status < 200 || response.status >= 300) {
|
|
806
|
+
console.warn(
|
|
807
|
+
`asset request failed with status ${response.status}`,
|
|
808
|
+
payload
|
|
809
|
+
);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
for (const candidate of response.json?.results || []) {
|
|
814
|
+
// the endpoint echoes normalized uuids, which assetIdentity matches back
|
|
815
|
+
// to whatever form the reference was embedded in
|
|
816
|
+
const identity = assetIdentity(candidate);
|
|
817
|
+
if (
|
|
818
|
+
identity &&
|
|
819
|
+
requested.has(identity) &&
|
|
820
|
+
typeof candidate.name === 'string' &&
|
|
821
|
+
this.isCurrentAssetVersion(identity, startVersions.get(identity))
|
|
822
|
+
) {
|
|
823
|
+
this.cacheAsset(candidate as StoreAsset);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
// Remember every requested identity, including assets the endpoint
|
|
827
|
+
// omitted, so deleted/missing references aren't repeatedly fetched.
|
|
828
|
+
for (const identity of requested) {
|
|
829
|
+
this.resolvedAssetIdentities.set(identity, true);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* True when nothing has written this identity since the given version was
|
|
835
|
+
* read. A version we no longer have (evicted from the bounded map) can't
|
|
836
|
+
* prove a newer write, so the response is allowed through - keeping a name
|
|
837
|
+
* we know is stale forever is the worse failure.
|
|
838
|
+
*
|
|
839
|
+
* Note what that costs when it happens: the write it can't see may be a live
|
|
840
|
+
* socket rename that landed mid-batch, so this doesn't merely fail to protect
|
|
841
|
+
* a name we've forgotten, it can overwrite a fresher one we still hold. That
|
|
842
|
+
* needs an eviction inside a single batch's flight time, which is why
|
|
843
|
+
* ASSET_VERSION_CACHE_SIZE is kept well clear of the asset cache; the next
|
|
844
|
+
* socket event or reconnect refresh heals it either way.
|
|
845
|
+
*/
|
|
846
|
+
private isCurrentAssetVersion(
|
|
847
|
+
identity: string,
|
|
848
|
+
startVersion: number | undefined
|
|
849
|
+
): boolean {
|
|
850
|
+
const current = this.assetVersions.get(identity);
|
|
851
|
+
return current === undefined || current === startVersion;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
private cacheAsset(asset: StoreAsset): void {
|
|
855
|
+
const identity = assetIdentity(asset);
|
|
856
|
+
if (!identity || typeof asset.name !== 'string') {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
this.assets.set(identity, { ...asset });
|
|
860
|
+
this.resolvedAssetIdentities.set(identity, true);
|
|
861
|
+
// every direct write is newer than any batch already in flight, so bump
|
|
862
|
+
// the version to discard a response that read an older name
|
|
863
|
+
this.assetVersions.set(identity, ++this.assetVersion);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
private handleOrganizationEvent(event: any): void {
|
|
867
|
+
const asset = event?.asset as StoreAsset;
|
|
868
|
+
const identity = assetIdentity(asset);
|
|
869
|
+
if (
|
|
870
|
+
event?.type !== 'asset_changed' ||
|
|
871
|
+
!identity ||
|
|
872
|
+
typeof asset.name !== 'string'
|
|
873
|
+
) {
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const changed = { ...asset };
|
|
878
|
+
const interested = this.assetWatchers.filter((watcher) =>
|
|
879
|
+
watchesAsset(watcher, changed)
|
|
880
|
+
);
|
|
881
|
+
const previouslyResolved = this.resolvedAssetIdentities.has(identity);
|
|
882
|
+
const pending = this.pendingAssetRequests.has(identity);
|
|
883
|
+
const legacyGroup =
|
|
884
|
+
changed.type === 'group' &&
|
|
885
|
+
changed.uuid &&
|
|
886
|
+
Object.prototype.hasOwnProperty.call(this.groups, changed.uuid);
|
|
887
|
+
|
|
888
|
+
if (legacyGroup) {
|
|
889
|
+
this.groups[changed.uuid] = {
|
|
890
|
+
...this.groups[changed.uuid],
|
|
891
|
+
uuid: changed.uuid,
|
|
892
|
+
name: changed.name
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
if (!previouslyResolved && !pending && interested.length === 0) {
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// cacheAsset records the write version, discarding any batch in flight
|
|
900
|
+
// for this identity that read the name before this change
|
|
901
|
+
this.cacheAsset(changed);
|
|
902
|
+
|
|
903
|
+
const publication: StoreAssetChangedEvent = {
|
|
904
|
+
type: 'asset_changed',
|
|
905
|
+
asset: changed
|
|
906
|
+
};
|
|
907
|
+
for (const watcher of interested) {
|
|
908
|
+
this.deliverAssetEvent(watcher, publication);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
private deliverAssetEvent(
|
|
913
|
+
watcher: AssetWatcher,
|
|
914
|
+
event: StoreAssetChangedEvent | null
|
|
915
|
+
): void {
|
|
916
|
+
try {
|
|
917
|
+
watcher.onEvent(event);
|
|
918
|
+
} catch (error) {
|
|
919
|
+
console.error('store asset watcher failed', error);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
434
923
|
public isDynamicGroup(uuid: string): boolean {
|
|
435
924
|
const group = this.groups[uuid];
|
|
436
925
|
// we treat missing groups as dynamic since the
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const UNHYPHENATED_UUID = /^[0-9a-f]{32}$/;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canonicalizes a uuid the way the server does, so a reference embedded in a
|
|
5
|
+
* flow definition in some other form (uppercase, braced, unhyphenated) still
|
|
6
|
+
* matches the normalized uuid the endpoint echoes back.
|
|
7
|
+
*
|
|
8
|
+
* This lives in its own module with no imports so both the store's asset cache
|
|
9
|
+
* and the flow definition rewriter can share it - they sit on opposite sides of
|
|
10
|
+
* a `Store -> AppState -> dependencies` chain, so a shared helper in either one
|
|
11
|
+
* would close an import cycle.
|
|
12
|
+
*/
|
|
13
|
+
export const normalizeUuid = (uuid: string): string => {
|
|
14
|
+
const trimmed = uuid
|
|
15
|
+
.trim()
|
|
16
|
+
.replace(/^\{|\}$/g, '')
|
|
17
|
+
.toLowerCase();
|
|
18
|
+
if (UNHYPHENATED_UUID.test(trimmed)) {
|
|
19
|
+
return [
|
|
20
|
+
trimmed.slice(0, 8),
|
|
21
|
+
trimmed.slice(8, 12),
|
|
22
|
+
trimmed.slice(12, 16),
|
|
23
|
+
trimmed.slice(16, 20),
|
|
24
|
+
trimmed.slice(20)
|
|
25
|
+
].join('-');
|
|
26
|
+
}
|
|
27
|
+
return trimmed;
|
|
28
|
+
};
|
package/src/utils.ts
CHANGED
|
@@ -47,8 +47,8 @@ interface KeyedAsset {
|
|
|
47
47
|
key?: string;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
interface AssetPage {
|
|
51
|
-
assets:
|
|
50
|
+
interface AssetPage<T = Asset> {
|
|
51
|
+
assets: T[];
|
|
52
52
|
next: string;
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -233,8 +233,10 @@ export const fetchResults = async (
|
|
|
233
233
|
return results;
|
|
234
234
|
};
|
|
235
235
|
|
|
236
|
-
|
|
237
|
-
|
|
236
|
+
/** Fetches one page of an asset endpoint. The caller names the shape it
|
|
237
|
+
* expects, since these endpoints serve everything from groups to shortcuts. */
|
|
238
|
+
export const getAssetPage = <T = Asset>(url: string): Promise<AssetPage<T>> => {
|
|
239
|
+
return new Promise<AssetPage<T>>((resolve, reject) => {
|
|
238
240
|
getUrl(url)
|
|
239
241
|
.then((response: WebResponse) => {
|
|
240
242
|
if (response.status >= 200 && response.status < 300) {
|
|
@@ -250,15 +252,15 @@ export const getAssetPage = (url: string): Promise<AssetPage> => {
|
|
|
250
252
|
});
|
|
251
253
|
};
|
|
252
254
|
|
|
253
|
-
export const getAssets = async (url: string): Promise<
|
|
255
|
+
export const getAssets = async <T = Asset>(url: string): Promise<T[]> => {
|
|
254
256
|
if (!url) {
|
|
255
|
-
return new Promise<
|
|
257
|
+
return new Promise<T[]>((resolve) => resolve([]));
|
|
256
258
|
}
|
|
257
259
|
|
|
258
|
-
let assets:
|
|
260
|
+
let assets: T[] = [];
|
|
259
261
|
let pageUrl = url;
|
|
260
262
|
while (pageUrl) {
|
|
261
|
-
const assetPage = await getAssetPage(pageUrl);
|
|
263
|
+
const assetPage = await getAssetPage<T>(pageUrl);
|
|
262
264
|
if (assetPage.assets) {
|
|
263
265
|
assets = assets.concat(assetPage.assets);
|
|
264
266
|
pageUrl = assetPage.next;
|