@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.
- package/CHANGELOG.md +24 -0
- package/dist/temba-components.js +763 -703
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/RapidElement.ts +83 -41
- package/src/events/eventRenderers.ts +4 -0
- package/src/flow/Editor.ts +202 -42
- package/src/flow/FlowSearch.ts +12 -392
- package/src/flow/actions/send_msg.ts +2 -1
- package/src/flow/actions/set_contact_field.ts +1 -0
- package/src/flow/actions/set_contact_name.ts +1 -0
- package/src/flow/dependencies.ts +115 -0
- package/src/flow/nodes/split_by_ticket.ts +1 -0
- package/src/flow/nodes/wait_for_dial.ts +1 -0
- package/src/form/RangePicker.ts +137 -97
- package/src/layout/SearchModal.ts +564 -0
- package/src/list/ContentList.ts +7 -1
- package/src/list/FlowList.ts +82 -0
- package/src/list/TembaMenu.ts +71 -13
- package/src/live/ContactChat.ts +247 -70
- package/src/live/ContactWatch.ts +166 -63
- package/src/live/Realtime.ts +155 -4
- package/src/live/SocketService.ts +115 -10
- package/src/live/TicketSearch.ts +290 -0
- package/src/live/Watchers.ts +93 -0
- package/src/simulator/Simulator.ts +59 -36
- package/src/store/AppState.ts +97 -10
- package/src/store/Store.ts +478 -6
- package/src/store/identity.ts +28 -0
- package/src/utils.ts +10 -8
- package/temba-modules.ts +2 -0
package/src/store/AppState.ts
CHANGED
|
@@ -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:
|
|
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;
|
package/src/store/Store.ts
CHANGED
|
@@ -22,15 +22,28 @@ 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
|
+
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 {
|
|
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
|
|
242
|
-
|
|
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
|