gloomberb 0.9.9 → 0.9.10
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/package.json +1 -1
- package/src/app.tsx +1 -0
- package/src/sync/controller.test.ts +123 -3
- package/src/sync/controller.ts +76 -45
- package/src/sync/core-contributors.ts +45 -21
- package/src/sync/react.ts +6 -8
- package/src/sync/types.ts +3 -0
- package/src/version.ts +1 -1
package/package.json
CHANGED
package/src/app.tsx
CHANGED
|
@@ -1,8 +1,24 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
appReducer,
|
|
4
|
+
createInitialState,
|
|
5
|
+
type AppAction,
|
|
6
|
+
type AppState,
|
|
7
|
+
} from "../core/state/app/state";
|
|
3
8
|
import type { TickerRepository } from "../data/ticker-repository";
|
|
9
|
+
import { createDefaultConfig } from "../types/config";
|
|
10
|
+
import {
|
|
11
|
+
__syncContributorInternalsForTests,
|
|
12
|
+
coreConfigSyncContributor,
|
|
13
|
+
} from "./core-contributors";
|
|
4
14
|
import { CloudSyncController } from "./controller";
|
|
5
|
-
import
|
|
15
|
+
import {
|
|
16
|
+
SYNC_SNAPSHOT_SCHEMA_VERSION,
|
|
17
|
+
type SyncContributor,
|
|
18
|
+
type SyncSnapshot,
|
|
19
|
+
type SyncSnapshotResponse,
|
|
20
|
+
type SyncTransport,
|
|
21
|
+
} from "./types";
|
|
6
22
|
|
|
7
23
|
test("does not push local state when the initial pull fails", async () => {
|
|
8
24
|
let pushes = 0;
|
|
@@ -17,7 +33,7 @@ test("does not push local state when the initial pull fails", async () => {
|
|
|
17
33
|
};
|
|
18
34
|
const controller = new CloudSyncController();
|
|
19
35
|
controller.setRuntime({
|
|
20
|
-
|
|
36
|
+
getState: () => ({} as AppState),
|
|
21
37
|
dispatch: () => {},
|
|
22
38
|
tickerRepository: {} as TickerRepository,
|
|
23
39
|
getContributors: () => [],
|
|
@@ -29,3 +45,107 @@ test("does not push local state when the initial pull fails", async () => {
|
|
|
29
45
|
expect(pushes).toBe(0);
|
|
30
46
|
expect(controller.getStatus()).toMatchObject({ phase: "error", error: "pull failed" });
|
|
31
47
|
});
|
|
48
|
+
|
|
49
|
+
test("keeps startup layout changes while serializing pull and push", async () => {
|
|
50
|
+
let state = createInitialState(createDefaultConfig("/tmp/gloomberb-sync-controller-test"));
|
|
51
|
+
const dispatch = (action: AppAction) => {
|
|
52
|
+
state = appReducer(state, action);
|
|
53
|
+
};
|
|
54
|
+
let resolvePull!: (response: SyncSnapshotResponse) => void;
|
|
55
|
+
const deferredPull = new Promise<SyncSnapshotResponse>((resolve) => {
|
|
56
|
+
resolvePull = resolve;
|
|
57
|
+
});
|
|
58
|
+
let pulls = 0;
|
|
59
|
+
const pushes: Array<{
|
|
60
|
+
snapshot: SyncSnapshot;
|
|
61
|
+
options?: { baseRevision?: number | null };
|
|
62
|
+
}> = [];
|
|
63
|
+
const transport: SyncTransport = {
|
|
64
|
+
id: "deferred-pull",
|
|
65
|
+
isAvailable: () => true,
|
|
66
|
+
pullSnapshot: () => {
|
|
67
|
+
pulls += 1;
|
|
68
|
+
return deferredPull;
|
|
69
|
+
},
|
|
70
|
+
pushSnapshot: async (snapshot, options) => {
|
|
71
|
+
pushes.push({ snapshot, options });
|
|
72
|
+
return { revision: 8, updatedAt: "2026-07-13T10:00:08.000Z" };
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
const contributor: SyncContributor = {
|
|
76
|
+
...coreConfigSyncContributor,
|
|
77
|
+
apply: (payload, context) => {
|
|
78
|
+
const config = __syncContributorInternalsForTests.mergeConfigPayload(
|
|
79
|
+
context.state.config,
|
|
80
|
+
payload,
|
|
81
|
+
context.baselineState.config,
|
|
82
|
+
);
|
|
83
|
+
if (config) context.dispatch({ type: "SET_CONFIG", config });
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
const controller = new CloudSyncController();
|
|
87
|
+
controller.setRuntime({
|
|
88
|
+
getState: () => state,
|
|
89
|
+
dispatch,
|
|
90
|
+
tickerRepository: {} as TickerRepository,
|
|
91
|
+
getContributors: () => [{ pluginId: "test", contributor }],
|
|
92
|
+
getTransport: () => ({ pluginId: "test", transport }),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const startupSync = controller.requestSync({ reason: "startup" });
|
|
96
|
+
const startupPane = {
|
|
97
|
+
instanceId: "help:startup",
|
|
98
|
+
paneId: "help",
|
|
99
|
+
binding: { kind: "none" as const },
|
|
100
|
+
};
|
|
101
|
+
const localLayout = {
|
|
102
|
+
...state.config.layout,
|
|
103
|
+
instances: [...state.config.layout.instances, startupPane],
|
|
104
|
+
floating: [
|
|
105
|
+
...state.config.layout.floating,
|
|
106
|
+
{ instanceId: startupPane.instanceId, x: 4, y: 3, width: 60, height: 20 },
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
dispatch({
|
|
110
|
+
type: "SET_CONFIG",
|
|
111
|
+
config: {
|
|
112
|
+
...state.config,
|
|
113
|
+
layout: localLayout,
|
|
114
|
+
layouts: state.config.layouts.map((saved, index) => (
|
|
115
|
+
index === state.config.activeLayoutIndex ? { ...saved, layout: localLayout } : saved
|
|
116
|
+
)),
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
const queuedPush = controller.requestSync({ reason: "state-change" });
|
|
120
|
+
|
|
121
|
+
const remoteConfig = createDefaultConfig("/remote/path-is-not-synced");
|
|
122
|
+
remoteConfig.theme = "green";
|
|
123
|
+
resolvePull({
|
|
124
|
+
snapshot: {
|
|
125
|
+
schemaVersion: SYNC_SNAPSHOT_SCHEMA_VERSION,
|
|
126
|
+
appId: "gloomberb",
|
|
127
|
+
clientId: "remote-client",
|
|
128
|
+
createdAt: "2026-07-13T10:00:07.000Z",
|
|
129
|
+
contributors: {
|
|
130
|
+
"core.config": {
|
|
131
|
+
schemaVersion: 1,
|
|
132
|
+
updatedAt: "2026-07-13T10:00:07.000Z",
|
|
133
|
+
payload: __syncContributorInternalsForTests.collectCoreConfigPayload(remoteConfig),
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
revision: 7,
|
|
138
|
+
updatedAt: "2026-07-13T10:00:07.000Z",
|
|
139
|
+
});
|
|
140
|
+
await Promise.all([startupSync, queuedPush]);
|
|
141
|
+
|
|
142
|
+
const pushedConfig = pushes[0]?.snapshot.contributors["core.config"]?.payload as {
|
|
143
|
+
layout: AppState["config"]["layout"];
|
|
144
|
+
};
|
|
145
|
+
expect(pulls).toBe(1);
|
|
146
|
+
expect(pushes).toHaveLength(1);
|
|
147
|
+
expect(pushes[0]?.options).toEqual({ baseRevision: 7 });
|
|
148
|
+
expect(state.config.theme).toBe("green");
|
|
149
|
+
expect(state.config.layout.instances).toContainEqual(startupPane);
|
|
150
|
+
expect(pushedConfig.layout.instances).toContainEqual(startupPane);
|
|
151
|
+
});
|
package/src/sync/controller.ts
CHANGED
|
@@ -23,7 +23,7 @@ export interface CloudSyncStatus {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
interface SyncRuntime {
|
|
26
|
-
|
|
26
|
+
getState: () => AppState;
|
|
27
27
|
dispatch: Dispatch<AppAction>;
|
|
28
28
|
tickerRepository: TickerRepository;
|
|
29
29
|
getContributors: () => RegisteredSyncContributor[];
|
|
@@ -32,7 +32,6 @@ interface SyncRuntime {
|
|
|
32
32
|
|
|
33
33
|
const CLIENT_ID_STORAGE_KEY = "gloomberb.sync.clientId";
|
|
34
34
|
const PUSH_DEBOUNCE_MS = 2500;
|
|
35
|
-
const PULL_MIN_INTERVAL_MS = 60_000;
|
|
36
35
|
|
|
37
36
|
function nowIso(): string {
|
|
38
37
|
return new Date().toISOString();
|
|
@@ -47,6 +46,15 @@ function resolveClientId(): string {
|
|
|
47
46
|
return id;
|
|
48
47
|
}
|
|
49
48
|
|
|
49
|
+
function snapshotContentSignature(snapshot: SyncSnapshot): string {
|
|
50
|
+
return stableStringify(Object.fromEntries(
|
|
51
|
+
Object.entries(snapshot.contributors).map(([id, contributor]) => [
|
|
52
|
+
id,
|
|
53
|
+
{ schemaVersion: contributor.schemaVersion, payload: contributor.payload },
|
|
54
|
+
]),
|
|
55
|
+
));
|
|
56
|
+
}
|
|
57
|
+
|
|
50
58
|
export class CloudSyncController {
|
|
51
59
|
private runtime: SyncRuntime | null = null;
|
|
52
60
|
private contributors = new Map<string, RegisteredSyncContributor>();
|
|
@@ -57,7 +65,6 @@ export class CloudSyncController {
|
|
|
57
65
|
private syncQueued = false;
|
|
58
66
|
private clientId: string | null = null;
|
|
59
67
|
private lastSignature: string | null = null;
|
|
60
|
-
private lastPulledAtMs = 0;
|
|
61
68
|
private hasPulledForTransport = new Set<string>();
|
|
62
69
|
private status: CloudSyncStatus = {
|
|
63
70
|
phase: "idle",
|
|
@@ -100,16 +107,20 @@ export class CloudSyncController {
|
|
|
100
107
|
return [...this.transports.values()];
|
|
101
108
|
}
|
|
102
109
|
|
|
103
|
-
setRuntime(runtime: SyncRuntime): void {
|
|
104
|
-
this.runtime
|
|
105
|
-
|
|
110
|
+
setRuntime(runtime: SyncRuntime): () => void {
|
|
111
|
+
if (this.runtime !== runtime) {
|
|
112
|
+
this.resetOperations();
|
|
113
|
+
this.runtime = runtime;
|
|
114
|
+
this.updateAvailability();
|
|
115
|
+
}
|
|
116
|
+
return () => this.clearRuntime(runtime);
|
|
106
117
|
}
|
|
107
118
|
|
|
108
|
-
clearRuntime(): void {
|
|
119
|
+
clearRuntime(runtime?: SyncRuntime): void {
|
|
120
|
+
if (runtime && this.runtime !== runtime) return;
|
|
109
121
|
this.runtime = null;
|
|
110
|
-
|
|
111
|
-
this.
|
|
112
|
-
this.syncQueued = false;
|
|
122
|
+
this.resetOperations();
|
|
123
|
+
this.setStatus({ phase: "disabled", transportId: null, error: null });
|
|
113
124
|
}
|
|
114
125
|
|
|
115
126
|
subscribe(listener: () => void): () => void {
|
|
@@ -124,11 +135,11 @@ export class CloudSyncController {
|
|
|
124
135
|
schedulePush(reason: string): void {
|
|
125
136
|
const runtime = this.runtime;
|
|
126
137
|
if (!runtime) return;
|
|
127
|
-
const
|
|
128
|
-
if (!
|
|
138
|
+
const registration = runtime.getTransport();
|
|
139
|
+
if (!registration?.transport.isAvailable()) {
|
|
129
140
|
this.setStatus({
|
|
130
141
|
phase: "disabled",
|
|
131
|
-
transportId:
|
|
142
|
+
transportId: registration?.transport.id ?? null,
|
|
132
143
|
error: null,
|
|
133
144
|
});
|
|
134
145
|
return;
|
|
@@ -146,6 +157,7 @@ export class CloudSyncController {
|
|
|
146
157
|
return this.inFlight;
|
|
147
158
|
}
|
|
148
159
|
const run = this.syncOnce(options).finally(async () => {
|
|
160
|
+
if (this.inFlight !== run) return;
|
|
149
161
|
this.inFlight = null;
|
|
150
162
|
if (!this.syncQueued) return;
|
|
151
163
|
this.syncQueued = false;
|
|
@@ -155,42 +167,36 @@ export class CloudSyncController {
|
|
|
155
167
|
return run;
|
|
156
168
|
}
|
|
157
169
|
|
|
158
|
-
async pullLatest(options: { force?: boolean } = {}): Promise<void> {
|
|
159
|
-
const runtime = this.runtime;
|
|
160
|
-
if (!runtime) return;
|
|
161
|
-
const transport = runtime.getTransport();
|
|
162
|
-
if (!transport?.transport.isAvailable()) {
|
|
163
|
-
this.setStatus({ phase: "disabled", transportId: transport?.transport.id ?? null, error: null });
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
const currentMs = Date.now();
|
|
167
|
-
if (!options.force && currentMs - this.lastPulledAtMs < PULL_MIN_INTERVAL_MS) return;
|
|
168
|
-
if (await this.runPull(runtime, transport.transport)) {
|
|
169
|
-
this.lastPulledAtMs = currentMs;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
170
|
private async syncOnce(options: { reason?: string; force?: boolean }): Promise<void> {
|
|
174
171
|
const runtime = this.runtime;
|
|
175
172
|
if (!runtime) return;
|
|
176
|
-
const
|
|
177
|
-
if (!
|
|
178
|
-
this.setStatus({
|
|
173
|
+
const registration = runtime.getTransport();
|
|
174
|
+
if (!registration?.transport.isAvailable()) {
|
|
175
|
+
this.setStatus({
|
|
176
|
+
phase: "disabled",
|
|
177
|
+
transportId: registration?.transport.id ?? null,
|
|
178
|
+
error: null,
|
|
179
|
+
});
|
|
179
180
|
return;
|
|
180
181
|
}
|
|
181
|
-
const transport =
|
|
182
|
+
const transport = registration.transport;
|
|
183
|
+
|
|
182
184
|
if (!this.hasPulledForTransport.has(transport.id)) {
|
|
185
|
+
this.lastSignature = null;
|
|
183
186
|
if (!await this.runPull(runtime, transport)) return;
|
|
187
|
+
if (!this.isCurrent(runtime, transport)) return;
|
|
184
188
|
this.hasPulledForTransport.add(transport.id);
|
|
185
189
|
}
|
|
186
190
|
|
|
187
191
|
const snapshot = await this.assembleSnapshot(runtime);
|
|
188
|
-
|
|
192
|
+
if (!this.isCurrent(runtime, transport)) return;
|
|
193
|
+
const signature = snapshotContentSignature(snapshot);
|
|
189
194
|
if (!options.force && signature === this.lastSignature) return;
|
|
190
195
|
|
|
191
196
|
this.setStatus({ phase: "syncing", transportId: transport.id, error: null });
|
|
192
197
|
try {
|
|
193
198
|
const result = await transport.pushSnapshot(snapshot, { baseRevision: this.status.revision });
|
|
199
|
+
if (!this.isCurrent(runtime, transport)) return;
|
|
194
200
|
this.lastSignature = signature;
|
|
195
201
|
this.setStatus({
|
|
196
202
|
phase: "synced",
|
|
@@ -200,6 +206,7 @@ export class CloudSyncController {
|
|
|
200
206
|
error: null,
|
|
201
207
|
});
|
|
202
208
|
} catch (error) {
|
|
209
|
+
if (!this.isCurrent(runtime, transport)) return;
|
|
203
210
|
this.setStatus({
|
|
204
211
|
phase: "error",
|
|
205
212
|
transportId: transport.id,
|
|
@@ -209,9 +216,30 @@ export class CloudSyncController {
|
|
|
209
216
|
}
|
|
210
217
|
|
|
211
218
|
private async runPull(runtime: SyncRuntime, transport: SyncTransport): Promise<boolean> {
|
|
219
|
+
const baselineState = runtime.getState();
|
|
212
220
|
this.setStatus({ phase: "syncing", transportId: transport.id, error: null });
|
|
213
221
|
try {
|
|
214
222
|
const response = await transport.pullSnapshot();
|
|
223
|
+
if (!this.isCurrent(runtime, transport)) return false;
|
|
224
|
+
|
|
225
|
+
if (response.snapshot) {
|
|
226
|
+
for (const entry of runtime.getContributors()) {
|
|
227
|
+
if (!this.isCurrent(runtime, transport)) return false;
|
|
228
|
+
const payload = response.snapshot.contributors[entry.contributor.id]?.payload;
|
|
229
|
+
if (payload === undefined || !entry.contributor.apply) continue;
|
|
230
|
+
await entry.contributor.apply(payload, {
|
|
231
|
+
snapshot: response.snapshot,
|
|
232
|
+
baselineState,
|
|
233
|
+
state: runtime.getState(),
|
|
234
|
+
getState: runtime.getState,
|
|
235
|
+
isCurrent: () => this.isCurrent(runtime, transport),
|
|
236
|
+
dispatch: runtime.dispatch,
|
|
237
|
+
tickerRepository: runtime.tickerRepository,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!this.isCurrent(runtime, transport)) return false;
|
|
215
243
|
this.setStatus({
|
|
216
244
|
phase: response.snapshot ? "synced" : "idle",
|
|
217
245
|
transportId: transport.id,
|
|
@@ -220,19 +248,9 @@ export class CloudSyncController {
|
|
|
220
248
|
lastSyncAt: response.updatedAt,
|
|
221
249
|
error: null,
|
|
222
250
|
});
|
|
223
|
-
if (!response.snapshot) return true;
|
|
224
|
-
for (const entry of runtime.getContributors()) {
|
|
225
|
-
const payload = response.snapshot.contributors[entry.contributor.id]?.payload;
|
|
226
|
-
if (payload === undefined || !entry.contributor.apply) continue;
|
|
227
|
-
await entry.contributor.apply(payload, {
|
|
228
|
-
snapshot: response.snapshot,
|
|
229
|
-
state: runtime.state,
|
|
230
|
-
dispatch: runtime.dispatch,
|
|
231
|
-
tickerRepository: runtime.tickerRepository,
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
251
|
return true;
|
|
235
252
|
} catch (error) {
|
|
253
|
+
if (!this.isCurrent(runtime, transport)) return false;
|
|
236
254
|
this.setStatus({
|
|
237
255
|
phase: "error",
|
|
238
256
|
transportId: transport.id,
|
|
@@ -247,7 +265,7 @@ export class CloudSyncController {
|
|
|
247
265
|
const createdAt = nowIso();
|
|
248
266
|
const payloads: SyncSnapshot["contributors"] = {};
|
|
249
267
|
for (const entry of contributors) {
|
|
250
|
-
const payload = await entry.contributor.collect({ state: runtime.
|
|
268
|
+
const payload = await entry.contributor.collect({ state: runtime.getState() });
|
|
251
269
|
payloads[entry.contributor.id] = {
|
|
252
270
|
schemaVersion: entry.contributor.schemaVersion,
|
|
253
271
|
updatedAt: createdAt,
|
|
@@ -263,6 +281,19 @@ export class CloudSyncController {
|
|
|
263
281
|
};
|
|
264
282
|
}
|
|
265
283
|
|
|
284
|
+
private isCurrent(runtime: SyncRuntime, transport: SyncTransport): boolean {
|
|
285
|
+
return this.runtime === runtime && runtime.getTransport()?.transport === transport;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private resetOperations(): void {
|
|
289
|
+
if (this.pushTimer) clearTimeout(this.pushTimer);
|
|
290
|
+
this.pushTimer = null;
|
|
291
|
+
this.inFlight = null;
|
|
292
|
+
this.syncQueued = false;
|
|
293
|
+
this.hasPulledForTransport.clear();
|
|
294
|
+
this.lastSignature = null;
|
|
295
|
+
}
|
|
296
|
+
|
|
266
297
|
private updateAvailability(): void {
|
|
267
298
|
const transport = this.runtime?.getTransport() ?? null;
|
|
268
299
|
if (!transport) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { scheduleConfigSave } from "../state/config-save-scheduler";
|
|
2
|
+
import { stableStringify } from "../remote/revision";
|
|
2
3
|
import type { AppConfig, BrokerInstanceConfig } from "../types/config";
|
|
3
4
|
import type { PricePoint, TickerFinancials } from "../types/financials";
|
|
4
5
|
import type { Portfolio, TickerMetadata, TickerPosition, TickerRecord, Watchlist } from "../types/ticker";
|
|
@@ -295,20 +296,30 @@ function collectCoreCollectionsPayload(
|
|
|
295
296
|
};
|
|
296
297
|
}
|
|
297
298
|
|
|
298
|
-
function
|
|
299
|
+
function valuesEqual(left: unknown, right: unknown): boolean {
|
|
300
|
+
return stableStringify(left) === stableStringify(right);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function mergeConfigPayload(
|
|
304
|
+
config: AppConfig,
|
|
305
|
+
payload: unknown,
|
|
306
|
+
baselineConfig: AppConfig = config,
|
|
307
|
+
): AppConfig | null {
|
|
299
308
|
if (!isPlainObject(payload)) return null;
|
|
300
309
|
const next: AppConfig = { ...config };
|
|
310
|
+
const canApply = <K extends keyof AppConfig>(key: K) => (
|
|
311
|
+
valuesEqual(config[key], baselineConfig[key])
|
|
312
|
+
);
|
|
301
313
|
const assign = <K extends keyof AppConfig>(key: K) => {
|
|
302
|
-
if (key in payload) {
|
|
314
|
+
if (key in payload && canApply(key)) {
|
|
303
315
|
next[key] = payload[key as string] as AppConfig[K];
|
|
304
316
|
}
|
|
305
317
|
};
|
|
306
318
|
|
|
307
319
|
assign("baseCurrency");
|
|
308
320
|
assign("refreshIntervalMinutes");
|
|
309
|
-
assign("
|
|
310
|
-
assign("
|
|
311
|
-
assign("activeLayoutIndex");
|
|
321
|
+
assign("portfolios");
|
|
322
|
+
assign("watchlists");
|
|
312
323
|
assign("disabledPlugins");
|
|
313
324
|
assign("disabledSources");
|
|
314
325
|
assign("pluginConfig");
|
|
@@ -318,9 +329,21 @@ function mergeConfigPayload(config: AppConfig, payload: unknown): AppConfig | nu
|
|
|
318
329
|
assign("recentTickers");
|
|
319
330
|
assign("onboardingComplete");
|
|
320
331
|
|
|
321
|
-
if (
|
|
322
|
-
|
|
323
|
-
|
|
332
|
+
if (
|
|
333
|
+
["layout", "layouts", "activeLayoutIndex"].every((key) => (
|
|
334
|
+
key in payload &&
|
|
335
|
+
valuesEqual(
|
|
336
|
+
config[key as keyof AppConfig],
|
|
337
|
+
baselineConfig[key as keyof AppConfig],
|
|
338
|
+
)
|
|
339
|
+
))
|
|
340
|
+
) {
|
|
341
|
+
next.layout = payload.layout as unknown as AppConfig["layout"];
|
|
342
|
+
next.layouts = payload.layouts as AppConfig["layouts"];
|
|
343
|
+
next.activeLayoutIndex = payload.activeLayoutIndex as number;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (Array.isArray(payload.brokerInstances) && canApply("brokerInstances")) {
|
|
324
347
|
const incoming = payload.brokerInstances as Array<Partial<BrokerInstanceConfig>>;
|
|
325
348
|
const existingById = new Map(config.brokerInstances.map((instance) => [instance.id, instance]));
|
|
326
349
|
next.brokerInstances = incoming.map((instance) => {
|
|
@@ -343,9 +366,9 @@ export const coreConfigSyncContributor: SyncContributor = {
|
|
|
343
366
|
id: "core.config",
|
|
344
367
|
schemaVersion: 1,
|
|
345
368
|
collect: ({ state }) => collectCoreConfigPayload(state.config),
|
|
346
|
-
apply: (payload, { state, dispatch }) => {
|
|
347
|
-
const nextConfig = mergeConfigPayload(state.config, payload);
|
|
348
|
-
if (!nextConfig) return;
|
|
369
|
+
apply: (payload, { baselineState, state, dispatch }) => {
|
|
370
|
+
const nextConfig = mergeConfigPayload(state.config, payload, baselineState.config);
|
|
371
|
+
if (!nextConfig || valuesEqual(nextConfig, state.config)) return;
|
|
349
372
|
dispatch({ type: "SET_CONFIG", config: nextConfig });
|
|
350
373
|
scheduleConfigSave(nextConfig);
|
|
351
374
|
},
|
|
@@ -360,18 +383,15 @@ export const coreCollectionsSyncContributor: SyncContributor = {
|
|
|
360
383
|
state.financials,
|
|
361
384
|
state.exchangeRates,
|
|
362
385
|
),
|
|
363
|
-
apply: async (payload, {
|
|
386
|
+
apply: async (payload, { getState, isCurrent, dispatch, tickerRepository }) => {
|
|
364
387
|
if (!isPlainObject(payload)) return;
|
|
365
|
-
const
|
|
366
|
-
if (Array.isArray(payload.portfolios)) nextConfig.portfolios = payload.portfolios as Portfolio[];
|
|
367
|
-
if (Array.isArray(payload.watchlists)) nextConfig.watchlists = payload.watchlists as Watchlist[];
|
|
368
|
-
|
|
369
|
-
const nextTickers = new Map(state.tickers);
|
|
388
|
+
const incomingRecords: TickerRecord[] = [];
|
|
370
389
|
const rawTickers = Array.isArray(payload.tickers) ? payload.tickers : [];
|
|
371
390
|
for (const rawTicker of rawTickers) {
|
|
391
|
+
if (!isCurrent()) return;
|
|
372
392
|
if (!isPlainObject(rawTicker)) continue;
|
|
373
393
|
const current = typeof rawTicker.ticker === "string"
|
|
374
|
-
?
|
|
394
|
+
? getState().tickers.get(rawTicker.ticker)
|
|
375
395
|
: null;
|
|
376
396
|
const metadata = hydrateTickerMetadata({
|
|
377
397
|
...current?.metadata,
|
|
@@ -380,12 +400,15 @@ export const coreCollectionsSyncContributor: SyncContributor = {
|
|
|
380
400
|
});
|
|
381
401
|
const record: TickerRecord = { metadata };
|
|
382
402
|
await tickerRepository.saveTicker(record);
|
|
383
|
-
|
|
403
|
+
incomingRecords.push(record);
|
|
384
404
|
}
|
|
385
405
|
|
|
386
|
-
|
|
406
|
+
if (!isCurrent() || incomingRecords.length === 0) return;
|
|
407
|
+
const nextTickers = new Map(getState().tickers);
|
|
408
|
+
for (const record of incomingRecords) {
|
|
409
|
+
nextTickers.set(record.metadata.ticker, record);
|
|
410
|
+
}
|
|
387
411
|
dispatch({ type: "SET_TICKERS", tickers: nextTickers });
|
|
388
|
-
scheduleConfigSave(nextConfig);
|
|
389
412
|
},
|
|
390
413
|
};
|
|
391
414
|
|
|
@@ -397,4 +420,5 @@ export const __syncContributorInternalsForTests = {
|
|
|
397
420
|
sanitizeUnknown,
|
|
398
421
|
collectCoreConfigPayload,
|
|
399
422
|
collectCoreCollectionsPayload,
|
|
423
|
+
mergeConfigPayload,
|
|
400
424
|
};
|
package/src/sync/react.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { cloudSyncController } from "./controller";
|
|
|
6
6
|
|
|
7
7
|
interface CloudSyncRuntimeOptions {
|
|
8
8
|
state: AppState;
|
|
9
|
+
getState: () => AppState;
|
|
9
10
|
dispatch: Dispatch<AppAction>;
|
|
10
11
|
tickerRepository: TickerRepository;
|
|
11
12
|
pluginRegistry: PluginRegistry;
|
|
@@ -14,28 +15,25 @@ interface CloudSyncRuntimeOptions {
|
|
|
14
15
|
|
|
15
16
|
export function useCloudSyncRuntime({
|
|
16
17
|
state,
|
|
18
|
+
getState,
|
|
17
19
|
dispatch,
|
|
18
20
|
tickerRepository,
|
|
19
21
|
pluginRegistry,
|
|
20
22
|
initialized,
|
|
21
23
|
}: CloudSyncRuntimeOptions): void {
|
|
22
24
|
useEffect(() => {
|
|
23
|
-
cloudSyncController.setRuntime({
|
|
24
|
-
|
|
25
|
+
return cloudSyncController.setRuntime({
|
|
26
|
+
getState,
|
|
25
27
|
dispatch,
|
|
26
28
|
tickerRepository,
|
|
27
29
|
getContributors: () => pluginRegistry.getEnabledSyncContributors(),
|
|
28
30
|
getTransport: () => pluginRegistry.getActiveSyncTransport(),
|
|
29
31
|
});
|
|
30
|
-
}, [dispatch,
|
|
31
|
-
|
|
32
|
-
useEffect(() => () => {
|
|
33
|
-
cloudSyncController.clearRuntime();
|
|
34
|
-
}, []);
|
|
32
|
+
}, [dispatch, getState, pluginRegistry, tickerRepository]);
|
|
35
33
|
|
|
36
34
|
useEffect(() => {
|
|
37
35
|
if (!initialized) return;
|
|
38
|
-
void cloudSyncController.
|
|
36
|
+
void cloudSyncController.requestSync({ reason: "startup" });
|
|
39
37
|
}, [initialized, pluginRegistry]);
|
|
40
38
|
|
|
41
39
|
useEffect(() => {
|
package/src/sync/types.ts
CHANGED
|
@@ -25,7 +25,10 @@ export interface SyncCollectContext {
|
|
|
25
25
|
|
|
26
26
|
export interface SyncApplyContext {
|
|
27
27
|
snapshot: SyncSnapshot;
|
|
28
|
+
baselineState: AppState;
|
|
28
29
|
state: AppState;
|
|
30
|
+
getState: () => AppState;
|
|
31
|
+
isCurrent: () => boolean;
|
|
29
32
|
dispatch: Dispatch<AppAction>;
|
|
30
33
|
tickerRepository: TickerRepository;
|
|
31
34
|
}
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.9.
|
|
1
|
+
export const VERSION = "0.9.10";
|