From e1ebb0df8db703f89617c02d47fc35fb3c10ebb4 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Thu, 15 Oct 2026 11:07:15 +1100 Subject: [PATCH] feat(root): add sourceResync and sourceResyncStatus Start a resync of the MQTT brokers, optionally clearing everything PlaceOS publishes first, and read where it got to (PlaceOS/rest-api#454). For the backoffice database page. Co-Authored-By: Claude Opus 5.5 --- src/api.ts | 3 +++ src/root/functions.ts | 23 ++++++++++++++++++- src/root/interfaces.ts | 14 ++++++++++++ test/root/functions.spec.ts | 45 +++++++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 test/root/functions.spec.ts diff --git a/src/api.ts b/src/api.ts index 9ad82d7d9..f67824f21 100644 --- a/src/api.ts +++ b/src/api.ts @@ -349,9 +349,12 @@ export { platformInfo, serviceVersion, signal, + sourceResync, + sourceResyncStatus, } from './root/functions'; export type { PlacePlatformInfo, + PlaceSourceResyncStatus, PlaceVersion, SignalOptions, } from './root/interfaces'; diff --git a/src/root/functions.ts b/src/root/functions.ts index d8da4f994..71441c11b 100644 --- a/src/root/functions.ts +++ b/src/root/functions.ts @@ -2,7 +2,12 @@ import { apiEndpoint } from '../auth/functions'; import { get, post } from '../http/functions'; import { toQueryString } from '../utilities/api'; import { HashMap } from '../utilities/types'; -import { PlacePlatformInfo, PlaceVersion, SignalOptions } from './interfaces'; +import { + PlacePlatformInfo, + PlaceSourceResyncStatus, + PlaceVersion, + SignalOptions, +} from './interfaces'; /** Check API health */ export function healthCheck(): Promise { @@ -29,6 +34,22 @@ export function apiScopes(): Promise { return get(`${apiEndpoint()}/scopes`) as any; } +/** + * Publish every module's state and every model's metadata to the MQTT brokers + * again. With `clear`, everything PlaceOS publishes is removed from the brokers + * first, for when what they hold has drifted. Runs in the background; follow + * it with `sourceResyncStatus`. Rejects with a 409 while one is running. + * Needs a system admin. + */ +export function sourceResync(clear = false): Promise { + return post(`${apiEndpoint()}/source/resync?clear=${clear}`, {}) as any; +} + +/** Where the last resync of the MQTT brokers got to. Needs a system admin */ +export function sourceResyncStatus(): Promise { + return get(`${apiEndpoint()}/source/resync`) as any; +} + /** Signal a channel in a similar manner to a webhook for drivers */ export function signal(channel: string, body: HashMap = {}): Promise { const q = toQueryString({ channel } as SignalOptions); diff --git a/src/root/interfaces.ts b/src/root/interfaces.ts index be455e33f..f030a2614 100644 --- a/src/root/interfaces.ts +++ b/src/root/interfaces.ts @@ -14,3 +14,17 @@ export interface PlaceVersion { export interface SignalOptions { channel: string; } + +/** Where a resync of the MQTT brokers got to */ +export interface PlaceSourceResyncStatus { + state: 'idle' | 'clearing' | 'resyncing' | 'complete' | 'failed'; + /** Whether everything source publishes was removed from the brokers first */ + clear: boolean; + started_at?: string; + finished_at?: string; + /** Retained topics removed, when clearing */ + cleared: number; + /** Models whose metadata was published again */ + metadata: number; + error?: string; +} diff --git a/test/root/functions.spec.ts b/test/root/functions.spec.ts new file mode 100644 index 000000000..17ccd7d80 --- /dev/null +++ b/test/root/functions.spec.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import * as Auth from '../../src/auth/functions'; +import * as Http from '../../src/http/functions'; +import * as SERVICE from '../../src/root/functions'; + +describe('Root API', () => { + afterEach(() => vi.restoreAllMocks()); + + const status = { + state: 'clearing', + clear: true, + started_at: '2026-10-15T00:00:00Z', + cleared: 0, + metadata: 0, + }; + + test('should allow starting a resync of the MQTT brokers', async () => { + vi.spyOn(Auth, 'apiEndpoint').mockReturnValue('/api/engine/v2'); + const http = vi + .spyOn(Http, 'post') + .mockImplementation(() => Promise.resolve(status) as any); + + expect(await SERVICE.sourceResync(true)).toEqual(status); + expect(http).toHaveBeenCalledWith( + '/api/engine/v2/source/resync?clear=true', + {}, + ); + + await SERVICE.sourceResync(); + expect(http).toHaveBeenLastCalledWith( + '/api/engine/v2/source/resync?clear=false', + {}, + ); + }); + + test('should allow reading where the resync got to', async () => { + vi.spyOn(Auth, 'apiEndpoint').mockReturnValue('/api/engine/v2'); + const http = vi + .spyOn(Http, 'get') + .mockImplementation(() => Promise.resolve(status) as any); + + expect(await SERVICE.sourceResyncStatus()).toEqual(status); + expect(http).toHaveBeenCalledWith('/api/engine/v2/source/resync'); + }); +});