From 8d26c716b4528aed886655ce217d614645adb63f Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 29 Oct 2026 12:49:47 -0500 Subject: [PATCH] feat: look up local row ids by sync id for the sync plugin (Plugin-Sync#4) --- package-lock.json | 2 +- packages/plugin-sdk/CHANGELOG.md | 1 + packages/plugin-sdk/src/backend.ts | 6 ++++++ src/backend/sync/host.ts | 6 ++++++ src/backend/sync/store.ts | 15 +++++++++++++++ src/backend/tests/sync/host.test.ts | 27 +++++++++++++++++++++++++-- 6 files changed, 54 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2524db738..957ec17d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15058,7 +15058,7 @@ }, "packages/plugin-sdk": { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.13", + "version": "1.0.22", "license": "Apache-2.0", "dependencies": { "@deadendjs/swagger-jsdoc": "^8.1.2", diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index f95f4a1df..748b1daa2 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -10,6 +10,7 @@ Plugin API 1.13.0. - `termix-plugin build` bundles `src/mobile` into the `contributes.mobile.entry` file, and `validate` checks that it exists - `@termix-ssh/plugin-sdk/frame-host` runs one plugin view outside the shell, with core's bridge, registries and CSS, so the mobile app can show `webview` plugins - SSH transport providers get the host's owner, sync id, connection origin and local only flag in `claims` and `open`, and core's SSH relay takes a `syncId` instead of a host id +- `ctx.sync.host.entities.idsOf` maps sync ids to the local ids of the user's own rows of one type ### Fixed diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index 7f3776b6a..03f529f62 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -470,6 +470,12 @@ export interface PluginSyncHost { ensureCore: () => Promise; onChanged: (listener: () => void) => () => void; onLocalWrite: (listener: () => void) => () => void; + /** Local ids of the user's own rows of a type, by sync id. Termix 26.10.6+. */ + idsOf?: ( + type: string, + userId: string, + syncIds: string[], + ) => Promise>; }; /** Rows and bookkeeping of one local user's replica. */ diff --git a/src/backend/sync/host.ts b/src/backend/sync/host.ts index cacbdef4c..0a1d8fdae 100644 --- a/src/backend/sync/host.ts +++ b/src/backend/sync/host.ts @@ -38,6 +38,7 @@ import { hosts, sshCredentials } from "../database/db/schema.js"; import { createResolvers, deleteStoredRow, + findStoredIds, listStoredRows, loadWireRow, loadWireRows, @@ -271,6 +272,11 @@ export function createSyncHost(): PluginSyncHost { ensureCore: () => ensureCoreEntities(), onChanged: (listener) => onEntitiesChanged(listener), onLocalWrite: (listener) => onLocalWrite(listener), + idsOf: async (type, userId, syncIds) => { + const entity = getEntity(type); + if (!entity || entity.load) return {}; + return findStoredIds(entity, userId, syncIds); + }, }, replica: { diff --git a/src/backend/sync/store.ts b/src/backend/sync/store.ts index 2d6426e18..4fb82c3e2 100644 --- a/src/backend/sync/store.ts +++ b/src/backend/sync/store.ts @@ -112,6 +112,21 @@ export async function findStoredRow( return row ?? null; } +/** The local ids of the user's own rows, by sync id. Unknown ones are left out. */ +export async function findStoredIds( + entity: RegisteredSyncEntity, + userId: string, + syncIds: string[], +): Promise> { + const ids: Record = {}; + if (entity.singleton || !entity.table) return ids; + for (const syncId of new Set(syncIds)) { + const row = await findStoredRow(entity, userId, syncId); + if (row && typeof row.id === "number") ids[syncId] = row.id; + } + return ids; +} + /** Gives an old row with no syncId one, so it can take part. */ async function ensureSyncId( entity: RegisteredSyncEntity, diff --git a/src/backend/tests/sync/host.test.ts b/src/backend/tests/sync/host.test.ts index e08fcb7bd..0f5273e82 100644 --- a/src/backend/tests/sync/host.test.ts +++ b/src/backend/tests/sync/host.test.ts @@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({ getLink: vi.fn(), generateJWTToken: vi.fn(), revokeSession: vi.fn(), + getEntity: vi.fn(), + findStoredIds: vi.fn(), })); vi.mock("../../utils/auth-manager.js", () => ({ @@ -28,7 +30,7 @@ vi.mock("../../utils/app-version.js", () => ({ vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert: vi.fn() })); vi.mock("../../plugins/index.js", () => ({ getPluginRuntime: vi.fn() })); vi.mock("../../plugins/sync-registry.js", () => ({ - getEntity: vi.fn(), + getEntity: mocks.getEntity, listEntities: () => [{ type: "hosts", owner: "core", order: 10 }], onEntitiesChanged: vi.fn(), })); @@ -55,7 +57,9 @@ vi.mock("../../utils/permission-manager.js", () => ({ vi.mock("../../sync/entities.js", () => ({ registerCoreSyncEntities: vi.fn(), })); -vi.mock("../../sync/store.js", () => ({})); +vi.mock("../../sync/store.js", () => ({ + findStoredIds: mocks.findStoredIds, +})); vi.mock("../../sync/wire.js", () => ({})); vi.mock("../../sync/records.js", () => ({ listChanges: mocks.listChanges, @@ -215,3 +219,22 @@ describe("host.links", () => { }); }); }); + +describe("host.entities.idsOf", () => { + it("maps sync ids to the user's own row ids", async () => { + const entity = { type: "snippets", table: {} }; + mocks.getEntity.mockReturnValue(entity); + mocks.findStoredIds.mockResolvedValue({ a: 3 }); + expect( + await createSyncHost().entities.idsOf!("snippets", "u", ["a", "b"]), + ).toEqual({ a: 3 }); + expect(mocks.findStoredIds).toHaveBeenCalledWith(entity, "u", ["a", "b"]); + }); + + it("answers nothing for an unknown or built type", async () => { + mocks.getEntity.mockReturnValue(undefined); + expect(await createSyncHost().entities.idsOf!("x", "u", ["a"])).toEqual({}); + mocks.getEntity.mockReturnValue({ type: "y", load: vi.fn() }); + expect(await createSyncHost().entities.idsOf!("y", "u", ["a"])).toEqual({}); + }); +});