diff --git a/.dagger/src/index.ts b/.dagger/src/index.ts
index c177cf9aef..dc4e0a4b36 100644
--- a/.dagger/src/index.ts
+++ b/.dagger/src/index.ts
@@ -636,6 +636,10 @@ export class AtomicServer {
this.source.directory('plugin-runtime'),
)
.withDirectory('/code/wasm', this.source.directory('wasm'))
+ .withDirectory(
+ '/code/integrations/localthought/syncables',
+ this.source.directory('integrations/localthought/syncables'),
+ )
.withDirectory('/code/server', this.source.directory('server'))
.withDirectory('/code/cli', this.source.directory('cli'))
.withDirectory('/code/desktop', this.source.directory('desktop'))
@@ -713,6 +717,10 @@ export class AtomicServer {
.withDirectory('/code/cli', this.source.directory('cli'))
.withDirectory('/code/desktop', this.source.directory('desktop'))
.withDirectory('/code/wasm', this.source.directory('wasm'))
+ .withDirectory(
+ '/code/integrations/localthought/syncables',
+ this.source.directory('integrations/localthought/syncables'),
+ )
.withDirectory(
'/code/plugin-examples',
this.source.directory('plugin-examples'),
@@ -1127,7 +1135,9 @@ export class AtomicServer {
// Surfaces /app/dev-drive and /app/prunetests in the production
// build the e2e tests run against. See `devRoutesEnabled()` in
// data-browser/src/config.ts.
- buildContainer = buildContainer.withEnvVariable('VITE_E2E', 'true');
+ buildContainer = buildContainer
+ .withEnvVariable('VITE_E2E', 'true')
+ .withEnvVariable('VITE_INTEGRATION_PROXY_URL', 'http://127.0.0.1:19090');
}
return buildContainer.withExec(['pnpm', 'run', 'build']);
@@ -1182,6 +1192,10 @@ export class AtomicServer {
.withDirectory('/code/cli', source.directory('cli'))
.withDirectory('/code/desktop', source.directory('desktop'))
.withDirectory('/code/wasm', source.directory('wasm'))
+ .withDirectory(
+ '/code/integrations/localthought/syncables',
+ source.directory('integrations/localthought/syncables'),
+ )
.withDirectory(
'/code/plugin-examples',
source.directory('plugin-examples'),
@@ -1380,6 +1394,10 @@ export class AtomicServer {
.withDirectory('/code/cli', source.directory('cli'))
.withDirectory('/code/desktop', source.directory('desktop'))
.withDirectory('/code/wasm', source.directory('wasm'))
+ .withDirectory(
+ '/code/integrations/localthought/syncables',
+ source.directory('integrations/localthought/syncables'),
+ )
.withDirectory(
'/code/plugin-examples',
source.directory('plugin-examples'),
diff --git a/.github/workflows/pets-e2e.yml b/.github/workflows/pets-e2e.yml
index 37f32a802c..ab799cfe08 100644
--- a/.github/workflows/pets-e2e.yml
+++ b/.github/workflows/pets-e2e.yml
@@ -2,7 +2,7 @@ name: LocalThought API plugins E2E
on:
push:
- branches: [feat/api-plugins, codex/localthought-api-plugins]
+ branches: [feat/api-plugins, codex/localthought-api-plugins, codex/browser-integrations]
permissions:
contents: read
diff --git a/Cargo.lock b/Cargo.lock
index 7f5b7ea106..e675c1e34f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1282,7 +1282,6 @@ dependencies = [
"sha1 0.11.0",
"simple-server-timing-header",
"static-files 0.3.1",
- "syncables",
"tempfile",
"text-splitter",
"tokio",
@@ -1345,14 +1344,17 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
name = "atomic-wasm"
version = "0.1.0"
dependencies = [
+ "async-trait",
"atomic_lib",
"blake3",
+ "chrono",
"console_error_panic_hook",
"getrandom 0.3.4",
"js-sys",
"serde",
"serde-wasm-bindgen",
"serde_json",
+ "syncables",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test",
@@ -13442,7 +13444,6 @@ dependencies = [
[[package]]
name = "syncables"
version = "0.1.0"
-source = "git+https://github.com/localthought/syncables-rs?rev=d48e4d9bad3ed9ec826d1c2040e989171701965d#d48e4d9bad3ed9ec826d1c2040e989171701965d"
dependencies = [
"async-trait",
"httpdate",
@@ -13453,7 +13454,6 @@ dependencies = [
"serde_yaml_ng",
"thiserror 2.0.18",
"tokio",
- "uuid",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index a575b5bc79..740f704af9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,7 +14,7 @@ members = [
"plugin-runtime",
"tools/cargo-bin",
]
-exclude = ["flutter/rust"]
+exclude = ["flutter/rust", "integrations/localthought/syncables"]
# Debuginfo dominates target/ size: with ~1460 deps (tauri, actix, iroh) the
# default `debug = true` produces a multi-GB tree per build flavor, and this
diff --git a/TESTING_COVERAGE.md b/TESTING_COVERAGE.md
index 6e3c8f1b5a..6d2d376406 100644
--- a/TESTING_COVERAGE.md
+++ b/TESTING_COVERAGE.md
@@ -1,5 +1,36 @@
# Testing coverage map
+LocalThought browser migration: `integrations/localthought/browser.test.ts`
+covers tenant HMAC, actor/drive ownership, rotation before dispatch, pagination,
+uncertain-response refusal and cross-origin pagination refusal. The real generated
+WASM bundle is exercised by `wasm-smoke.mjs` for pagination, typed ontology,
+timestamps and provider failures. `browser-smoke.mjs` exercises the complete
+mock consent/import/review/OPFS/reload journey with AtomicServer unavailable
+(verified locally). Local installation/schema lookup tests reject missing or
+incomplete local databases rather than inferring permission to create duplicates.
+The companion Syncables branch has 142 passing native tests and a wasm32 build;
+the companion proxy branch has 39 passing tests including CORS preflight and
+exposed headers. Live OAuth on the browser path still requires deployment of
+the companion proxy CORS change and is not yet verified.
+
+`browser/e2e/tests/devonian-issue-sync.spec.mts` exercises tenant-secret entry,
+proxy consent, direct HTTP writes and local OPFS storage for two-way issue
+creation, comments, close/reopen and reload without duplicate resources. Its
+stateful HTTP mock isolates repositories and consumes/rotates connection codes;
+it does not substitute the in-page sample transport.
+
+The browser-only Devonian issue tracker demo has focused tests under
+`integrations/github-issues/devonian`: real Devonian lenses with deterministic
+connectors exercise bidirectional issue/comment creation and edits, close/reopen,
+distinct identical resources, conflicts, missing records and restart/replay.
+Transport fixtures cover pagination, label preservation, scoped comment links,
+rotating connection codes and refusal to resend uncertain writes. The native
+OPFS browser flow was manually verified for creation and comments on both sides,
+closing from Atomic, reopening from the sample GitHub side and reloading without
+duplicate issues/comments. Live proxy OAuth,
+GitHub writes and a guided uncertain-write recovery UI remain unverified/unbuilt;
+proxy v40 CORS and browser OAuth are verified, but its GitHub credential returns 404 for the private sandbox.
+
What is tested, at which layer, and — the part that matters — **what is not**.
This exists because the protocol is far better tested than the glue around it,
@@ -65,12 +96,12 @@ return to the same drive, rotating connection codes, two-page Syncables fetch,
review/apply, and five displayed records with integer/boolean/float/timestamp
properties. Dagger starts the mock for E2E; local runs opt in with
`ATOMIC_MOCK_INTEGRATION_PROXY=1` and the README configuration.
-`integration_proxy` Rust tests cover actor/drive binding, tenant HMAC,
+`browser.test.ts` and the real WASM smoke cover actor/drive binding, tenant HMAC,
Syncables pagination/ontology and duplicate-page refusal. The mock's Node test
covers invalid tenant proofs and replayed/rotated codes. The mapping tests cover
typed proposals, missing identities, repeat imports, local edits and duplicates.
-Live catalog OAD endpoints currently return 404 (integration-proxy #25), so live
-OAuth and GitHub data fetching are not yet certified.
+The historical server path was live-verified for GitHub and Google Calendar.
+The new browser path awaits deployment of the companion proxy CORS change.
Run it against a production build to catch missing translation catalog entries:
Vite dev extracts them automatically and can hide blank production labels.
The GitHub setup flow also covers opting into assistant-led automation creation:
@@ -1053,3 +1084,15 @@ cancelled on teardown. Old plugin-name grants are deliberately not migrated.
commit. It failed with the server signer before `Resource::destroy_as` was used;
installation deletion must use the same selected identity as create/update.
LocalThought: Rust handler tests cover connection binding, request signing, duplicate-page rejection, typed paginated previews, and Calendar UTC date-range validation. Live Calendar OAuth, bounded fetch, review/apply and event table display were verified against proxy v39 (54 records).
+
+Google Calendar one-way projection: `integrations/localthought/calendar.test.ts`
+covers all-day/timed start dates, offset boundaries, exclusive end preservation,
+feature notes (including WASM-normalized field names), cancellations without
+start data, invalid active events, namespace isolation and repeat import/local
+field preservation. `browser/e2e/tests/google-calendar-import.spec.mts` uses the
+shared HTTP mock integration-proxy with a paginated Google Calendar, tenant
+secret entry and OAuth consent. It covers browser WASM fetching, local
+schema/proposal/apply, Calendar display, provider updates, OPFS reload and
+stable identities while AtomicServer HTTP/WebSockets are unavailable. Missing
+rows in a bounded snapshot are retained, not interpreted as deletions.
+Live-provider browser OAuth verification remains separate from this fixture test.
diff --git a/browser/data-browser/src/chunks/DevonianDemo/DEVONIAN-LICENSE b/browser/data-browser/src/chunks/DevonianDemo/DEVONIAN-LICENSE
new file mode 100644
index 0000000000..8dada3edaf
--- /dev/null
+++ b/browser/data-browser/src/chunks/DevonianDemo/DEVONIAN-LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "{}"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright {yyyy} {name of copyright owner}
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/browser/data-browser/src/chunks/DevonianDemo/demo.d.mts b/browser/data-browser/src/chunks/DevonianDemo/demo.d.mts
new file mode 100644
index 0000000000..8055f7e558
--- /dev/null
+++ b/browser/data-browser/src/chunks/DevonianDemo/demo.d.mts
@@ -0,0 +1,47 @@
+import type { Store } from '@tomic/lib';
+export interface Demo {
+ key: string;
+ state: {
+ options: { sample: boolean };
+ config: { connection: { table: string } };
+ fixture: {
+ issues?: Array<{ number: number; title: string; state: string }>;
+ comments?: Array<{ id: number; body: string; issue_url: string }>;
+ };
+ };
+}
+export interface Row {
+ id: string;
+ value: { title: string; body: string; status: string };
+ comments: Array<{ id: string; value: { body: string } }>;
+}
+export function openDemo(
+ store: Store,
+ options: {
+ sample: boolean;
+ repository: string;
+ proxy: string;
+ },
+): Promise;
+export function syncDemo(store: Store, demo: Demo): Promise;
+export function demoRows(store: Store, demo: Demo): Promise;
+export function editAtomic(
+ store: Store,
+ demo: Demo,
+ command: string,
+ id?: string,
+ text?: string,
+): Promise;
+export function editFixture(
+ demo: Demo,
+ command: string,
+ number?: number,
+ text?: string,
+): Promise;
+
+export function connectDemo(
+ store: Store,
+ options: { repository: string; proxy: string },
+ secret: string,
+): Promise;
+export function resumeDemo(store: Store): Promise;
diff --git a/browser/data-browser/src/chunks/DevonianDemo/demo.mjs b/browser/data-browser/src/chunks/DevonianDemo/demo.mjs
new file mode 100644
index 0000000000..65615d2b21
--- /dev/null
+++ b/browser/data-browser/src/chunks/DevonianDemo/demo.mjs
@@ -0,0 +1,297 @@
+// @wc-ignore-file
+import { BrowserIntegrations } from '../../../../../integrations/localthought/browser';
+import { endpoint } from '../../../../../integrations/github-issues/adapter';
+import { get, set } from 'idb-keyval';
+import { core, server, dataBrowser, Datatype, enableLoro } from '@tomic/lib';
+import * as devonian from './devonian.js';
+import { ensureAgentForDemo } from '../Demo/guestAgent';
+import { buildTableFromSpec } from '../TablePage/createTableFromSpec';
+import { Bridge } from '../../../../../integrations/github-issues/devonian/bridge.mjs';
+import {
+ AtomicPort,
+ GitHubPort,
+} from '../../../../../integrations/github-issues/devonian/ports.mjs';
+import {
+ fixtureTransport,
+ proxyTransport,
+} from '../../../../../integrations/github-issues/devonian/proxy.mjs';
+
+export async function openDemo(store, options) {
+ await enableLoro();
+ await ensureAgentForDemo(store);
+ await store.waitForClientDb(10000);
+ const db = store.getClientDb();
+ if (!db || !(await db.waitForReady()))
+ throw new Error('Enable the browser database on the Sync page first.');
+ const repository = options.sample ? 'demo/issues' : options.repository;
+ endpoint(repository);
+ const key = `devonian-demo:${JSON.stringify([store.getAgent().subject, repository, options.sample ? 'sample' : new URL(options.proxy).origin])}`;
+ return navigator.locks.request(key, async () => {
+ let state = await get(key);
+ if (!state) {
+ const actor = store.getAgent().subject;
+ const drive = await store.newResource({
+ noParent: true,
+ isA: [server.classes.drive],
+ propVals: {
+ [core.properties.name]: options.sample
+ ? 'Devonian sample tracker'
+ : `GitHub: ${repository}`,
+ [core.properties.read]: [actor],
+ [core.properties.write]: [actor],
+ },
+ });
+ store.registerLocalOnlyDrive(drive.subject);
+ await drive.save();
+ await store.createDefaultOntology(drive);
+ const table = await buildTableFromSpec(
+ store,
+ {
+ name: 'Issue Tracker',
+ rowName: 'Issue',
+ columns: [
+ { name: 'Description', type: 'markdown' },
+ {
+ name: 'Status',
+ type: 'select',
+ options: ['Todo', 'Doing', 'Done'],
+ },
+ { name: 'GitHub issue number', type: 'number' },
+ ],
+ views: [
+ {
+ name: 'Board',
+ kind: 'kanban',
+ groupByColumn: 'Status',
+ default: true,
+ },
+ { name: 'All issues', kind: 'table' },
+ ],
+ },
+ {
+ parent: drive.subject,
+ driveSubject: drive.subject,
+ addToOntology: async () => {},
+ },
+ );
+ const provenance = await store.newResource({
+ parent: drive.subject,
+ isA: [core.classes.property],
+ propVals: {
+ [core.properties.name]: 'GitHub source',
+ [core.properties.shortname]: 'github-source',
+ [core.properties.description]:
+ 'Original author, identity and timestamps from GitHub.',
+ [core.properties.datatype]: Datatype.JSON,
+ },
+ });
+ await provenance.save();
+ const folder = await store.newResource({
+ parent: drive.subject,
+ isA: [dataBrowser.classes.folder],
+ propVals: { [core.properties.name]: 'Comments' },
+ });
+ await folder.save();
+ await drive.set(dataBrowser.properties.commentsFolder, folder.subject);
+ await drive.save();
+ const config = {
+ connection: {
+ repository,
+ drive: drive.subject,
+ table: table.tableSubject,
+ rowClass: table.classSubject,
+ body: table.columns.Description,
+ status: table.columns.Status,
+ number: table.columns['GitHub issue number'],
+ tags: table.tags.Status,
+ },
+ commentsFolder: folder.subject,
+ provenance: provenance.subject,
+ };
+ state = {
+ config,
+ options: { sample: options.sample, proxy: options.proxy, repository },
+ base: `https://atomicdata.dev/devonian-bridges/${crypto.randomUUID()}`,
+ fixture: {},
+ journal: {},
+ };
+ await db.flush();
+ await set(key, state);
+ }
+ store.registerLocalOnlyDrive(state.config.connection.drive);
+ store.setDrive(state.config.connection.drive);
+ return { key, state };
+ });
+}
+
+export async function syncDemo(store, demo) {
+ return navigator.locks.request(demo.key, async () => {
+ const state = await get(demo.key);
+ const save = () => set(demo.key, state);
+ const call = state.options.sample
+ ? fixtureTransport(state.fixture, save)
+ : proxyTransport({
+ url: state.options.proxy,
+ repository: state.options.repository,
+ journal: state.journal,
+ save,
+ dispatch: (path, init) =>
+ client(state.options.proxy).request(
+ state.config.connection.drive,
+ store.getAgent().subject,
+ state.connection,
+ 'github-issues',
+ path,
+ init,
+ ),
+ });
+ const bridge = new Bridge({
+ devonian,
+ local: new AtomicPort(store, state.config),
+ remote: new GitHubPort(null, state.config.connection, call),
+ base: state.base,
+ snapshot: state.bridge,
+ save: async snapshot => {
+ await store.getClientDb().flush();
+ state.bridge = snapshot;
+ await save();
+ },
+ });
+ await bridge.sync();
+ demo.state = state;
+ return Object.keys(bridge.records).length;
+ });
+}
+
+export async function demoRows(store, demo) {
+ const local = new AtomicPort(store, demo.state.config);
+ const issues = await local.list('issue');
+ for (const row of issues)
+ row.comments = await local.list('comment:ui', { issueId: row.id });
+ return issues;
+}
+
+export async function editAtomic(store, demo, command, id, text) {
+ const port = new AtomicPort(store, demo.state.config);
+ if (command === 'create')
+ await port.create(
+ 'issue',
+ { title: text, body: '', status: 'Todo' },
+ crypto.randomUUID(),
+ );
+ else if (command === 'comment')
+ await port.create(
+ 'comment:ui',
+ { body: text },
+ crypto.randomUUID(),
+ undefined,
+ { issueId: id },
+ );
+ else {
+ const row = await port.get('issue', id);
+ await port.update('issue', id, {
+ ...row.value,
+ status: row.value.status === 'Done' ? 'Todo' : 'Done',
+ });
+ }
+ await store.getClientDb().flush();
+}
+
+export async function editFixture(demo, command, number, text) {
+ return navigator.locks.request(demo.key, async () => {
+ const state = await get(demo.key);
+ const call = fixtureTransport(state.fixture, () => set(demo.key, state));
+ if (command === 'create')
+ await call(
+ 'create_issue',
+ { title: text, body: '' },
+ crypto.randomUUID(),
+ );
+ else if (command === 'comment')
+ await call('create_comment', { number, body: text }, crypto.randomUUID());
+ else {
+ const issue = state.fixture.issues.find(r => r.number === number);
+ await call(
+ 'update_issue',
+ {
+ number,
+ title: issue.title,
+ body: issue.body,
+ state: issue.state === 'open' ? 'closed' : 'open',
+ },
+ crypto.randomUUID(),
+ );
+ }
+ demo.state = state;
+ });
+}
+
+const handoffKey = 'devonian-browser-handoff';
+const resumeKey = 'devonian-browser-resume';
+const client = origin =>
+ new BrowserIntegrations(
+ localStorage,
+ async () => {
+ throw new Error('Devonian uses its own resource lenses');
+ },
+ origin,
+ );
+export async function connectDemo(store, options, secret) {
+ const demo = await openDemo(store, { ...options, sample: false });
+ const result = await client(options.proxy).start(
+ demo.state.config.connection.drive,
+ store.getAgent().subject,
+ 'github-issues',
+ `${location.origin}/app/devonian-demo`,
+ secret,
+ );
+ sessionStorage.setItem(
+ handoffKey,
+ JSON.stringify({ key: demo.key, state: result.state }),
+ );
+ location.assign(result.url);
+}
+export async function resumeDemo(store) {
+ const url = new URL(location.href);
+ const callbackCode = url.searchParams.get('connection_code');
+ const callbackState = url.searchParams.get('integration_state');
+ const handoff = JSON.parse(sessionStorage.getItem(handoffKey) ?? 'null');
+ // Save the validated handoff before removing credentials from the URL, so a
+ // reload while OPFS opens cannot abandon the completed proxy consent.
+ if (callbackCode || callbackState) {
+ history.replaceState(null, '', url.pathname);
+ if (!handoff || callbackState !== handoff.state || !callbackCode)
+ throw new Error('Invalid connection callback state');
+ handoff.code = callbackCode;
+ sessionStorage.setItem(handoffKey, JSON.stringify(handoff));
+ }
+ const code = handoff?.code;
+ const stateId = handoff?.state;
+ const key = code ? handoff.key : sessionStorage.getItem(resumeKey);
+ if (!key) {
+ if (callbackCode || callbackState)
+ throw new Error('Missing browser connection handoff');
+ return;
+ }
+ const saved = await get(key);
+ if (!saved) throw new Error('Missing local tracker');
+ const demo = await openDemo(store, saved.options);
+ if (demo.key !== key) throw new Error('Connection belongs to another agent');
+ if (code) {
+ if (!handoff.finished) {
+ client(saved.options.proxy).finish(
+ saved.config.connection.drive,
+ store.getAgent().subject,
+ stateId,
+ code,
+ );
+ handoff.finished = true;
+ sessionStorage.setItem(handoffKey, JSON.stringify(handoff));
+ }
+ demo.state.connection = stateId;
+ await set(key, demo.state);
+ sessionStorage.removeItem(handoffKey);
+ sessionStorage.setItem(resumeKey, key);
+ }
+ return demo;
+}
diff --git a/browser/data-browser/src/chunks/DevonianDemo/devonian.js b/browser/data-browser/src/chunks/DevonianDemo/devonian.js
new file mode 100644
index 0000000000..ea269a9670
--- /dev/null
+++ b/browser/data-browser/src/chunks/DevonianDemo/devonian.js
@@ -0,0 +1,328 @@
+// Devonian native resource API, e11104f78ebd151a361171ca0b21489b25e1e2c8. Apache-2.0; see DEVONIAN-LICENSE.
+
+// ../../localthought/devonian/src/atomic/Resource.ts
+import { Datatype, validateDatatype } from "@tomic/lib";
+var IS_A = "https://atomicdata.dev/properties/isA";
+function assertSubject(value) {
+ if (typeof value !== "string" || /\s/.test(value))
+ throw new Error("Expected an absolute URL without whitespace");
+ const url = new URL(value);
+ if (!["http:", "https:"].includes(url.protocol)) {
+ throw new Error(`Expected an HTTP(S) URL: ${value}`);
+ }
+}
+var AtomicSchema = class {
+ properties = /* @__PURE__ */ new Map([
+ [IS_A, Datatype.RESOURCEARRAY]
+ ]);
+ property(subject, datatype) {
+ assertSubject(subject);
+ if (!Object.values(Datatype).includes(datatype) || datatype === Datatype.UNKNOWN) {
+ throw new Error(`Unsupported datatype: ${datatype}`);
+ }
+ const existing = this.properties.get(subject);
+ if (existing && existing !== datatype)
+ throw new Error(`Conflicting property: ${subject}`);
+ this.properties.set(subject, datatype);
+ return this;
+ }
+ validate(resource) {
+ assertSubject(resource["@id"]);
+ this.validateProperties(resource, true);
+ }
+ validateProperties(resource, named) {
+ if (Object.getPrototypeOf(resource) !== Object.prototype)
+ throw new Error("Expected a plain JSON object");
+ for (const [property, value] of Object.entries(resource)) {
+ if (property === "@id" && named) continue;
+ assertSubject(property);
+ const datatype = this.properties.get(property);
+ if (!datatype) throw new Error(`Unknown property: ${property}`);
+ if (typeof value === "number" && !Number.isFinite(value))
+ throw new Error("Expected a finite number");
+ if (value === void 0 || value === null)
+ throw new Error(`Missing value for ${property}`);
+ if (datatype === Datatype.ATOMIC_URL) {
+ this.validateLink(value);
+ } else if (datatype === Datatype.RESOURCEARRAY) {
+ if (!Array.isArray(value))
+ throw new Error(`Expected a resource array: ${property}`);
+ for (const link of value) this.validateLink(link);
+ } else {
+ if ([Datatype.FLOAT, Datatype.INTEGER, Datatype.TIMESTAMP].includes(
+ datatype
+ ) && typeof value !== "number")
+ throw new Error("Expected a number");
+ if (datatype === Datatype.BOOLEAN && typeof value !== "boolean")
+ throw new Error("Expected a boolean");
+ validateDatatype(value, datatype);
+ }
+ }
+ }
+ validateLink(value) {
+ if (typeof value === "string") assertSubject(value);
+ else if (value && typeof value === "object" && !Array.isArray(value))
+ this.validateProperties(value, false);
+ else throw new Error("Expected a resource URL or nested resource");
+ }
+};
+
+// ../../localthought/devonian/src/atomic/Store.ts
+var AtomicStore = class _AtomicStore {
+ constructor(schema) {
+ this.schema = schema;
+ }
+ schema;
+ resources = /* @__PURE__ */ new Map();
+ get(subject) {
+ const resource = this.resources.get(subject);
+ return resource && structuredClone(resource);
+ }
+ put(resource) {
+ this.schema.validate(resource);
+ this.resources.set(resource["@id"], structuredClone(resource));
+ }
+ /** Preserve omitted properties; removals must be listed in unset. */
+ patch(subject, patch) {
+ assertSubject(subject);
+ if (Object.hasOwn(patch.set ?? {}, "@id") || patch.unset?.includes("@id")) {
+ throw new Error("A patch cannot change resource identity");
+ }
+ const resource = {
+ ...this.get(subject) ?? { "@id": subject },
+ ...patch.set
+ };
+ for (const property of patch.unset ?? []) {
+ assertSubject(property);
+ delete resource[property];
+ }
+ this.put(resource);
+ return structuredClone(resource);
+ }
+ /** Synchronous local transaction. Async work must finish before entering this callback. */
+ transaction(operation) {
+ const previous = this.resources;
+ this.resources = new Map(previous);
+ try {
+ operation();
+ } catch (error) {
+ this.resources = previous;
+ throw error;
+ }
+ }
+ /** Apply a resource projection as one local transaction. */
+ apply(changes) {
+ const staged = new _AtomicStore(this.schema);
+ staged.resources = new Map(this.resources);
+ for (const change of changes) staged.patch(change.subject, change.patch);
+ this.resources = staged.resources;
+ }
+ delete(subject) {
+ this.resources.delete(subject);
+ }
+ /** Optional class filtering; callers can apply additional predicates to the returned copies. */
+ all(classSubject) {
+ return [...this.resources.values()].filter((resource) => {
+ const classes = resource["https://atomicdata.dev/properties/isA"];
+ return !classSubject || Array.isArray(classes) && classes.includes(classSubject);
+ }).map((resource) => structuredClone(resource));
+ }
+ toJSONAD() {
+ return JSON.stringify(this.all());
+ }
+ /** Validate the complete snapshot before replacing state. Import does not emit writes. */
+ loadJSONAD(json) {
+ const parsed = JSON.parse(json);
+ const resources = Array.isArray(parsed) ? parsed : [parsed];
+ const next = /* @__PURE__ */ new Map();
+ for (const item of resources) {
+ if (!item || typeof item !== "object" || Array.isArray(item) || typeof item["@id"] !== "string") {
+ throw new Error("Expected a named JSON-AD resource");
+ }
+ const resource = item;
+ this.schema.validate(resource);
+ if (next.has(resource["@id"]))
+ throw new Error(`Duplicate subject: ${resource["@id"]}`);
+ next.set(resource["@id"], structuredClone(resource));
+ }
+ this.resources = next;
+ }
+};
+
+// ../../localthought/devonian/src/atomic/IdentityMap.ts
+var VOCAB = "https://raw.githubusercontent.com/localthought/devonian/main/vocab/";
+var identity = {
+ class: `${VOCAB}Identity.json`,
+ scope: `${VOCAB}scope.json`,
+ entity: `${VOCAB}entity.json`,
+ localId: `${VOCAB}localId.json`,
+ idType: `${VOCAB}idType.json`,
+ resource: `${VOCAB}resource.json`
+};
+var AtomicIdentityMap = class {
+ constructor(store, baseURL) {
+ this.store = store;
+ assertSubject(baseURL);
+ const url = new URL(baseURL);
+ if (url.search || url.hash)
+ throw new Error("Identity base URL cannot contain a query or fragment");
+ this.base = baseURL.replace(/\/$/, "");
+ store.schema.property(identity.scope, Datatype.ATOMIC_URL).property(identity.entity, Datatype.STRING).property(identity.localId, Datatype.STRING).property(identity.idType, Datatype.STRING).property(identity.resource, Datatype.ATOMIC_URL);
+ }
+ store;
+ base;
+ key(scope, id) {
+ assertSubject(scope.scope);
+ if (!scope.entity || typeof id !== "string" && typeof id !== "number" || typeof id === "number" && !Number.isSafeInteger(id) || id === "") {
+ throw new Error(
+ "Expected an entity and a nonempty string or safe integer external ID"
+ );
+ }
+ return encodeURIComponent(
+ JSON.stringify([scope.scope, scope.entity, typeof id, id])
+ );
+ }
+ subjectFor(scope, id) {
+ return this.lookup(scope, id) ?? `${this.base}/resources/${this.key(scope, id)}`;
+ }
+ lookup(scope, id) {
+ this.key(scope, id);
+ return this.find(scope).find(
+ (resource) => resource[identity.localId] === String(id) && resource[identity.idType] === typeof id
+ )?.[identity.resource];
+ }
+ externalId(scope, subject) {
+ const resource = this.find(scope).find(
+ (resource2) => resource2[identity.resource] === subject
+ );
+ if (!resource) return void 0;
+ return resource[identity.idType] === "number" ? Number(resource[identity.localId]) : resource[identity.localId];
+ }
+ bind(scope, id, subject) {
+ const key = this.key(scope, id);
+ assertSubject(subject);
+ const existing = this.lookup(scope, id);
+ const reverse = this.externalId(scope, subject);
+ if (existing !== void 0 && existing !== subject || reverse !== void 0 && reverse !== id) {
+ throw new Error("Conflicting identity mapping");
+ }
+ if (existing === subject && reverse === id) return;
+ this.store.put({
+ "@id": `${this.base}/identities/${key}`,
+ [IS_A]: [identity.class],
+ [identity.scope]: scope.scope,
+ [identity.entity]: scope.entity,
+ [identity.localId]: String(id),
+ [identity.idType]: typeof id,
+ [identity.resource]: subject
+ });
+ }
+ find(scope) {
+ const resources = this.store.all(identity.class).filter(
+ (resource) => resource[identity.scope] === scope.scope && resource[identity.entity] === scope.entity
+ );
+ const ids = /* @__PURE__ */ new Set();
+ const subjects = /* @__PURE__ */ new Set();
+ for (const resource of resources) {
+ const id = resource[identity.localId];
+ const type = resource[identity.idType];
+ const subject = resource[identity.resource];
+ if (typeof id !== "string" || !id || !["string", "number"].includes(String(type)) || typeof subject !== "string" || type === "number" && (!Number.isSafeInteger(Number(id)) || String(Number(id)) !== id)) {
+ throw new Error("Invalid identity mapping");
+ }
+ const key = JSON.stringify([type, id]);
+ if (ids.has(key) || subjects.has(subject))
+ throw new Error("Conflicting identity mappings in snapshot");
+ ids.add(key);
+ subjects.add(subject);
+ }
+ return resources;
+ }
+};
+
+// ../../localthought/devonian/src/atomic/Lens.ts
+var AtomicLens = class {
+ constructor(options) {
+ this.options = options;
+ assertSubject(options.scope);
+ if (!options.entity) throw new Error("A lens requires an entity type");
+ if (options.identities.store !== options.store) {
+ throw new Error("Lens and identity map must use the same store");
+ }
+ }
+ options;
+ pending = Promise.resolve();
+ /** Handle a webhook or fetched record. Never writes back to the connector. */
+ ingest(record) {
+ return this.enqueue(async () => {
+ const { connector, identities, store, read } = this.options;
+ const id = connector.id(record);
+ const subject = identities.subjectFor(this.options, id);
+ const patch = await read(record, subject);
+ store.transaction(() => {
+ store.apply([...patch.related ?? [], { subject, patch }]);
+ for (const mapping of patch.identities ?? [])
+ identities.bind(mapping.scope, mapping.id, mapping.subject);
+ identities.bind(this.options, id, subject);
+ });
+ return subject;
+ });
+ }
+ /** Publish the latest native state; failures reject and may be retried. */
+ publish(subject) {
+ return this.enqueue(async () => {
+ const { connector, identities, store, write } = this.options;
+ const resource = store.get(subject);
+ if (!resource) throw new Error(`Unknown resource: ${subject}`);
+ const id = identities.externalId(this.options, subject);
+ if (id !== void 0) {
+ const previous = await connector.get(id);
+ await connector.update(id, await write(resource, previous));
+ return id;
+ }
+ const key = JSON.stringify([
+ this.options.scope,
+ this.options.entity,
+ subject
+ ]);
+ const created = await connector.create(
+ await write(resource, void 0),
+ key
+ );
+ const createdId = connector.id(created);
+ identities.bind(this.options, createdId, subject);
+ return createdId;
+ });
+ }
+ /** Delete native state after an external deletion. Retain identity for replay/recreation. */
+ ingestDelete(id) {
+ return this.enqueue(async () => {
+ const subject = this.options.identities.lookup(this.options, id);
+ if (subject) this.options.store.delete(subject);
+ });
+ }
+ /** Delete externally first, so a failed request leaves native state available for retry. */
+ delete(subject) {
+ return this.enqueue(async () => {
+ const { connector, identities, store } = this.options;
+ const id = identities.externalId(this.options, subject);
+ if (id !== void 0) await connector.delete(id);
+ store.delete(subject);
+ });
+ }
+ enqueue(operation) {
+ const result = this.pending.then(operation);
+ this.pending = result.catch(() => void 0);
+ return result;
+ }
+};
+export {
+ AtomicIdentityMap,
+ AtomicLens,
+ AtomicSchema,
+ AtomicStore,
+ Datatype,
+ IS_A,
+ assertSubject,
+ identity
+};
diff --git a/browser/data-browser/src/chunks/PluginRuns/ConnectLocalThought.tsx b/browser/data-browser/src/chunks/PluginRuns/ConnectLocalThought.tsx
index bb33f0ea6e..1448434a53 100644
--- a/browser/data-browser/src/chunks/PluginRuns/ConnectLocalThought.tsx
+++ b/browser/data-browser/src/chunks/PluginRuns/ConnectLocalThought.tsx
@@ -3,7 +3,7 @@ import {
core,
dataBrowser,
ensureSchema,
- executeServerPlugin,
+ pluginSchema,
useStore,
type Resource,
} from '@tomic/react';
@@ -12,10 +12,13 @@ import { Column } from '@components/Row';
import Field from '@components/forms/Field';
import { Input, ErrMessage } from '@components/forms/InputStyles';
import { AtomicLink } from '@components/AtomicLink';
-import { pluginClassesFor } from './runScript';
-import { ensureInstallationResource } from './installationResources';
+import {
+ localSchemaStore,
+ ensureLocalInstallationResource as ensureInstallationResource,
+} from './installationResources';
import { RunPluginDialog } from './RunPluginDialog';
import {
+ browserIntegrations,
connectionKey,
platformName,
proxyRequest,
@@ -27,7 +30,12 @@ import {
type FetchedPlatform,
} from '../../../../../integrations/localthought/schema';
import type { Config } from '../../../../../integrations/localthought/plugin';
+import { localImportVerdict } from './localImportVerdict';
import source from '../../../../../integrations/localthought/plugin.js?raw';
+import {
+ calendarProjection,
+ calendarFields,
+} from '../../../../../integrations/localthought/calendar';
export function ConnectLocalThought({
drive,
@@ -55,6 +63,7 @@ export function ConnectLocalThought({
start: new Date().toISOString().slice(0, 10),
end: new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10),
}));
+ const [tenantSecret, setTenantSecret] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [preview, setPreview] = useState<{
@@ -65,13 +74,10 @@ export function ConnectLocalThought({
const [tables, setTables] = useState([]);
useEffect(() => {
const controller = new AbortController();
- fetch(
- `${store.getServerUrl()}/integration-proxy/platform?platform=${encodeURIComponent(platform)}`,
- { signal: controller.signal },
- )
- .then(async response => {
- if (!response.ok) throw new Error(await response.text());
- const data = await response.json();
+ browserIntegrations()
+ .describe(platform)
+ .then(data => {
+ if (controller.signal.aborted) return;
setParameters(data.parameters);
setCollections(data.collections);
setConstants(
@@ -104,7 +110,12 @@ export function ConnectLocalThought({
const result = await proxyRequest<{ url: string; state: string }>(
store,
'start',
- { drive, platform, returnUrl: `${location.origin}/app/integrations` },
+ {
+ drive,
+ platform,
+ tenantSecret,
+ returnUrl: `${location.origin}/app/integrations`,
+ },
);
sessionStorage.setItem(
'localthought-pending',
@@ -123,15 +134,17 @@ export function ConnectLocalThought({
setError('');
try {
- const fetched = await proxyRequest(store, 'fetch', {
+ const response = await proxyRequest(store, 'fetch', {
drive,
connection: connection.connection,
constants,
...(platform === 'google-calendar' ? { calendarRange } : {}),
});
+ const fetched = calendarProjection(response);
if (fetched.platform !== platform)
throw new Error('Imported platform did not match this connection');
- const terms = await pluginClassesFor(store, drive);
+ const schemaStore = localSchemaStore(store);
+ const terms = await ensureSchema(schemaStore, drive, pluginSchema());
const name = platformName(platform);
const identity = `localthought:${connection.connection}:${JSON.stringify(Object.entries(constants).sort())}`;
const resource = await ensureInstallationResource(store, drive, {
@@ -145,7 +158,7 @@ export function ConnectLocalThought({
},
});
const schema = await ensureSchema(
- store,
+ schemaStore,
drive,
platformSchema(platform, fetched.ontology.terms),
);
@@ -187,13 +200,47 @@ export function ConnectLocalThought({
[dataBrowser.properties.viewColumns]: columns,
},
});
+ const calendar =
+ platform === 'google-calendar' && term.shortname === 'event'
+ ? await ensureInstallationResource(store, drive, {
+ parent: destination.subject,
+ localId: `${identity}:calendar:${term.shortname}`,
+ isA: [dataBrowser.classes.view],
+ propVals: {
+ [core.properties.name]: tableName,
+ [dataBrowser.properties.viewKind]: 'calendar',
+ [dataBrowser.properties.viewGroupBy]:
+ properties[calendarFields.day],
+ [dataBrowser.properties.viewColumns]: columns,
+ },
+ })
+ : undefined;
+ const existingViews = destination.get(
+ dataBrowser.properties.tableViews,
+ ) as string[] | undefined;
await destination.set(dataBrowser.properties.tableViews, [
- view.subject,
+ ...new Set([
+ ...(existingViews ?? []),
+ view.subject,
+ ...(calendar ? [calendar.subject] : []),
+ ]),
]);
- await destination.set(
+ const currentDefault = destination.get(
dataBrowser.properties.tableDefaultView,
- view.subject,
);
+
+ if (
+ !currentDefault ||
+ (calendar &&
+ !existingViews?.includes(calendar.subject) &&
+ currentDefault === view.subject)
+ ) {
+ await destination.set(
+ dataBrowser.properties.tableDefaultView,
+ calendar?.subject ?? view.subject,
+ );
+ }
+
await destination.save();
destinations[term.shortname] = { table: destination.subject, rowClass };
}
@@ -203,24 +250,13 @@ export function ConnectLocalThought({
localthought: { ...config, connection: connection.connection },
});
await resource.save();
- const result = await executeServerPlugin(store, {
- drive,
- plugin: resource.subject,
- source,
- input: {
- config: { ...config, records: fetched.records },
- trigger: {
- kind: 'manual',
- at: Date.now(),
- subject: resource.subject,
- },
- },
+ const verdict = await localImportVerdict(store, drive, {
+ ...config,
+ records: fetched.records,
});
- if (result.error || !result.verdict)
- throw new Error(result.error ?? 'Import returned no preview');
setPreview({
resource,
- verdict: result.verdict,
+ verdict,
tables: Object.values(destinations).map(d => d.table),
});
} catch (reason) {
@@ -236,7 +272,21 @@ export function ConnectLocalThought({
Connect your personal account through LocalThought, then return here to
preview an import.
-
+
+ setTenantSecret(e.target.value)}
+ disabled={busy}
+ />
+
+
+ The tenant secret is used in this tab. Connection credentials stay in
+ this browser.
+
+
{connection ? 'Reconnect account' : 'Install and connect'}
{connection && (
diff --git a/browser/data-browser/src/chunks/PluginRuns/LocalThoughtCatalog.tsx b/browser/data-browser/src/chunks/PluginRuns/LocalThoughtCatalog.tsx
index 7e10caf095..733f9a6170 100644
--- a/browser/data-browser/src/chunks/PluginRuns/LocalThoughtCatalog.tsx
+++ b/browser/data-browser/src/chunks/PluginRuns/LocalThoughtCatalog.tsx
@@ -6,7 +6,12 @@ import { Button } from '@components/Button';
import { Dialog, useDialog } from '@components/Dialog';
import { ErrMessage } from '@components/forms/InputStyles';
import { ConnectLocalThought } from './ConnectLocalThought';
-import { connectionKey, platformName, proxyRequest } from './localThought';
+import {
+ browserIntegrations,
+ connectionKey,
+ platformName,
+ proxyRequest,
+} from './localThought';
const DirectGitHub = lazy(() =>
import('./ConnectGitHub').then(m => ({ default: m.ConnectGitHub })),
@@ -26,20 +31,9 @@ export function LocalThoughtCatalog({
const completing = useRef(false);
useEffect(() => {
const controller = new AbortController();
- fetch(`${store.getServerUrl()}/integration-proxy/catalog`, {
- signal: controller.signal,
- })
- .then(async response => {
- if (!response.ok)
- throw new Error('Could not load the LocalThought catalog');
- const data = await response.json();
- if (
- !Array.isArray(data.platforms) ||
- data.platforms.some((id: unknown) => typeof id !== 'string')
- )
- throw new Error('Invalid integration catalog');
- setPlatforms(data.platforms);
- })
+ browserIntegrations()
+ .catalog(controller.signal)
+ .then(setPlatforms)
.catch(reason => {
if (!controller.signal.aborted) setError(String(reason));
});
diff --git a/browser/data-browser/src/chunks/PluginRuns/installationResources.test.ts b/browser/data-browser/src/chunks/PluginRuns/installationResources.test.ts
index 81dc6fc793..97ead3f8d7 100644
--- a/browser/data-browser/src/chunks/PluginRuns/installationResources.test.ts
+++ b/browser/data-browser/src/chunks/PluginRuns/installationResources.test.ts
@@ -47,3 +47,37 @@ it('never interprets a failed identity query as permission to create', async ()
).rejects.toThrow('offline');
expect(store.newResource).not.toHaveBeenCalled();
});
+
+it('uses complete local identities without a server and refuses a missing local DB', async () => {
+ const { ensureLocalInstallationResource } =
+ await import('./installationResources');
+ const resource = {
+ get: (p: string) => ({ parent: 'parent', isA: ['class'] })[p],
+ };
+ const queryLocalDb = vi
+ .fn()
+ .mockResolvedValue({ subjects: ['saved'], count: 1 });
+ const store = {
+ queryLocalDb,
+ getResource: async () => resource,
+ newResource: vi.fn(),
+ };
+ const options = {
+ parent: 'parent',
+ localId: 'step',
+ isA: ['class'],
+ propVals: {},
+ };
+ expect(
+ await ensureLocalInstallationResource(store as never, 'drive', options),
+ ).toBe(resource);
+ queryLocalDb.mockResolvedValue(null);
+ await expect(
+ ensureLocalInstallationResource(store as never, 'drive', options),
+ ).rejects.toThrow('Local installation query');
+ queryLocalDb.mockResolvedValue({ subjects: [], count: 1 });
+ await expect(
+ ensureLocalInstallationResource(store as never, 'drive', options),
+ ).rejects.toThrow('Local installation query');
+ expect(store.newResource).not.toHaveBeenCalled();
+});
diff --git a/browser/data-browser/src/chunks/PluginRuns/installationResources.ts b/browser/data-browser/src/chunks/PluginRuns/installationResources.ts
index e4a7be3b9e..62c808ce40 100644
--- a/browser/data-browser/src/chunks/PluginRuns/installationResources.ts
+++ b/browser/data-browser/src/chunks/PluginRuns/installationResources.ts
@@ -16,14 +16,12 @@ export async function ensureInstallationResource(
isA: string[];
propVals: Record;
},
+ local = false,
) {
const find = async () => {
- const ids = await readConnectionSubjects(
- store,
- drive,
- core.properties.localId,
- options.localId,
- );
+ const ids = await (
+ local ? readLocalInstallationSubjects : readConnectionSubjects
+ )(store, drive, core.properties.localId, options.localId);
const resources = await Promise.all(ids.map(id => store.getResource(id)));
const matches = resources.filter(
r =>
@@ -71,3 +69,60 @@ export async function ensureInstallationResource(
throw error;
}
}
+
+async function readLocalInstallationSubjects(
+ store: Store,
+ drive: string,
+ property: string,
+ value: string,
+) {
+ const result = await store.queryLocalDb({
+ drive,
+ property,
+ value,
+ limit: 10001,
+ });
+ if (!result || result.count !== result.subjects.length)
+ throw new Error(
+ 'Local installation query failed or is incomplete; refusing to create duplicates',
+ );
+ return result.subjects;
+}
+
+/** LocalThought installations use the same identity checks against local OPFS. */
+export function ensureLocalInstallationResource(
+ store: Store,
+ drive: string,
+ options: Parameters[2],
+) {
+ return ensureInstallationResource(store, drive, options, true);
+}
+
+/** Schema recovery must use the same local identity authority as installation. */
+export function localSchemaStore(store: Store) {
+ return {
+ getResource: store.getResource.bind(store),
+ newResource: store.newResource.bind(store),
+ findByLocalId: async (drive: string, parent: string, localId: string) => {
+ const subjects = await readLocalInstallationSubjects(
+ store,
+ drive,
+ core.properties.localId,
+ localId,
+ );
+ const resources = await Promise.all(
+ subjects.map(s => store.getResource(s)),
+ );
+ const matches = resources.filter(
+ r =>
+ String(r.get(core.properties.parent)).split('?')[0] ===
+ parent.split('?')[0],
+ );
+ if (matches.length > 1)
+ throw new Error(
+ 'Duplicate local schema identity; resolve before importing',
+ );
+ return matches[0];
+ },
+ };
+}
diff --git a/browser/data-browser/src/chunks/PluginRuns/localImportVerdict.ts b/browser/data-browser/src/chunks/PluginRuns/localImportVerdict.ts
new file mode 100644
index 0000000000..7f373d547c
--- /dev/null
+++ b/browser/data-browser/src/chunks/PluginRuns/localImportVerdict.ts
@@ -0,0 +1,49 @@
+// @wc-ignore-file
+import { core, type Store } from '@tomic/react';
+import {
+ run,
+ type Config,
+} from '../../../../../integrations/localthought/plugin';
+
+/** The shipped pure mapper gets a local read snapshot, never network or credentials. */
+export async function localImportVerdict(
+ store: Store,
+ drive: string,
+ config: Config,
+) {
+ const rows = new Map>();
+ for (const { table } of Object.values(config.destinations)) {
+ let offset = 0;
+ for (;;) {
+ const result = await store.queryLocalDb({
+ drive,
+ property: core.properties.parent,
+ value: table,
+ offset,
+ limit: 1000,
+ });
+ if (!result)
+ throw new Error('Local database must be available before importing');
+ for (const subject of result.subjects)
+ rows.set(subject, (await store.getResource(subject)).getPropVals());
+ offset += result.subjects.length;
+ if (offset >= result.count) break;
+ if (!result.subjects.length)
+ throw new Error('Local import snapshot is incomplete');
+ }
+ }
+ return JSON.stringify(
+ run({
+ config,
+ query: (property, value) =>
+ [...rows]
+ .filter(([, row]) => row[property] === value)
+ .map(([subject]) => subject),
+ read: subject => {
+ const row = rows.get(subject);
+ if (!row) throw new Error('Missing local import record');
+ return row;
+ },
+ }),
+ );
+}
diff --git a/browser/data-browser/src/chunks/PluginRuns/localThought.ts b/browser/data-browser/src/chunks/PluginRuns/localThought.ts
index 6dcf43024e..8751989c66 100644
--- a/browser/data-browser/src/chunks/PluginRuns/localThought.ts
+++ b/browser/data-browser/src/chunks/PluginRuns/localThought.ts
@@ -1,5 +1,5 @@
// @wc-ignore-file
-import { signRequest, type Store } from '@tomic/react';
+import { type Store } from '@tomic/react';
export const platformName = (id: string) =>
({
@@ -7,25 +7,73 @@ export const platformName = (id: string) =>
'google-calendar': 'Google Calendar',
pets: 'Pets',
})[id] ?? id;
+import {
+ BrowserIntegrations,
+ type Engine,
+} from '../../../../../integrations/localthought/browser';
+import { wasmJsUrl, wasmBinaryUrl } from '../../helpers/wasmUrls';
+let loaded: Promise | undefined;
+async function engine(): Promise {
+ return (loaded ??= (async () => {
+ const url = wasmJsUrl();
+ const module = await import(/* @vite-ignore */ url);
+ await module.default({ module_or_path: wasmBinaryUrl() });
+ if (typeof module.fetchIntegration !== 'function')
+ throw new Error('Rebuild the WASM bundle and reload Atomic');
+ return module;
+ })().catch(error => {
+ loaded = undefined;
+ throw error;
+ }));
+}
+export const browserIntegrations = () =>
+ new BrowserIntegrations(
+ localStorage,
+ engine,
+ import.meta.env.VITE_INTEGRATION_PROXY_URL || undefined,
+ );
export async function proxyRequest(
store: Store,
action: string,
- body: object,
+ body: {
+ drive: string;
+ platform?: string;
+ returnUrl?: string;
+ tenantSecret?: string;
+ state?: string;
+ connectionCode?: string;
+ connection?: string;
+ constants?: Record;
+ calendarRange?: { start: string; end: string };
+ },
): Promise {
- const agent = store.getAgent();
- if (!agent) throw new Error('Sign in before connecting an account');
- const url = `${store.getServerUrl()}/integration-proxy/${action}`;
- const response = await fetch(url, {
- method: 'POST',
- headers: {
- ...(await signRequest(url, agent, {})),
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(body),
- });
- if (!response.ok) throw new Error(await response.text());
-
- return response.json();
+ const actor = store.getAgent()?.subject;
+ if (!actor) throw new Error('Sign in before connecting an account');
+ const client = browserIntegrations();
+ if (action === 'start')
+ return (await client.start(
+ body.drive,
+ actor,
+ body.platform!,
+ body.returnUrl!,
+ body.tenantSecret!,
+ )) as T;
+ if (action === 'finish')
+ return client.finish(
+ body.drive,
+ actor,
+ body.state!,
+ body.connectionCode!,
+ ) as T;
+ if (action === 'fetch')
+ return client.fetchRecords(
+ body.drive,
+ actor,
+ body.connection!,
+ body.constants ?? {},
+ body.calendarRange,
+ );
+ throw new Error('Unknown browser integration action');
}
export interface SavedConnection {
connection: string;
@@ -34,4 +82,4 @@ export interface SavedConnection {
actor: string;
}
export const connectionKey = (drive: string, actor: string, platform: string) =>
- `localthought:${JSON.stringify([drive, actor, platform])}`;
+ `localthought-browser:${JSON.stringify([drive, actor, platform])}`;
diff --git a/browser/data-browser/src/locales/de.po b/browser/data-browser/src/locales/de.po
index ee9bc15a69..ff10ed5033 100644
--- a/browser/data-browser/src/locales/de.po
+++ b/browser/data-browser/src/locales/de.po
@@ -4704,6 +4704,7 @@ msgstr ""
msgid "Syncing…"
msgstr ""
+#: src/routes/DevonianDemoRoute.tsx
#: src/routes/SyncRoute.tsx
msgid "Sync now"
msgstr ""
@@ -6287,6 +6288,7 @@ msgid "Keep an encrypted copy of this workspace in {0}. It is sealed on this dev
msgstr ""
#: src/components/Vault/VaultPanel.tsx
+#: src/routes/DevonianDemoRoute.tsx
msgid "Working…"
msgstr ""
@@ -9659,3 +9661,125 @@ msgstr ""
#: src/chunks/PluginRuns/ConnectLocalThought.tsx
msgid "Events before (UTC)"
msgstr ""
+
+#: src/chunks/PluginRuns/ConnectLocalThought.tsx
+#: src/routes/DevonianDemoRoute.tsx
+msgid "LocalThought tenant secret"
+msgstr ""
+
+#: src/chunks/PluginRuns/ConnectLocalThought.tsx
+msgid "The tenant secret is used in this tab. Connection credentials stay in this browser."
+msgstr ""
+
+#. 0: count
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Synchronized {0} issues and comments."
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Integration proxy URL"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "GitHub repository (owner/repo)"
+msgstr ""
+
+#~ msgid "Connection code"
+#~ msgstr ""
+
+#~ msgid "The proxy must allow this app’s origin through CORS and expose X-Connection-Code. Use a fresh GitHub connection code from the proxy’s OAuth flow."
+#~ msgstr ""
+
+#~ msgid "Open live tracker"
+#~ msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Connect a real GitHub repository"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Devonian issue tracker demo"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Sync issues and comments in both directions. Devonian runs in this browser; the Atomic tracker is stored on this device."
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Try sample data"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Sample mode: the GitHub side below is a browser fixture. No requests are sent to GitHub."
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Live mode: Sync now creates and updates real GitHub issues and comments. Keep this tab open to sync."
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Open Atomic kanban"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "New issue title or comment"
+msgstr ""
+
+#~ msgid "Done"
+#~ msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Reopen Atomic issue"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Close Atomic issue"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Add Atomic comment"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Atomic tracker"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Atomic issues"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Reopen sample GitHub issue"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Close sample GitHub issue"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Add sample GitHub comment"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Create sample GitHub issue"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Create Atomic issue"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Sample GitHub tracker"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Sample GitHub issues"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Connect through LocalThought. The tenant secret is used in this tab to start the connection and is never saved."
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Connect GitHub tracker"
+msgstr ""
diff --git a/browser/data-browser/src/locales/en.po b/browser/data-browser/src/locales/en.po
index 2cbaf4b6de..06bff9a6f1 100644
--- a/browser/data-browser/src/locales/en.po
+++ b/browser/data-browser/src/locales/en.po
@@ -4715,6 +4715,7 @@ msgstr "{0}% used"
msgid "Syncing…"
msgstr "Syncing…"
+#: src/routes/DevonianDemoRoute.tsx
#: src/routes/SyncRoute.tsx
msgid "Sync now"
msgstr "Sync now"
@@ -6298,6 +6299,7 @@ msgid "Keep an encrypted copy of this workspace in {0}. It is sealed on this dev
msgstr "Keep an encrypted copy of this workspace in {0}. It is sealed on this device, so we store it without being able to read it."
#: src/components/Vault/VaultPanel.tsx
+#: src/routes/DevonianDemoRoute.tsx
msgid "Working…"
msgstr "Working…"
@@ -9698,3 +9700,125 @@ msgstr "Events from (UTC)"
#: src/chunks/PluginRuns/ConnectLocalThought.tsx
msgid "Events before (UTC)"
msgstr "Events before (UTC)"
+
+#: src/chunks/PluginRuns/ConnectLocalThought.tsx
+#: src/routes/DevonianDemoRoute.tsx
+msgid "LocalThought tenant secret"
+msgstr "LocalThought tenant secret"
+
+#: src/chunks/PluginRuns/ConnectLocalThought.tsx
+msgid "The tenant secret is used in this tab. Connection credentials stay in this browser."
+msgstr "The tenant secret is used in this tab. Connection credentials stay in this browser."
+
+#. 0: count
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Synchronized {0} issues and comments."
+msgstr "Synchronized {0} issues and comments."
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Integration proxy URL"
+msgstr "Integration proxy URL"
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "GitHub repository (owner/repo)"
+msgstr "GitHub repository (owner/repo)"
+
+#~ msgid "Connection code"
+#~ msgstr "Connection code"
+
+#~ msgid "The proxy must allow this app’s origin through CORS and expose X-Connection-Code. Use a fresh GitHub connection code from the proxy’s OAuth flow."
+#~ msgstr "The proxy must allow this app’s origin through CORS and expose X-Connection-Code. Use a fresh GitHub connection code from the proxy’s OAuth flow."
+
+#~ msgid "Open live tracker"
+#~ msgstr "Open live tracker"
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Connect a real GitHub repository"
+msgstr "Connect a real GitHub repository"
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Devonian issue tracker demo"
+msgstr "Devonian issue tracker demo"
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Sync issues and comments in both directions. Devonian runs in this browser; the Atomic tracker is stored on this device."
+msgstr "Sync issues and comments in both directions. Devonian runs in this browser; the Atomic tracker is stored on this device."
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Try sample data"
+msgstr "Try sample data"
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Sample mode: the GitHub side below is a browser fixture. No requests are sent to GitHub."
+msgstr "Sample mode: the GitHub side below is a browser fixture. No requests are sent to GitHub."
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Live mode: Sync now creates and updates real GitHub issues and comments. Keep this tab open to sync."
+msgstr "Live mode: Sync now creates and updates real GitHub issues and comments. Keep this tab open to sync."
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Open Atomic kanban"
+msgstr "Open Atomic kanban"
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "New issue title or comment"
+msgstr "New issue title or comment"
+
+#~ msgid "Done"
+#~ msgstr "Done"
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Reopen Atomic issue"
+msgstr "Reopen Atomic issue"
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Close Atomic issue"
+msgstr "Close Atomic issue"
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Add Atomic comment"
+msgstr "Add Atomic comment"
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Atomic tracker"
+msgstr "Atomic tracker"
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Atomic issues"
+msgstr "Atomic issues"
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Reopen sample GitHub issue"
+msgstr "Reopen sample GitHub issue"
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Close sample GitHub issue"
+msgstr "Close sample GitHub issue"
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Add sample GitHub comment"
+msgstr "Add sample GitHub comment"
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Create sample GitHub issue"
+msgstr "Create sample GitHub issue"
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Create Atomic issue"
+msgstr "Create Atomic issue"
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Sample GitHub tracker"
+msgstr "Sample GitHub tracker"
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Sample GitHub issues"
+msgstr "Sample GitHub issues"
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Connect through LocalThought. The tenant secret is used in this tab to start the connection and is never saved."
+msgstr "Connect through LocalThought. The tenant secret is used in this tab to start the connection and is never saved."
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Connect GitHub tracker"
+msgstr "Connect GitHub tracker"
diff --git a/browser/data-browser/src/locales/es.po b/browser/data-browser/src/locales/es.po
index 73252fe2f3..c21040df5e 100644
--- a/browser/data-browser/src/locales/es.po
+++ b/browser/data-browser/src/locales/es.po
@@ -4704,6 +4704,7 @@ msgstr ""
msgid "Syncing…"
msgstr ""
+#: src/routes/DevonianDemoRoute.tsx
#: src/routes/SyncRoute.tsx
msgid "Sync now"
msgstr ""
@@ -6287,6 +6288,7 @@ msgid "Keep an encrypted copy of this workspace in {0}. It is sealed on this dev
msgstr ""
#: src/components/Vault/VaultPanel.tsx
+#: src/routes/DevonianDemoRoute.tsx
msgid "Working…"
msgstr ""
@@ -9659,3 +9661,125 @@ msgstr ""
#: src/chunks/PluginRuns/ConnectLocalThought.tsx
msgid "Events before (UTC)"
msgstr ""
+
+#: src/chunks/PluginRuns/ConnectLocalThought.tsx
+#: src/routes/DevonianDemoRoute.tsx
+msgid "LocalThought tenant secret"
+msgstr ""
+
+#: src/chunks/PluginRuns/ConnectLocalThought.tsx
+msgid "The tenant secret is used in this tab. Connection credentials stay in this browser."
+msgstr ""
+
+#. 0: count
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Synchronized {0} issues and comments."
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Integration proxy URL"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "GitHub repository (owner/repo)"
+msgstr ""
+
+#~ msgid "Connection code"
+#~ msgstr ""
+
+#~ msgid "The proxy must allow this app’s origin through CORS and expose X-Connection-Code. Use a fresh GitHub connection code from the proxy’s OAuth flow."
+#~ msgstr ""
+
+#~ msgid "Open live tracker"
+#~ msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Connect a real GitHub repository"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Devonian issue tracker demo"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Sync issues and comments in both directions. Devonian runs in this browser; the Atomic tracker is stored on this device."
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Try sample data"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Sample mode: the GitHub side below is a browser fixture. No requests are sent to GitHub."
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Live mode: Sync now creates and updates real GitHub issues and comments. Keep this tab open to sync."
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Open Atomic kanban"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "New issue title or comment"
+msgstr ""
+
+#~ msgid "Done"
+#~ msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Reopen Atomic issue"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Close Atomic issue"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Add Atomic comment"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Atomic tracker"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Atomic issues"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Reopen sample GitHub issue"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Close sample GitHub issue"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Add sample GitHub comment"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Create sample GitHub issue"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Create Atomic issue"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Sample GitHub tracker"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Sample GitHub issues"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Connect through LocalThought. The tenant secret is used in this tab to start the connection and is never saved."
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Connect GitHub tracker"
+msgstr ""
diff --git a/browser/data-browser/src/locales/fr.po b/browser/data-browser/src/locales/fr.po
index 7dcbb3f208..46b33e9914 100644
--- a/browser/data-browser/src/locales/fr.po
+++ b/browser/data-browser/src/locales/fr.po
@@ -4704,6 +4704,7 @@ msgstr ""
msgid "Syncing…"
msgstr ""
+#: src/routes/DevonianDemoRoute.tsx
#: src/routes/SyncRoute.tsx
msgid "Sync now"
msgstr ""
@@ -6287,6 +6288,7 @@ msgid "Keep an encrypted copy of this workspace in {0}. It is sealed on this dev
msgstr ""
#: src/components/Vault/VaultPanel.tsx
+#: src/routes/DevonianDemoRoute.tsx
msgid "Working…"
msgstr ""
@@ -9659,3 +9661,125 @@ msgstr ""
#: src/chunks/PluginRuns/ConnectLocalThought.tsx
msgid "Events before (UTC)"
msgstr ""
+
+#: src/chunks/PluginRuns/ConnectLocalThought.tsx
+#: src/routes/DevonianDemoRoute.tsx
+msgid "LocalThought tenant secret"
+msgstr ""
+
+#: src/chunks/PluginRuns/ConnectLocalThought.tsx
+msgid "The tenant secret is used in this tab. Connection credentials stay in this browser."
+msgstr ""
+
+#. 0: count
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Synchronized {0} issues and comments."
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Integration proxy URL"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "GitHub repository (owner/repo)"
+msgstr ""
+
+#~ msgid "Connection code"
+#~ msgstr ""
+
+#~ msgid "The proxy must allow this app’s origin through CORS and expose X-Connection-Code. Use a fresh GitHub connection code from the proxy’s OAuth flow."
+#~ msgstr ""
+
+#~ msgid "Open live tracker"
+#~ msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Connect a real GitHub repository"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Devonian issue tracker demo"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Sync issues and comments in both directions. Devonian runs in this browser; the Atomic tracker is stored on this device."
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Try sample data"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Sample mode: the GitHub side below is a browser fixture. No requests are sent to GitHub."
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Live mode: Sync now creates and updates real GitHub issues and comments. Keep this tab open to sync."
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Open Atomic kanban"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "New issue title or comment"
+msgstr ""
+
+#~ msgid "Done"
+#~ msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Reopen Atomic issue"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Close Atomic issue"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Add Atomic comment"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Atomic tracker"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Atomic issues"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Reopen sample GitHub issue"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Close sample GitHub issue"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Add sample GitHub comment"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Create sample GitHub issue"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Create Atomic issue"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Sample GitHub tracker"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Sample GitHub issues"
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Connect through LocalThought. The tenant secret is used in this tab to start the connection and is never saved."
+msgstr ""
+
+#: src/routes/DevonianDemoRoute.tsx
+msgid "Connect GitHub tracker"
+msgstr ""
diff --git a/browser/data-browser/src/routes/DevonianDemoRoute.tsx b/browser/data-browser/src/routes/DevonianDemoRoute.tsx
new file mode 100644
index 0000000000..f961500c59
--- /dev/null
+++ b/browser/data-browser/src/routes/DevonianDemoRoute.tsx
@@ -0,0 +1,275 @@
+import { createLazyRoute } from '@tanstack/react-router';
+import { useEffect, useRef, useState } from 'react';
+import { useStore } from '@tomic/react';
+import { Main } from '@components/Main';
+import { ContainerWide } from '@components/Containers';
+import { Column, Row as Horizontal } from '@components/Row';
+import { Card } from '@components/Card';
+import { Button } from '@components/Button';
+import { AtomicLink } from '@components/AtomicLink';
+import Field from '@components/forms/Field';
+import { Input, ErrMessage } from '@components/forms/InputStyles';
+import {
+ openDemo,
+ connectDemo,
+ resumeDemo,
+ syncDemo,
+ demoRows,
+ editAtomic,
+ editFixture,
+ type Demo,
+ type Row,
+} from '../chunks/DevonianDemo/demo.mjs';
+
+function DevonianDemo() {
+ const store = useStore();
+ const [demo, setDemo] = useState();
+ const [rows, setRows] = useState([]);
+ const [proxy, setProxy] = useState('https://localthought.io');
+ const [repository, setRepository] = useState('');
+ const [secret, setSecret] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+ const [status, setStatus] = useState('');
+ const [text, setText] = useState('');
+ const run = async (work: () => Promise) => {
+ setBusy(true);
+ setError('');
+ try {
+ const current = await work();
+ if (current) {
+ setDemo({ ...current });
+ setRows(await demoRows(store, current));
+ }
+ } catch (reason) {
+ setError(reason instanceof Error ? reason.message : String(reason));
+ } finally {
+ setBusy(false);
+ }
+ };
+ const resumed = useRef(false);
+ useEffect(() => {
+ if (resumed.current) return;
+ resumed.current = true;
+ void run(() => resumeDemo(store));
+ }, [store]);
+ const start = (sample: boolean) =>
+ run(async () => {
+ if (!sample) {
+ const credential = secret;
+ setSecret('');
+ await connectDemo(store, { repository, proxy }, credential);
+ return;
+ }
+ const opened = await openDemo(store, { sample, repository, proxy });
+ if (sample) await syncDemo(store, opened);
+ return opened;
+ });
+ const sync = () =>
+ run(async () => {
+ if (!demo) return;
+ const count = await syncDemo(store, demo);
+ setStatus(`Synchronized ${count} issues and comments.`);
+ return demo;
+ });
+ const edit = (
+ side: 'atomic' | 'fixture',
+ command: string,
+ id?: string | number,
+ ) =>
+ run(async () => {
+ if (!demo) return;
+ if (side === 'atomic')
+ await editAtomic(store, demo, command, id as string, text);
+ else await editFixture(demo, command, id as number, text);
+ setText('');
+ return demo;
+ });
+ return (
+
+
+
+ Devonian issue tracker demo
+
+ Sync issues and comments in both directions. Devonian runs in this
+ browser; the Atomic tracker is stored on this device.
+
+ {!demo && (
+ <>
+ start(true)}>
+ Try sample data
+
+
+ Connect a real GitHub repository
+
+
+ Connect through LocalThought. The tenant secret is used in
+ this tab to start the connection and is never saved.
+
+
+ setProxy(e.target.value)}
+ />
+
+
+ setRepository(e.target.value)}
+ />
+
+
+ setSecret(e.target.value)}
+ />
+
+ start(false)}
+ >
+ Connect GitHub tracker
+
+
+
+ >
+ )}
+ {demo && (
+ <>
+
+ {demo.state.options.sample
+ ? 'Sample mode: the GitHub side below is a browser fixture. No requests are sent to GitHub.'
+ : 'Live mode: Sync now creates and updates real GitHub issues and comments. Keep this tab open to sync.'}
+
+
+
+ {busy ? 'Working…' : 'Sync now'}
+
+
+ Open Atomic kanban
+
+
+
+ setText(e.target.value)}
+ />
+
+
+ edit('atomic', 'create')}
+ >
+ Create Atomic issue
+
+ {demo.state.options.sample && (
+ edit('fixture', 'create')}
+ >
+ Create sample GitHub issue
+
+ )}
+
+
+ Atomic tracker
+
+ {rows.map(row => (
+
+
+
+ {row.value.title}
+
+ {row.value.status}
+
+ edit('atomic', 'toggle', row.id)}
+ >
+ {row.value.status === /* @wc-ignore */ 'Done'
+ ? 'Reopen Atomic issue'
+ : 'Close Atomic issue'}
+
+ edit('atomic', 'comment', row.id)}
+ >
+ Add Atomic comment
+
+
+ {row.comments.map(comment => (
+ {comment.value.body}
+ ))}
+
+
+ ))}
+
+
+ {demo.state.options.sample && (
+
+ Sample GitHub tracker
+
+ {demo.state.fixture.issues?.map(issue => (
+
+
+
+ #{issue.number} {issue.title}
+
+ {issue.state}
+
+
+ edit('fixture', 'toggle', issue.number)
+ }
+ >
+ {issue.state === 'closed'
+ ? 'Reopen sample GitHub issue'
+ : 'Close sample GitHub issue'}
+
+
+ edit('fixture', 'comment', issue.number)
+ }
+ >
+ Add sample GitHub comment
+
+
+ {demo.state.fixture.comments
+ ?.filter(c =>
+ c.issue_url.endsWith(`/${issue.number}`),
+ )
+ .map(c => (
+ {c.body}
+ ))}
+
+
+ ))}
+
+
+ )}
+ >
+ )}
+ {status}
+ {error && {error} }
+
+
+
+ );
+}
+export const devonianDemoRouteLazy = createLazyRoute('/app/devonian-demo')({
+ component: DevonianDemo,
+});
diff --git a/browser/data-browser/src/routes/Router.tsx b/browser/data-browser/src/routes/Router.tsx
index 5c79967682..176a64ffcb 100644
--- a/browser/data-browser/src/routes/Router.tsx
+++ b/browser/data-browser/src/routes/Router.tsx
@@ -42,6 +42,13 @@ const DemoRoute = createRoute({
path: pathNames.demo,
}).lazy(() => import('./DemoRoute').then(mod => mod.demoRouteLazy));
+const DevonianDemoRoute = createRoute({
+ getParentRoute: () => appRoute,
+ path: '/devonian-demo',
+}).lazy(() =>
+ import('./DevonianDemoRoute').then(mod => mod.devonianDemoRouteLazy),
+);
+
const PruneTestsRoute = createRoute({
getParentRoute: () => appRoute,
path: pathNames.pruneTests,
@@ -91,6 +98,7 @@ const routeTree = rootRoute.addChildren({
SandboxRoute,
DevDriveRoute,
DemoRoute,
+ DevonianDemoRoute,
InviteRoute,
LinkOpenRouter,
}),
diff --git a/browser/e2e/tests/devonian-issue-sync.spec.mts b/browser/e2e/tests/devonian-issue-sync.spec.mts
new file mode 100644
index 0000000000..b79b8e97fa
--- /dev/null
+++ b/browser/e2e/tests/devonian-issue-sync.spec.mts
@@ -0,0 +1,182 @@
+import { test, expect } from '@playwright/test';
+import {
+ mockProxy,
+ tenantSecret,
+} from '../../../integrations/localthought/mock-proxy.mjs';
+const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:6747';
+const SERVER_URL = process.env.SERVER_URL ?? 'http://localhost:9883';
+
+test.use({ serviceWorkers: 'block' });
+
+// This is a public fixture secret, never an environment/live credential.
+// Exercise the real form, tenant challenge, consent, rotating HTTP transport and OPFS.
+test('Devonian syncs issue creation, state and comments both ways through the browser proxy', async ({
+ page,
+}) => {
+ test.setTimeout(180_000);
+ const proxy = mockProxy({ frontendOrigin: new URL(FRONTEND_URL).origin });
+ await new Promise(resolve => proxy.listen(0, '127.0.0.1', resolve));
+ const address = proxy.address() as { port: number };
+ const proxyOrigin = `http://127.0.0.1:${address.port}`;
+ const repository = 'demo/two-way';
+ const remote = proxy.github;
+ const initial = remote.createIssue(repository, {
+ title: 'Created on GitHub',
+ });
+ remote.createComment(repository, initial.number, {
+ body: 'Initial GitHub comment',
+ });
+ // No AtomicServer WebSocket writes (or Vite HMR reloads) during this journey.
+ await page.routeWebSocket('**/*', socket => socket.close());
+ const forbidden: string[] = [];
+ const requests: string[] = [];
+ page.on('request', req => {
+ if (req.url().startsWith(`${proxyOrigin}/proxy/`))
+ requests.push(req.method());
+ });
+ // Keep static app assets available even when frontend and AtomicServer share an origin.
+ await page.route('**/*', route => {
+ const url = new URL(route.request().url());
+ if (/^\/(integration-proxy|plugin-run|commit)(\/|$)/.test(url.pathname)) {
+ forbidden.push(url.pathname);
+ return route.abort();
+ }
+ if (
+ url.origin === new URL(SERVER_URL).origin &&
+ url.origin !== new URL(FRONTEND_URL).origin
+ )
+ return route.abort();
+ return route.continue();
+ });
+ try {
+ await page.goto(`${FRONTEND_URL}/app/dev-drive`);
+ await page.waitForURL(/app\/show\?subject=/, { timeout: 60000 });
+ await page.goto(`${FRONTEND_URL}/app/devonian-demo`);
+ await page
+ .getByText('Connect a real GitHub repository', { exact: true })
+ .click();
+ await page.getByLabel('Integration proxy URL').fill(proxyOrigin);
+ await page.getByLabel('GitHub repository (owner/repo)').fill(repository);
+ await page.getByLabel('LocalThought tenant secret').fill(tenantSecret);
+ await page
+ .getByRole('button', { name: 'Connect GitHub tracker', exact: true })
+ .click();
+ await page
+ .getByRole('button', { name: 'Connect test account', exact: true })
+ .click();
+ const sync = async () => {
+ const button = page.getByRole('button', {
+ name: 'Sync now',
+ exact: true,
+ });
+ await button.click();
+ await expect(button).toBeEnabled();
+ await expect(page.getByRole('alert')).toHaveCount(0);
+ };
+ await expect(
+ page.getByRole('button', { name: 'Sync now', exact: true }),
+ ).toBeVisible();
+ expect(page.url()).not.toContain('connection_code');
+ expect(
+ await page.evaluate(() =>
+ JSON.stringify({ ...localStorage, ...sessionStorage }),
+ ),
+ ).not.toContain(tenantSecret);
+ await sync();
+ const issue = (title: string) =>
+ page
+ .getByTestId('atomic-issue')
+ .filter({ has: page.getByRole('link', { name: title, exact: true }) });
+ await expect(issue('Created on GitHub')).toContainText(
+ 'Initial GitHub comment',
+ );
+
+ const text = page.getByLabel('New issue title or comment');
+ await text.fill('Created in Atomic');
+ await page
+ .getByRole('button', { name: 'Create Atomic issue', exact: true })
+ .click();
+ await expect(issue('Created in Atomic')).toBeVisible();
+ await sync();
+ const created = remote
+ .snapshot(repository)
+ .issues.find(i => i.title === 'Created in Atomic');
+ expect(created).toBeDefined();
+ expect(remote.snapshot(repository).issues).toHaveLength(2);
+
+ await text.fill('Comment from Atomic');
+ await issue('Created in Atomic')
+ .getByRole('button', { name: 'Add Atomic comment', exact: true })
+ .click();
+ await issue('Created in Atomic')
+ .getByRole('button', { name: 'Close Atomic issue', exact: true })
+ .click();
+ await sync();
+ expect(
+ remote.snapshot(repository).issues.find(i => i.number === created!.number)
+ ?.state,
+ ).toBe('closed');
+ expect(
+ remote
+ .snapshot(repository)
+ .comments.filter(c => c.issue_url.endsWith(`/${created!.number}`))
+ .map(c => c.body),
+ ).toEqual(['Comment from Atomic']);
+
+ remote.updateIssue(repository, created!.number, { state: 'open' });
+ remote.createComment(repository, created!.number, {
+ body: 'Reply from GitHub',
+ });
+ remote.updateIssue(repository, initial.number, { state: 'closed' });
+ await sync();
+ await expect(
+ issue('Created in Atomic').getByRole('button', {
+ name: 'Close Atomic issue',
+ exact: true,
+ }),
+ ).toBeVisible();
+ await expect(issue('Created in Atomic')).toContainText('Reply from GitHub');
+ await expect(
+ issue('Created on GitHub').getByRole('button', {
+ name: 'Reopen Atomic issue',
+ exact: true,
+ }),
+ ).toBeVisible();
+ await issue('Created on GitHub')
+ .getByRole('button', { name: 'Reopen Atomic issue', exact: true })
+ .click();
+ await sync();
+ expect(
+ remote.snapshot(repository).issues.find(i => i.number === initial.number)
+ ?.state,
+ ).toBe('open');
+
+ const before = remote.snapshot(repository);
+ const subjects = await page
+ .getByTestId('atomic-issue')
+ .getByRole('link')
+ .evaluateAll(links =>
+ links.map(link => link.getAttribute('href')).sort(),
+ );
+ await page.reload();
+ await sync();
+ await expect(page.getByTestId('atomic-issue')).toHaveCount(2);
+ await expect(issue('Created in Atomic')).toContainText('Reply from GitHub');
+ expect(
+ await page
+ .getByTestId('atomic-issue')
+ .getByRole('link')
+ .evaluateAll(links =>
+ links.map(link => link.getAttribute('href')).sort(),
+ ),
+ ).toEqual(subjects);
+ expect(remote.snapshot(repository)).toEqual(before);
+ expect(requests).toEqual(expect.arrayContaining(['GET', 'POST', 'PATCH']));
+ expect(forbidden).toEqual([]);
+ } finally {
+ proxy.closeAllConnections();
+ await new Promise((resolve, reject) =>
+ proxy.close(error => (error ? reject(error) : resolve())),
+ );
+ }
+});
diff --git a/browser/e2e/tests/google-calendar-import.spec.mts b/browser/e2e/tests/google-calendar-import.spec.mts
new file mode 100644
index 0000000000..22d00c5e81
--- /dev/null
+++ b/browser/e2e/tests/google-calendar-import.spec.mts
@@ -0,0 +1,169 @@
+import { test, expect } from '@playwright/test';
+import {
+ mockProxy,
+ tenantSecret,
+} from '../../../integrations/localthought/mock-proxy.mjs';
+const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:6747';
+const SERVER_URL = process.env.SERVER_URL ?? 'http://localhost:9883';
+test.use({ serviceWorkers: 'block' });
+
+// Reuse the browser transport and HTTP mock from the Devonian integration.
+// The tenant secret is a public fixture. No real provider credentials are used.
+test('Calendar imports, refreshes and persists through the browser with AtomicServer unavailable', async ({
+ page,
+}) => {
+ test.setTimeout(180_000);
+ const proxy = mockProxy({ frontendOrigin: new URL(FRONTEND_URL).origin });
+ await new Promise(resolve => proxy.listen(0, '127.0.0.1', resolve));
+ const port = (proxy.address() as { port: number }).port;
+ const proxyOrigin = `http://127.0.0.1:${port}`;
+ const configuredProxy =
+ process.env.VITE_INTEGRATION_PROXY_URL || 'https://localthought.io';
+ await page.routeWebSocket('**/*', socket => socket.close());
+ const forbidden: string[] = [];
+ const providerMethods: string[] = [];
+ // Forward the configured proxy to this test's isolated HTTP fixture. All
+ // tenant proof, consent, code rotation, pagination and WASM code remain real.
+ await page.route('**/*', async route => {
+ const request = route.request();
+ const url = new URL(request.url());
+
+ if (/^\/(integration-proxy|plugin-run|commit)(\/|$)/.test(url.pathname)) {
+ forbidden.push(url.pathname);
+
+ return route.abort();
+ }
+
+ if (url.origin === configuredProxy) {
+ if (url.pathname.startsWith('/proxy/'))
+ providerMethods.push(request.method());
+ const response = await route.fetch({
+ url: `${proxyOrigin}${url.pathname}${url.search}`,
+ maxRedirects: 0,
+ });
+
+ return route.fulfill({ response });
+ }
+
+ if (
+ url.origin === new URL(SERVER_URL).origin &&
+ url.origin !== new URL(FRONTEND_URL).origin
+ )
+ return route.abort();
+
+ return route.continue();
+ });
+
+ try {
+ await page.goto(`${FRONTEND_URL}/app/dev-drive`);
+ await page.waitForURL(/app\/show\?subject=/, { timeout: 60000 });
+
+ const setup = async () => {
+ await page
+ .getByRole('link', { name: 'Integrations', exact: true })
+ .click();
+ await page
+ .locator('[data-integration="google-calendar"]')
+ .getByRole('button', { name: 'Set up connection' })
+ .click();
+ };
+
+ await setup();
+ await page.getByLabel('LocalThought tenant secret').fill(tenantSecret);
+ await page
+ .getByRole('button', { name: 'Install and connect', exact: true })
+ .click();
+ await page
+ .getByRole('button', { name: 'Connect test account', exact: true })
+ .click();
+ await expect(
+ page.getByRole('button', { name: 'Fetch and preview', exact: true }),
+ ).toBeVisible();
+ expect(page.url()).not.toContain('connection_code');
+ expect(
+ await page.evaluate(() =>
+ JSON.stringify({ ...localStorage, ...sessionStorage }),
+ ),
+ ).not.toContain(tenantSecret);
+
+ const apply = async (count: number) => {
+ await page
+ .getByRole('button', { name: 'Fetch and preview', exact: true })
+ .click();
+ await page
+ .getByRole('button', { name: new RegExp(`^Apply ${count} changes?$`) })
+ .click();
+ await page
+ .getByRole('link', { name: 'Open imported records', exact: true })
+ .click();
+ await expect(page.getByTestId('calendar-view')).toBeVisible();
+ };
+
+ await apply(2);
+ await expect(page.getByTestId('calendar-event')).toHaveCount(2);
+ await page.getByText('Calendar timed fixture', { exact: true }).click();
+ await expect(page.getByRole('dialog').last()).toContainText(
+ 'Recurring event',
+ );
+ await expect(page.getByRole('dialog').last()).toContainText(
+ 'Attendees and RSVP',
+ );
+ await page.keyboard.press('Escape');
+ const original = await page.evaluate(async () => {
+ const store = window.store!;
+ const table = new URL(location.href).searchParams.get('subject')!;
+ const result = await store.queryLocalDb({
+ drive: store.getDrive()!,
+ property: 'https://atomicdata.dev/properties/parent',
+ value: table,
+ limit: 100,
+ });
+ const subjects = result!.subjects.sort();
+ const row = await store.getResource(subjects[0]);
+ await row.set(
+ 'https://atomicdata.dev/properties/description',
+ 'Atomic-only notes',
+ );
+ await row.save();
+ await store.getClientDb()!.flush();
+
+ return { subjects, table, annotated: row.subject };
+ });
+ await page.reload();
+ await expect(page.getByTestId('calendar-event')).toHaveCount(2);
+ proxy.calendar.events[1].summary = 'Calendar refreshed fixture';
+ await setup();
+ await apply(1);
+ await expect(
+ page.getByText('Calendar refreshed fixture', { exact: true }),
+ ).toBeVisible();
+ await expect(page.getByTestId('calendar-event')).toHaveCount(2);
+ const restored = await page.evaluate(async saved => {
+ const store = window.store!;
+ const result = await store.queryLocalDb({
+ drive: store.getDrive()!,
+ property: 'https://atomicdata.dev/properties/parent',
+ value: saved.table,
+ limit: 100,
+ });
+
+ return {
+ subjects: result!.subjects.sort(),
+ note: (await store.getResource(saved.annotated)).get(
+ 'https://atomicdata.dev/properties/description',
+ ),
+ };
+ }, original);
+ expect(restored.subjects).toEqual(original.subjects);
+ expect(restored.note).toBe('Atomic-only notes');
+ expect(providerMethods).toEqual(['GET', 'GET', 'GET', 'GET']);
+ expect(
+ proxy.calendar.requests.filter(r => r.query.pageToken === 'second'),
+ ).toHaveLength(2);
+ expect(forbidden).toEqual([]);
+ } finally {
+ await new Promise((resolve, reject) =>
+ proxy.close(error => (error ? reject(error) : resolve())),
+ );
+ }
+});
diff --git a/browser/e2e/tests/plugins.spec.ts b/browser/e2e/tests/plugins.spec.ts
index 1d4e1f0a6f..1c0f9b0392 100644
--- a/browser/e2e/tests/plugins.spec.ts
+++ b/browser/e2e/tests/plugins.spec.ts
@@ -28,10 +28,9 @@ test.describe('plugins', () => {
!process.env.ATOMIC_MOCK_INTEGRATION_PROXY,
'Run with the documented mock integration-proxy server configuration',
);
- await page.getByRole('link', { name: 'Integrations', exact: true }).click();
// CI's browser and server are in different containers. Forward the mock's
- // loopback address to the server container; the real connect/rotation logic runs there.
+ // loopback address to the server container before catalog loading starts.
if (process.env.ATOMIC_SERVICE_URL)
await page.route('http://127.0.0.1:19090/**', async route => {
const target = new URL(route.request().url());
@@ -42,6 +41,7 @@ test.describe('plugins', () => {
});
await route.fulfill({ response });
});
+ await page.getByRole('link', { name: 'Integrations', exact: true }).click();
const pets = page.locator('[data-integration=pets]');
await expect(
pets.getByRole('heading', { name: 'Pets', exact: true }),
@@ -52,6 +52,9 @@ test.describe('plugins', () => {
await expect(
setup.getByRole('button', { name: 'Install and connect', exact: true }),
).toBeVisible();
+ await setup
+ .getByLabel('LocalThought tenant secret')
+ .fill('bW9jay10ZW5hbnQ.mock-signature');
await setup
.getByRole('button', { name: 'Install and connect', exact: true })
.click();
@@ -61,23 +64,11 @@ test.describe('plugins', () => {
).toBeVisible();
await page.getByRole('button', { name: 'Connect test account' }).click();
await expect(page).not.toHaveURL(/connection_code=/);
- const [preview] = await Promise.all([
- page.waitForResponse(
- response =>
- response.url().endsWith('/plugin-run') &&
- response.request().method() === 'POST',
- { timeout: 45_000 },
- ),
- page.getByRole('button', { name: 'Fetch and preview' }).click(),
- ]);
- expect(preview.ok(), await preview.text()).toBe(true);
+ await page.getByRole('button', { name: 'Fetch and preview' }).click();
const review = page.locator('dialog[open]');
- // Installing walks pluginClassesFor, two ensureSchema calls, three
- // ensureInstallationResource calls and the actual sandboxed plugin run —
- // measured ~24s locally even on a fresh, otherwise-idle server. The
- // default 10s expect timeout is tuned for interaction latency, not this
- // one-time setup cost.
+ // The browser creates the local ontology, tables and reviewed proposal.
+ // Allow the one-time installation more than the interaction timeout.
await expect(
review.getByRole('button', { name: 'Apply 5 changes', exact: true }),
).toBeEnabled({ timeout: 45_000 });
diff --git a/integrations/github-issues/README.md b/integrations/github-issues/README.md
index f265fa5f0c..e0f80d1254 100644
--- a/integrations/github-issues/README.md
+++ b/integrations/github-issues/README.md
@@ -1,3 +1,10 @@
+## Browser-only Devonian demo
+
+Open `/app/devonian-demo` for a browser-side issue/comment sync demo using
+Devonian and this integration's mappings. It uses a local-only Atomic drive and
+direct integration-proxy requests, with an explicit sample mode. See
+[setup, source and the current live proxy CORS limitation](devonian/README.md).
+
## Connect in the app
Open **Integrations**, enter `owner/repository` and a repository-scoped GitHub
diff --git a/integrations/github-issues/adapter.test.ts b/integrations/github-issues/adapter.test.ts
index e60052a966..854422b60d 100644
--- a/integrations/github-issues/adapter.test.ts
+++ b/integrations/github-issues/adapter.test.ts
@@ -3,6 +3,7 @@ import { preview, project, manifest, type Issue } from './adapter.js';
import { validateManifest } from '../../browser/lib/src/plugin-manifest.js';
import { readFile } from 'node:fs/promises';
import { execFileSync } from 'node:child_process';
+import { existsSync } from 'node:fs';
const issue = (number: number): Issue => ({
number,
title: `Issue ${number}`,
@@ -18,7 +19,9 @@ describe('GitHub package', () => {
});
it('ships exactly the artifact tested by the sandbox', async () => {
const built = execFileSync(
- './browser/node_modules/.bin/esbuild',
+ existsSync('./browser/node_modules/.bin/esbuild')
+ ? './browser/node_modules/.bin/esbuild'
+ : './browser/node_modules/.pnpm/node_modules/.bin/esbuild',
[
'integrations/github-issues/plugin.ts',
'--bundle',
diff --git a/integrations/github-issues/devonian/README.md b/integrations/github-issues/devonian/README.md
new file mode 100644
index 0000000000..56c895d925
--- /dev/null
+++ b/integrations/github-issues/devonian/README.md
@@ -0,0 +1,129 @@
+# Browser issue tracker demo
+
+Open `/app/devonian-demo` in the Atomic web app. Choose **Try sample data**,
+create issues or comments on either side, close/reopen an issue, and press
+**Sync now**. **Open Atomic kanban** opens the real native tracker, whose issue
+pages also have the normal Comments panel. Reload and choose the same mode to
+resume saved resources and mappings without importing duplicates.
+
+The JavaScript runs in the browser. Devonian's native resource lenses run there;
+Atomic resources live in a local-only drive in the browser's WASM/OPFS database.
+The intermediate graph, scoped identity mappings, baselines and request journal
+live in IndexedDB. No Node runtime, custom AtomicServer endpoint, server-side
+plugin executor, tenant secret on AtomicServer or server worker is used. Enable
+the browser database if it has been disabled.
+
+## Live GitHub
+
+Expand **Connect a real GitHub repository**, enter the integration-proxy origin,
+`owner/repo`, and your **LocalThought tenant secret**, then choose **Connect GitHub
+tracker**. The demo uses #1401's shared `BrowserIntegrations` client to sign the
+tenant challenge in the browser, navigate to proxy consent, and bind the return
+to this agent and local drive. The secret is cleared from the form and never
+persisted. The callback code is removed from the address bar immediately.
+
+After consent, **Sync now** authorizes two-way writes. The shared transport
+serializes requests with Web Locks, consumes each code before dispatch, and
+persists rotated credentials in browser localStorage, outside the Atomic graph.
+Reloading the tab resumes the local tracker and connection. Use a dedicated
+demo repository; writes use the connected GitHub account.
+
+The browser calls `/proxy/github-issues/repos/{owner}/{repo}/issues...` directly.
+The proxy instance must answer unauthenticated OPTIONS preflights, allow the
+app's origin and GET/POST/PATCH/DELETE with Authorization and Content-Type,
+expose `X-Connection-Code`, preserve query parameters, and allow issue/comment
+and label operations in its catalog. CORS headers must cover error responses too.
+
+**Live verification (2026-09-09):** Heroku release v40 (`bc02f13f`) now
+answers browser preflights and exposes `X-Connection-Code`. Tenant challenge,
+GitHub OAuth and return to the browser demo succeeded with AtomicServer
+unavailable. The first authenticated issue-list request for the private
+`ontola/atomic-github-sync-sandbox` returned 404, although the independent
+GitHub CLI credential can access it. Repository access for the proxy credential
+must be resolved before live two-way writes can be verified. The disposable
+CLI-created issue #17 was closed; no issue/comment writes occurred through the proxy.
+
+## Mapping
+
+`GitHubPort` reuses `github-issues/adapter.ts`'s projection and request builder.
+`tracker-actions.ts` adds scoped issue/comment request construction for the
+browser transport. The installed server plugin's actions remain unchanged.
+
+- Title/body map to the native row's name and Markdown description.
+- Open maps to Todo; open with `atomic:doing` maps to Doing; closed maps to Done.
+ Other labels are preserved. Provision `atomic:doing` before using Doing.
+- A comment is a native Message whose `about` points to its issue and whose
+ `parent` is the drive's Comments folder. Body edits synchronize both ways.
+- GitHub identity, author and original timestamps are retained in a separate
+ provenance property. The importing Atomic agent is distinct from the original
+ author. GitHub writes use the connected account's authorship.
+- The demo creates drive-local task properties so setup works offline. The
+ intermediate Devonian graph uses the shared task vocabulary and maps native
+ properties explicitly. Atomic DIDs are scoped external IDs because Devonian's
+ current native subjects must be HTTP(S) URLs.
+- Explicit issue numbers can bind existing rows; matching text never does.
+ Comments have their own scoped IDs; identical comments remain distinct.
+
+## Persistence and limits
+
+A Web Lock serializes syncs. Three-way reconciliation preserves independent
+field edits and stops on same-field conflicts. Reloads resume saved operations
+before discovering new records. Ingest never automatically publishes an echo.
+Missing records are conflicts; neither side is deleted.
+
+The proxy does not provide idempotent GitHub creation. Writes are journaled
+before sending. Successful receipts can be replayed; an uncertain/lost response
+stops without resending. An operator must inspect and reconcile that operation;
+there is no recovery wizard yet. Do not clear IndexedDB to retry a create.
+Concurrent edits during a multi-request status transition may also require
+reconciliation. Pre-write reads and verification do not make local/provider
+writes one transaction. Sync is explicit while the tab is open, with a
+10,000-record scan cap. Clearing browser site data loses the demo and its state.
+
+## Source and verification
+
+- `browser/data-browser/src/chunks/DevonianDemo/demo.mjs`: browser entry script.
+- `bridge.mjs`: Devonian lenses and checkpointed reconciliation.
+- `ports.mjs`: native Atomic and GitHub projections/transports.
+- `proxy.mjs`: rotating-code transport and labelled sample fixture.
+- `browser/data-browser/src/routes/DevonianDemoRoute.tsx`: demo controls.
+
+The committed `DevonianDemo/devonian.js` bundle contains only the native resource
+API from Devonian main `e11104f78ebd151a361171ca0b21489b25e1e2c8`, with its
+Apache-2.0 license alongside. Regenerate at development time:
+
+```sh
+DEVONIAN_PATH=/path/to/devonian node integrations/github-issues/devonian/build.mjs
+browser/node_modules/.bin/vitest run --config integrations/github-issues/devonian/vitest.config.mjs
+browser/node_modules/.bin/vitest run --config integrations/github-issues/vitest.config.ts
+cd browser/data-browser && pnpm typecheck
+```
+
+Tests use real Devonian lenses and deterministic connectors: creation, identical
+content with distinct identities, independent edits, close/reopen, comments,
+conflicts, missing resources, replay after lost receipts, label preservation,
+pagination, scoped URLs and serialized code rotation. The browser sample flow
+was manually verified with native OPFS resources, issue creation on both sides,
+comments both ways, closing from Atomic and reopening from the GitHub fixture,
+then reloading and resuming the same three issues and two comments without duplication.
+
+## Playwright two-way regression
+
+`browser/e2e/tests/devonian-issue-sync.spec.mts` starts an isolated HTTP integration
+proxy with stateful, repository-scoped GitHub issue/comment endpoints. It fills
+the tenant-secret form with the **public mock secret** (no real credentials),
+completes consent, and exercises the live transport mode rather than sample mode.
+It verifies creation and comments in both directions, close/reopen in both
+directions, matching parent issues, and reload without duplicate resources or
+provider writes. Legacy server integration endpoints, commit POSTs and all WebSockets are blocked.
+The local run uses an unavailable AtomicServer port to verify browser-only storage.
+
+With Vite and the built browser/WASM packages available:
+
+```sh
+cd browser/e2e
+FRONTEND_URL=http://localhost:6747 playwright test tests/devonian-issue-sync.spec.mts --project chromium
+```
+
+The test is in the full E2E suite, without a smoke tag. The mock is local test
+infrastructure only; the runtime demo uses the real integration-proxy protocol.
diff --git a/integrations/github-issues/devonian/bridge.mjs b/integrations/github-issues/devonian/bridge.mjs
new file mode 100644
index 0000000000..3f90390a4d
--- /dev/null
+++ b/integrations/github-issues/devonian/bridge.mjs
@@ -0,0 +1,243 @@
+import { reconcileRecord } from '../../../browser/lib/src/plugin-reconcile.js';
+
+const p = {
+ title: 'https://atomicdata.dev/properties/name',
+ body: 'https://atomicdata.dev/task/v1/body',
+ status: 'https://atomicdata.dev/task/v1/status',
+};
+const tag = 'https://atomicdata.dev/task/v1/';
+const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
+const copy = value => structuredClone(value);
+
+/** Single-writer, checkpointed reconciliation. Ports own transport and durable writes. */
+export class Bridge {
+ constructor({ devonian, local, remote, base, snapshot, save }) {
+ this.api = devonian;
+ this.local = local;
+ this.remote = remote;
+ this.save = save;
+ const { AtomicSchema, AtomicStore, AtomicIdentityMap, Datatype } = devonian;
+ this.store = new AtomicStore(
+ new AtomicSchema()
+ .property(p.title, Datatype.STRING)
+ .property(p.body, Datatype.MARKDOWN)
+ .property(p.status, Datatype.RESOURCEARRAY),
+ );
+ this.identities = new AtomicIdentityMap(this.store, base);
+ this.binding = { base, local: local.scope, remote: remote.scope };
+ if (snapshot && !equal(snapshot.binding, this.binding))
+ throw new Error('State belongs to another connection');
+ if (snapshot?.graph) this.store.loadJSONAD(snapshot.graph);
+ this.records = copy(snapshot?.records ?? {});
+ }
+
+ scope(side, entity) {
+ return { scope: this[side].scope, entity };
+ }
+ id(side, entity, subject) {
+ return this.identities.externalId(this.scope(side, entity), subject);
+ }
+ context(side, entity) {
+ if (entity === 'issue') return {};
+ const parent = entity.slice('comment:'.length);
+ const issueId = this.id(side, 'issue', parent);
+ if (issueId === undefined)
+ throw new Error('Issue must be mapped before comments');
+ return { issueId };
+ }
+ async checkpoint() {
+ await this.save({
+ version: 1,
+ binding: this.binding,
+ graph: this.store.toJSONAD(),
+ records: copy(this.records),
+ });
+ }
+ properties(value) {
+ return {
+ [p.body]: value.body,
+ ...(value.title === undefined
+ ? {}
+ : {
+ [p.title]: value.title,
+ [p.status]: [`${tag}${value.status.toLowerCase()}`],
+ }),
+ };
+ }
+ value(resource, entity) {
+ if (entity !== 'issue') return { body: resource[p.body] };
+ const status = {
+ [`${tag}todo`]: 'Todo',
+ [`${tag}doing`]: 'Doing',
+ [`${tag}done`]: 'Done',
+ }[resource[p.status]?.[0]];
+ if (!status || resource[p.status].length !== 1)
+ throw new Error('Unsupported task status');
+ return { title: resource[p.title], body: resource[p.body], status };
+ }
+ lens(side, entity, operation, metadata) {
+ const port = this[side],
+ context = this.context(side, entity);
+ return new this.api.AtomicLens({
+ store: this.store,
+ identities: this.identities,
+ ...this.scope(side, entity),
+ connector: {
+ id: row => row.id,
+ get: id => port.get(entity, id, context),
+ create: (row, key) =>
+ port.create(entity, row.value, key, metadata, context),
+ update: (id, row) =>
+ port.update(
+ entity,
+ id,
+ row.value,
+ `${operation}:${side}`,
+ metadata,
+ context,
+ ),
+ delete: async () => {
+ throw new Error('Deletion is outside issue sync');
+ },
+ },
+ read: row => ({ set: this.properties(row.value) }),
+ write: (resource, previous) => ({
+ ...previous,
+ value: this.value(resource, entity),
+ }),
+ });
+ }
+
+ async sync() {
+ // Resume saved operations BEFORE discovering their newly-created counterparts.
+ for (const [subject, record] of Object.entries(this.records)) {
+ if (record.pending) await this.finish(subject, record);
+ }
+ await this.syncEntity('issue');
+ for (const [subject, record] of Object.entries(this.records)) {
+ if (record.entity === 'issue')
+ await this.syncEntity(`comment:${subject}`);
+ }
+ }
+
+ async syncEntity(entity) {
+ const lists = {};
+ for (const side of ['remote', 'local']) {
+ const rows = await this[side].list(entity, this.context(side, entity));
+ lists[side] = new Map();
+ for (const row of rows) {
+ if (lists[side].has(row.id))
+ throw new Error('Duplicate external identity');
+ lists[side].set(row.id, row);
+ }
+ }
+ // An existing pilot's explicit issue-number column is an identity, never a title match.
+ for (const row of lists.local.values()) {
+ if (row.remoteId === undefined) continue;
+ const scope = this.scope('remote', entity);
+ const subject = this.identities.subjectFor(scope, row.remoteId);
+ this.identities.bind(scope, row.remoteId, subject);
+ this.identities.bind(this.scope('local', entity), row.id, subject);
+ this.records[subject] ??= { entity };
+ }
+ for (const side of ['remote', 'local']) {
+ for (const row of lists[side].values()) {
+ let subject = this.identities.lookup(this.scope(side, entity), row.id);
+ if (!subject) subject = await this.lens(side, entity).ingest(row);
+ this.records[subject] ??= { entity };
+ }
+ }
+ await this.checkpoint();
+ for (const [subject, record] of Object.entries(this.records)) {
+ if (record.entity !== entity) continue;
+ const rows = {};
+ for (const side of ['local', 'remote']) {
+ const id = this.id(side, entity, subject);
+ rows[side] = id === undefined ? undefined : lists[side].get(id);
+ if (id !== undefined && !rows[side])
+ throw new Error(`Missing ${side} record: ${subject}`);
+ }
+ const decision = reconcileRecord(
+ record.baseline,
+ rows.local?.value,
+ rows.remote?.value,
+ );
+ if (decision.conflicts.length)
+ throw new Error(
+ `Conflict on ${subject}: ${decision.conflicts.map(c => c.property).join(', ')}`,
+ );
+ const desired = {
+ ...(rows.remote?.value ?? rows.local?.value),
+ ...decision.remote,
+ };
+ const metadata = rows.remote?.metadata;
+ if (
+ rows.local &&
+ rows.remote &&
+ equal(rows.local.value, rows.remote.value) &&
+ equal(rows.local.metadata, metadata)
+ ) {
+ record.baseline = copy(desired);
+ await this.checkpoint();
+ continue;
+ }
+ record.pending = {
+ operation: crypto.randomUUID(),
+ local: rows.local?.value,
+ remote: rows.remote?.value,
+ desired,
+ metadata,
+ };
+ this.store.patch(subject, { set: this.properties(desired) });
+ await this.checkpoint();
+ await this.finish(subject, record);
+ }
+ }
+
+ async finish(subject, record) {
+ const { pending, entity } = record;
+ // A retry accepts only the original observation or this operation's exact result.
+ for (const side of ['local', 'remote']) {
+ const id = this.id(side, entity, subject);
+ if (id === undefined) continue;
+ const row = await this[side].get(entity, id, this.context(side, entity));
+ if (
+ !equal(row.value, pending[side]) &&
+ !equal(row.value, pending.desired)
+ )
+ throw new Error(`Conflict during saved operation on ${subject}`);
+ }
+ for (const side of ['remote', 'local']) {
+ const id = this.id(side, entity, subject);
+ const row =
+ id === undefined
+ ? undefined
+ : await this[side].get(entity, id, this.context(side, entity));
+ if (
+ !row ||
+ !equal(row.value, pending.desired) ||
+ (side === 'local' && !equal(row.metadata, pending.metadata))
+ ) {
+ await this.lens(
+ side,
+ entity,
+ pending.operation,
+ pending.metadata,
+ ).publish(subject);
+ await this.checkpoint();
+ }
+ }
+ for (const side of ['local', 'remote']) {
+ const row = await this[side].get(
+ entity,
+ this.id(side, entity, subject),
+ this.context(side, entity),
+ );
+ if (!equal(row.value, pending.desired))
+ throw new Error(`Concurrent edit after write on ${subject}`);
+ }
+ record.baseline = copy(pending.desired);
+ delete record.pending;
+ await this.checkpoint();
+ }
+}
diff --git a/integrations/github-issues/devonian/bridge.test.mjs b/integrations/github-issues/devonian/bridge.test.mjs
new file mode 100644
index 0000000000..672a7b1030
--- /dev/null
+++ b/integrations/github-issues/devonian/bridge.test.mjs
@@ -0,0 +1,133 @@
+import { expect, it } from 'vitest';
+import * as devonian from 'devonian';
+import { Bridge } from './bridge.mjs';
+
+function fixture(snapshot) {
+ let saved = snapshot;
+ const makePort = scope => ({
+ scope,
+ rows: new Map(),
+ receipts: new Map(),
+ writes: 0,
+ lose: false,
+ async list(entity) {
+ return [...this.rows.values()].filter(r => r.entity === entity);
+ },
+ async get(entity, id) {
+ const row = this.rows.get(id);
+ if (!row || row.entity !== entity) throw new Error('Missing record');
+ return structuredClone(row);
+ },
+ async create(entity, value, key, metadata) {
+ if (this.receipts.has(key)) return this.receipts.get(key);
+ const id = this.rows.size + 1;
+ const row = { id, entity, value: structuredClone(value), metadata };
+ this.rows.set(id, row);
+ this.receipts.set(key, row);
+ this.writes++;
+ if (this.lose) {
+ this.lose = false;
+ throw new Error('Lost response');
+ }
+ return row;
+ },
+ async update(entity, id, value) {
+ const row = await this.get(entity, id);
+ this.rows.set(id, { ...row, value: structuredClone(value) });
+ this.writes++;
+ },
+ });
+ const local = makePort('https://atomic.example/bridge');
+ const remote = makePort('https://github.com/acme/repo');
+ const open = () =>
+ new Bridge({
+ devonian,
+ local,
+ remote,
+ snapshot: saved,
+ base: 'https://bridge.example/sync',
+ save: async s => {
+ saved = structuredClone(s);
+ },
+ });
+ return { local, remote, open, saved: () => saved };
+}
+const issue = (id, title = 'Same title') => ({
+ id,
+ entity: 'issue',
+ value: { title, body: '', status: 'Todo' },
+});
+
+it('syncs creation both ways without deduplicating equal content, then no-ops after restart', async () => {
+ const f = fixture();
+ f.local.rows.set(1, issue(1));
+ f.remote.rows.set(1, issue(1));
+ await f.open().sync();
+ expect(f.local.rows.size).toBe(2);
+ expect(f.remote.rows.size).toBe(2);
+ const writes = f.local.writes + f.remote.writes;
+ await f.open().sync();
+ expect(f.local.writes + f.remote.writes).toBe(writes);
+});
+
+it('merges independent title/body edits and propagates close/reopen', async () => {
+ const f = fixture();
+ f.remote.rows.set(1, issue(1));
+ await f.open().sync();
+ f.local.rows.get(1).value.title = 'Local title';
+ f.remote.rows.get(1).value.body = 'Remote body';
+ f.local.rows.get(1).value.status = 'Done';
+ await f.open().sync();
+ expect(f.remote.rows.get(1).value).toEqual({
+ title: 'Local title',
+ body: 'Remote body',
+ status: 'Done',
+ });
+ f.remote.rows.get(1).value.status = 'Todo';
+ await f.open().sync();
+ expect(f.local.rows.get(1).value.status).toBe('Todo');
+});
+
+it('syncs comments and edits both ways, preserving source metadata', async () => {
+ const f = fixture();
+ f.remote.rows.set(1, issue(1));
+ await f.open().sync();
+ const parent = Object.keys(f.saved().records)[0];
+ const entity = `comment:${parent}`;
+ f.remote.rows.set(2, {
+ id: 2,
+ entity,
+ value: { body: 'GitHub comment' },
+ metadata: { author: 'octocat' },
+ });
+ f.local.rows.set(2, { id: 2, entity, value: { body: 'Atomic comment' } });
+ await f.open().sync();
+ expect(f.local.rows.get(3).metadata).toEqual({ author: 'octocat' });
+ expect(f.remote.rows.get(3).value.body).toBe('Atomic comment');
+ f.local.rows.get(3).value.body = 'Edited';
+ await f.open().sync();
+ expect(f.remote.rows.get(2).value.body).toBe('Edited');
+});
+
+it('stops on same-field conflicts and missing records without deleting either side', async () => {
+ const f = fixture();
+ f.remote.rows.set(1, issue(1));
+ await f.open().sync();
+ f.local.rows.get(1).value.title = 'A';
+ f.remote.rows.get(1).value.title = 'B';
+ await expect(f.open().sync()).rejects.toThrow('Conflict');
+ expect(f.local.rows.get(1).value.title).toBe('A');
+ f.remote.rows.delete(1);
+ await expect(f.open().sync()).rejects.toThrow('Missing');
+ expect(f.local.rows.size).toBe(1);
+});
+
+it('reuses the same create identity after a lost receipt and restart', async () => {
+ const f = fixture();
+ f.local.rows.set(1, issue(1));
+ f.remote.lose = true;
+ await expect(f.open().sync()).rejects.toThrow('Lost response');
+ await f.open().sync();
+ expect(f.remote.rows.size).toBe(1);
+ expect(f.remote.writes).toBe(1);
+});
diff --git a/integrations/github-issues/devonian/build.mjs b/integrations/github-issues/devonian/build.mjs
new file mode 100644
index 0000000000..9a6c4c2051
--- /dev/null
+++ b/integrations/github-issues/devonian/build.mjs
@@ -0,0 +1,36 @@
+/** Development-time bundle; the demo itself runs entirely in the browser. */
+import { createRequire } from 'node:module';
+import { resolve, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
+const require = createRequire(import.meta.url);
+const { build } = require(
+ require.resolve('esbuild', {
+ paths: [
+ resolve(root, 'browser'),
+ resolve(root, 'browser/node_modules/.pnpm'),
+ ],
+ }),
+);
+if (!process.env.DEVONIAN_PATH)
+ throw new Error('Set DEVONIAN_PATH to Devonian main');
+await build({
+ stdin: {
+ contents: ['Resource', 'Store', 'IdentityMap', 'Lens']
+ .map(name => `export * from './src/atomic/${name}.js';`)
+ .join('\n'),
+ resolveDir: resolve(process.env.DEVONIAN_PATH),
+ loader: 'ts',
+ },
+ bundle: true,
+ platform: 'browser',
+ format: 'esm',
+ external: ['@tomic/lib'],
+ banner: {
+ js: '// Devonian native resource API, e11104f78ebd151a361171ca0b21489b25e1e2c8. Apache-2.0; see DEVONIAN-LICENSE.',
+ },
+ outfile: resolve(
+ root,
+ 'browser/data-browser/src/chunks/DevonianDemo/devonian.js',
+ ),
+});
diff --git a/integrations/github-issues/devonian/ports.mjs b/integrations/github-issues/devonian/ports.mjs
new file mode 100644
index 0000000000..ca5733d489
--- /dev/null
+++ b/integrations/github-issues/devonian/ports.mjs
@@ -0,0 +1,322 @@
+import { project } from '../adapter.js';
+import { core, dataBrowser } from '../../../browser/lib/src/index.js';
+
+export const digest = async value =>
+ Array.from(
+ new Uint8Array(
+ await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)),
+ ),
+ byte => byte.toString(16).padStart(2, '0'),
+ ).join('');
+const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
+
+/** Reuses the GitHub integration mapping with an injected browser proxy transport. */
+export class GitHubPort {
+ constructor(store, connection, call) {
+ this.store = store;
+ this.connection = connection;
+ this.scope = `https://github.com/${connection.repository}`;
+ if (!call)
+ throw new Error('A browser integration-proxy transport is required');
+ this.call = call;
+ }
+ async request(action, args, key) {
+ const receipt = await this.call(
+ action,
+ args,
+ await digest(key ?? `${action}:${JSON.stringify(args)}`),
+ );
+ if (receipt.status < 200 || receipt.status >= 300)
+ throw new Error(`GitHub ${action} returned ${receipt.status}`);
+ return receipt.body ? JSON.parse(receipt.body) : undefined;
+ }
+ row(entity, raw, context = {}) {
+ if (entity === 'issue') {
+ if (raw.pull_request) throw new Error('Pull requests are excluded');
+ return {
+ id: raw.number,
+ value: project(raw),
+ metadata: this.metadata(raw, { number: raw.number }),
+ };
+ }
+ if (
+ !Number.isSafeInteger(raw.id) ||
+ raw.id <= 0 ||
+ typeof raw.body !== 'string'
+ )
+ throw new Error('Invalid GitHub comment');
+ const expected = `https://api.github.com/repos/${this.connection.repository}/issues/${context.issueId}`;
+ if (raw.issue_url !== expected)
+ throw new Error('Comment belongs to another issue');
+ return {
+ id: raw.id,
+ value: { body: raw.body },
+ metadata: this.metadata(raw, { commentId: raw.id }),
+ };
+ }
+ metadata(raw, identity) {
+ return {
+ ...identity,
+ ...(raw.user?.login ? { author: raw.user.login } : {}),
+ ...(raw.html_url ? { url: raw.html_url } : {}),
+ ...(raw.created_at ? { createdAt: raw.created_at } : {}),
+ ...(raw.updated_at ? { updatedAt: raw.updated_at } : {}),
+ };
+ }
+ async list(entity, context = {}) {
+ const result = [];
+ for (let page = 1; page <= 100; page++) {
+ const raw = await this.request(
+ entity === 'issue' ? 'list_issues' : 'list_comments',
+ {
+ page,
+ ...(entity === 'issue' ? {} : { number: context.issueId }),
+ },
+ );
+ if (!Array.isArray(raw)) throw new Error('Invalid GitHub page');
+ for (const r of raw) {
+ if (entity === 'issue' && 'pull_request' in r) continue;
+ result.push(this.row(entity, r, context));
+ }
+ if (raw.length < 100) return result;
+ }
+ throw new Error('More than 10,000 records in one scan');
+ }
+ async get(entity, id, context) {
+ const raw = await this.request(
+ entity === 'issue' ? 'get_issue' : 'get_comment',
+ entity === 'issue' ? { number: id } : { id },
+ );
+ const row = this.row(entity, raw, context);
+ if (row.id !== id) throw new Error('Provider returned another identity');
+ return row;
+ }
+ async create(entity, value, key, metadata, context) {
+ const raw = await this.request(
+ entity === 'issue' ? 'create_issue' : 'create_comment',
+ entity === 'issue'
+ ? { title: value.title, body: value.body }
+ : { number: context.issueId, body: value.body },
+ `${key}:create`,
+ );
+ const row = this.row(entity, raw, context);
+ if (entity === 'issue' && value.status !== 'Todo')
+ await this.update(
+ entity,
+ row.id,
+ value,
+ `${key}:initial-state`,
+ metadata,
+ context,
+ );
+ return { ...row, value };
+ }
+ async update(entity, id, value, key, _metadata, context) {
+ if (entity !== 'issue') {
+ await this.get(entity, id, context); // validate issue membership before writing
+ await this.request(
+ 'update_comment',
+ { id, body: value.body },
+ `${key}:body`,
+ );
+ return;
+ }
+ if (!['Todo', 'Doing', 'Done'].includes(value.status))
+ throw new Error('Unsupported task status');
+ const current = await this.get(entity, id, context);
+ await this.request(
+ 'update_issue',
+ {
+ number: id,
+ title: value.title,
+ body: value.body,
+ state: value.status === 'Done' ? 'closed' : 'open',
+ },
+ `${key}:fields`,
+ );
+ // Only this workflow label is managed. Closing preserves the label like the pilot.
+ if (value.status === 'Doing' && current.value.status !== 'Doing') {
+ await this.request('add_doing_label', { number: id }, `${key}:doing`);
+ } else if (value.status === 'Todo') {
+ // Closed issues may still carry the doing label, so inspect raw labels.
+ const raw = await this.request('get_issue', { number: id });
+ if (
+ raw.labels.some(
+ l =>
+ (typeof l === 'string' ? l : l.name).toLowerCase() ===
+ 'atomic:doing',
+ )
+ ) {
+ await this.request('remove_doing_label', { number: id }, `${key}:todo`);
+ }
+ }
+ }
+}
+
+/** Signed SDK writes build on the fetched Loro state, preserving unmanaged fields. */
+export class AtomicPort {
+ constructor(store, config) {
+ this.store = store;
+ this.config = config;
+ this.connection = config.connection;
+ this.scope = `https://atomicdata.dev/devonian-local/${encodeURIComponent(this.connection.table)}`;
+ }
+ async subjects(property, value) {
+ const result = await this.store.queryLocalDb({
+ drive: this.connection.drive,
+ property,
+ value,
+ limit: 10001,
+ });
+ if (
+ !result ||
+ result.count > 10000 ||
+ result.subjects.length !== result.count
+ )
+ throw new Error('Incomplete local database query');
+ return result.subjects;
+ }
+ async findByLocalId(parent, localId) {
+ const rows = await Promise.all(
+ (await this.subjects(core.properties.localId, localId)).map(id =>
+ this.resource(id),
+ ),
+ );
+ const matches = rows.filter(r => r.get(core.properties.parent) === parent);
+ if (matches.length > 1)
+ throw new Error('Duplicate Atomic creation identity');
+ return matches[0];
+ }
+ async resource(subject) {
+ const r = await this.store.getResource(subject);
+ if (r.error) throw r.error;
+ r.getLoroDoc();
+ return r;
+ }
+ row(entity, r, context = {}) {
+ const c = this.connection;
+ const classes = r.get(core.properties.isA) ?? [];
+ if (entity === 'issue') {
+ if (
+ !classes.includes(c.rowClass) ||
+ r.get(core.properties.parent) !== c.table
+ )
+ throw new Error('Resource is not a row of this issue tracker');
+ } else if (
+ !classes.includes(dataBrowser.classes.message) ||
+ r.get(dataBrowser.properties.about) !== context.issueId
+ )
+ throw new Error('Message belongs to another issue');
+ let metadata = r.get(this.config.provenance);
+ if (typeof metadata === 'string') metadata = JSON.parse(metadata);
+ if (entity !== 'issue')
+ return {
+ id: r.subject,
+ value: { body: r.get(core.properties.description) ?? '' },
+ ...(metadata ? { metadata } : {}),
+ };
+ const statuses = r.get(c.status) ?? [c.tags.Todo];
+ const status = Object.keys(c.tags).find(s => c.tags[s] === statuses[0]);
+ if (statuses.length !== 1 || !status)
+ throw new Error(
+ 'Choose exactly one Todo/Doing/Done status; Blocked is unmapped',
+ );
+ const title = r.get(core.properties.name),
+ body = r.get(c.body) ?? '';
+ if (typeof title !== 'string' || !title.trim() || typeof body !== 'string')
+ throw new Error('Invalid Atomic issue');
+ const remoteId = r.get(c.number);
+ if (
+ remoteId !== undefined &&
+ (!Number.isSafeInteger(remoteId) || remoteId <= 0)
+ )
+ throw new Error('Invalid GitHub issue number');
+ return {
+ id: r.subject,
+ remoteId,
+ value: { title, body, status },
+ ...(metadata ? { metadata } : {}),
+ };
+ }
+ async list(entity, context = {}) {
+ const ids = await this.subjects(
+ entity === 'issue'
+ ? core.properties.parent
+ : dataBrowser.properties.about,
+ entity === 'issue' ? this.connection.table : context.issueId,
+ );
+ const rows = [];
+ for (const id of ids) {
+ const resource = await this.resource(id);
+ if (
+ !(resource.get(core.properties.isA) ?? []).includes(
+ entity === 'issue'
+ ? this.connection.rowClass
+ : dataBrowser.classes.message,
+ )
+ )
+ continue;
+ rows.push(this.row(entity, resource, context));
+ }
+ return rows;
+ }
+ async get(entity, id, context) {
+ return this.row(entity, await this.resource(id), context);
+ }
+ values(entity, value, metadata, context) {
+ const c = this.connection;
+ return {
+ ...(entity === 'issue'
+ ? {
+ [core.properties.name]: value.title,
+ [c.body]: value.body,
+ [c.status]: [c.tags[value.status]],
+ ...(metadata?.number ? { [c.number]: metadata.number } : {}),
+ }
+ : {
+ [core.properties.description]: value.body,
+ [dataBrowser.properties.about]: context.issueId,
+ }),
+ ...(metadata ? { [this.config.provenance]: metadata } : {}),
+ };
+ }
+ async create(entity, value, key, metadata, context) {
+ const parent =
+ entity === 'issue' ? this.connection.table : this.config.commentsFolder;
+ const localId = `devonian:${await digest(key)}`;
+ const existing = await this.findByLocalId(parent, localId);
+ if (existing) {
+ const row = await this.get(entity, existing.subject, context);
+ if (!equal(row.value, value))
+ throw new Error(
+ 'Recovered Atomic create was edited; reconcile before retry',
+ );
+ return row;
+ }
+ const r = await this.store.newResource({
+ parent,
+ isA: [
+ entity === 'issue'
+ ? this.connection.rowClass
+ : dataBrowser.classes.message,
+ ],
+ propVals: {
+ ...this.values(entity, value, metadata, context),
+ [core.properties.localId]: localId,
+ },
+ });
+ if ((await r.save()) === 'offline')
+ throw new Error('AtomicServer disconnected');
+ return this.get(entity, r.subject, context);
+ }
+ async update(entity, id, value, _key, metadata, context) {
+ const r = await this.resource(id);
+ this.row(entity, r, context);
+ for (const [p, v] of Object.entries(
+ this.values(entity, value, metadata, context),
+ ))
+ await r.set(p, v);
+ if ((await r.save()) === 'offline')
+ throw new Error('AtomicServer disconnected');
+ }
+}
diff --git a/integrations/github-issues/devonian/ports.test.mjs b/integrations/github-issues/devonian/ports.test.mjs
new file mode 100644
index 0000000000..25590e5ec6
--- /dev/null
+++ b/integrations/github-issues/devonian/ports.test.mjs
@@ -0,0 +1,237 @@
+import { expect, it } from 'vitest';
+import { GitHubPort, AtomicPort } from './ports.mjs';
+import {
+ trackerAction,
+ trackerActions,
+ trackerOperations,
+} from '../tracker-actions.js';
+import { manifest } from '../adapter.js';
+import { validateManifest } from '../../../browser/lib/src/plugin-manifest.js';
+
+it('declares and prepares scoped issue/comment actions with validated arguments', () => {
+ const original = manifest('owner/repo');
+ const m = validateManifest({
+ ...original,
+ actions: [...original.actions, ...trackerActions],
+ operations: [
+ ...original.operations,
+ ...trackerOperations('https://api.github.com/repos/owner/repo/issues'),
+ ],
+ });
+ for (const [action, args] of [
+ ['list_issues', { page: 1 }],
+ ['list_comments', { number: 5, page: 2 }],
+ ['create_comment', { number: 5, body: 'Hello' }],
+ ['get_comment', { id: 9 }],
+ ['update_comment', { id: 9, body: 'Edit' }],
+ ['update_issue', { number: 5, title: 'Title', body: '', state: 'closed' }],
+ ['add_doing_label', { number: 5 }],
+ ['remove_doing_label', { number: 5 }],
+ ]) {
+ const intent = trackerAction('owner/repo', action, args);
+ expect(
+ intent.url.startsWith('https://api.github.com/repos/owner/repo/issues'),
+ ).toBe(true);
+ expect(m.actions.find(a => a.name === action).operation).toBe(
+ intent.operation,
+ );
+ expect(
+ m.operations.some(
+ o => o.id === intent.operation && o.method === intent.method,
+ ),
+ ).toBe(true);
+ }
+ expect(() =>
+ trackerAction('owner/repo', 'get_comment', { id: '../escape' }),
+ ).toThrow();
+ expect(() =>
+ trackerAction('owner/repo', 'list_issues', { page: 101 }),
+ ).toThrow();
+ expect(() =>
+ trackerAction('owner/repo', 'create_comment', { number: 1, body: '' }),
+ ).toThrow();
+});
+
+function github() {
+ const issues = new Map([
+ [
+ 1,
+ { number: 1, title: 'First', body: '', state: 'open', labels: ['bug'] },
+ ],
+ ]);
+ const comments = new Map();
+ const receipts = new Map();
+ const writes = [];
+ const port = new GitHubPort(
+ null,
+ { repository: 'owner/repo' },
+ async (action, args, id) => {
+ const isWrite = /^(create|update|add|remove)_/.test(action);
+ if (isWrite && receipts.has(id)) return receipts.get(id);
+ let value;
+ if (isWrite) writes.push(action);
+ switch (action) {
+ case 'get_issue':
+ value = issues.get(args.number);
+ break;
+ case 'list_issues':
+ value = [...issues.values()];
+ break;
+ case 'create_issue':
+ value = {
+ number: issues.size + 1,
+ ...args,
+ state: 'open',
+ labels: [],
+ };
+ issues.set(value.number, value);
+ break;
+ case 'update_issue':
+ value = issues.get(args.number);
+ Object.assign(value, args);
+ break;
+ case 'add_doing_label':
+ value = issues.get(args.number);
+ value.labels.push('atomic:doing');
+ break;
+ case 'remove_doing_label':
+ value = issues.get(args.number);
+ value.labels = value.labels.filter(l => l !== 'atomic:doing');
+ break;
+ case 'create_comment':
+ value = {
+ id: comments.size + 1,
+ body: args.body,
+ issue_url: `https://api.github.com/repos/owner/repo/issues/${args.number}`,
+ };
+ comments.set(value.id, value);
+ break;
+ case 'get_comment':
+ value = comments.get(args.id);
+ break;
+ case 'list_comments':
+ value = [...comments.values()];
+ break;
+ case 'update_comment':
+ value = comments.get(args.id);
+ value.body = args.body;
+ break;
+ default:
+ throw new Error(action);
+ }
+ const receipt = { status: 200, body: JSON.stringify(value) };
+ if (isWrite) receipts.set(id, receipt);
+ return receipt;
+ },
+ );
+ return { port, issues, comments, writes };
+}
+
+it('creates closed/doing issues and reopens while preserving unrelated labels', async () => {
+ const { port, issues } = github();
+ const value = { title: 'Created closed', body: 'Markdown', status: 'Done' };
+ const row = await port.create('issue', value, 'stable-create');
+ expect(issues.get(row.id).state).toBe('closed');
+ await port.create('issue', value, 'stable-create');
+ expect(issues.size).toBe(2);
+ await port.update('issue', 1, { ...value, status: 'Doing' }, 'doing');
+ expect(issues.get(1).labels).toEqual(['bug', 'atomic:doing']);
+ await port.update('issue', 1, value, 'close');
+ await port.update('issue', 1, { ...value, status: 'Todo' }, 'reopen');
+ expect(issues.get(1).state).toBe('open');
+ expect(issues.get(1).labels).toEqual(['bug']);
+});
+
+it('creates/edits comments with replay-safe IDs and rejects another issue’s comment', async () => {
+ const { port, comments } = github();
+ const context = { issueId: 1 };
+ await port.create(
+ 'comment:x',
+ { body: 'Hi' },
+ 'comment-create',
+ undefined,
+ context,
+ );
+ await port.create(
+ 'comment:x',
+ { body: 'Hi' },
+ 'comment-create',
+ undefined,
+ context,
+ );
+ expect(comments.size).toBe(1);
+ await port.update(
+ 'comment:x',
+ 1,
+ { body: 'Edited' },
+ 'comment-edit',
+ undefined,
+ context,
+ );
+ expect(comments.get(1).body).toBe('Edited');
+ await expect(port.get('comment:x', 1, { issueId: 2 })).rejects.toThrow(
+ 'another issue',
+ );
+});
+
+it('fails on provider errors and scans all pages without importing pull requests', async () => {
+ const f = github();
+ const row = f.issues.get(1);
+ let calls = 0;
+ const port = new GitHubPort(null, { repository: 'owner/repo' }, async () => ({
+ status: 200,
+ body: JSON.stringify(
+ ++calls === 1
+ ? Array.from({ length: 100 }, (_, i) => ({
+ ...row,
+ number: i + 1,
+ ...(i === 0 ? { pull_request: {} } : {}),
+ }))
+ : [],
+ ),
+ }));
+ expect(await port.list('issue')).toHaveLength(99);
+ expect(calls).toBe(2);
+ port.call = async () => ({ status: 429, body: '{}' });
+ await expect(port.list('issue')).rejects.toThrow('429');
+});
+
+it('Atomic creates reuse native localId after a lost acknowledgement', async () => {
+ const stored = new Map();
+ let lose = true;
+ const store = {
+ getServerUrl: () => 'https://atomic.example',
+ findByLocalId: async (_drive, parent, key) =>
+ [...stored.values()].find(r => r.parent === parent && r.key === key),
+ newResource: async ({ parent, propVals }) => {
+ const r = {
+ subject: `did:ad:${stored.size + 1}`,
+ parent,
+ key: propVals['https://atomicdata.dev/properties/localId'],
+ async save() {
+ stored.set(r.subject, r);
+ if (lose) {
+ lose = false;
+ throw new Error('Lost acknowledgement');
+ }
+ },
+ };
+ return r;
+ },
+ };
+ const port = new AtomicPort(store, {
+ connection: {
+ table: 'did:ad:table',
+ drive: 'did:ad:drive',
+ tags: { Todo: 'https://atomicdata.dev/task/v1/todo' },
+ },
+ });
+ const value = { title: 'Same', body: '', status: 'Todo' };
+ port.get = async (_entity, id) => ({ id, value });
+ port.findByLocalId = (parent, key) => store.findByLocalId('', parent, key);
+ await expect(port.create('issue', value, 'key')).rejects.toThrow(
+ 'Lost acknowledgement',
+ );
+ expect((await port.create('issue', value, 'key')).id).toBe('did:ad:1');
+ expect(stored.size).toBe(1);
+});
diff --git a/integrations/github-issues/devonian/proxy.mjs b/integrations/github-issues/devonian/proxy.mjs
new file mode 100644
index 0000000000..e997b41311
--- /dev/null
+++ b/integrations/github-issues/devonian/proxy.mjs
@@ -0,0 +1,199 @@
+import { endpoint, request } from '../adapter.js';
+import { trackerAction } from '../tracker-actions.js';
+
+/** Direct browser transport. Codes stay in tab storage, never in Atomic resources. */
+export function proxyTransport({
+ url,
+ repository,
+ getCode,
+ setCode,
+ journal,
+ save,
+ fetcher = fetch,
+ dispatch,
+}) {
+ const origin = new URL(url);
+ if (
+ origin.protocol !== 'https:' &&
+ !(
+ origin.protocol === 'http:' &&
+ ['localhost', '127.0.0.1'].includes(origin.hostname)
+ )
+ )
+ throw new Error('Use an HTTPS proxy or loopback HTTP');
+ const root = endpoint(repository);
+ let pending = Promise.resolve();
+ return (action, args, id) => {
+ const operation = async () => {
+ if (
+ action === 'get_issue' &&
+ (!Number.isSafeInteger(args.number) || args.number <= 0)
+ )
+ throw new Error('Invalid issue number');
+ if (
+ action === 'create_issue' &&
+ (typeof args.title !== 'string' ||
+ !args.title.trim() ||
+ args.title.length > 256 ||
+ (args.body !== undefined && typeof args.body !== 'string'))
+ )
+ throw new Error('Invalid issue title or body');
+ const intent =
+ trackerAction(repository, action, args) ??
+ (action === 'get_issue'
+ ? request('get', 'GET', `${root}/${args.number}`, id)
+ : action === 'create_issue'
+ ? request('create', 'POST', root, id, args)
+ : undefined);
+ if (!intent) throw new Error('Unsupported GitHub action');
+ const writes = intent.method !== 'GET';
+ const signature = JSON.stringify({ action, args });
+ const old = journal[id];
+ if (writes && old) {
+ if (old.signature !== signature)
+ throw new Error('Operation identity reused with different arguments');
+ if (old.receipt) return old.receipt;
+ throw new Error(
+ `Uncertain GitHub write (${action}). Inspect its outcome before retrying; it will not be resent.`,
+ );
+ }
+ if (writes) {
+ journal[id] = { signature };
+ await save();
+ }
+ const target = new URL(intent.url);
+ let receipt;
+ let next = true;
+ if (dispatch) {
+ receipt = await dispatch(`${target.pathname}${target.search}`, {
+ method: intent.method,
+ ...(intent.body ? { body: intent.body } : {}),
+ }).catch(() => {
+ throw new Error(
+ 'Proxy request failed. Check CORS and reconnect; an uncertain write will not be resent.',
+ );
+ });
+ } else {
+ const code = getCode();
+ if (!code)
+ throw new Error(
+ 'Connect to the proxy or supply a fresh connection code',
+ );
+ setCode('');
+ const destination = new URL(
+ `/proxy/github-issues${target.pathname}${target.search}`,
+ origin,
+ );
+ const response = await fetcher(destination.href, {
+ method: intent.method,
+ redirect: 'error',
+ credentials: 'omit',
+ headers: {
+ Authorization: `Bearer ${code}`,
+ Accept: 'application/vnd.github+json',
+ 'Content-Type': 'application/json',
+ },
+ ...(intent.body ? { body: intent.body } : {}),
+ }).catch(() => {
+ throw new Error(
+ 'Proxy request failed. Check CORS and reconnect; an uncertain write will not be resent.',
+ );
+ });
+ next = response.headers.get('X-Connection-Code');
+ if (next) setCode(next);
+ receipt = { status: response.status, body: await response.text() };
+ }
+ if (writes && receipt.status >= 200 && receipt.status < 300) {
+ journal[id].receipt = receipt;
+ await save();
+ }
+ if (!next)
+ throw new Error(
+ 'Proxy did not expose X-Connection-Code. Enable CORS exposure and reconnect.',
+ );
+ return receipt;
+ };
+ const next = pending.then(operation);
+ pending = next.catch(() => {});
+ return next;
+ };
+}
+
+/** An explicitly labelled GitHub fixture; no network or real GitHub mutations. */
+export function fixtureTransport(state, save) {
+ state.issues ??= [
+ {
+ number: 1,
+ title: 'Welcome from GitHub',
+ body: 'Edit either tracker, then sync again.',
+ state: 'open',
+ labels: ['demo'],
+ },
+ ];
+ state.comments ??= [];
+ state.receipts ??= {};
+ return async (action, args, id) => {
+ if (state.receipts[id]) return state.receipts[id];
+ let value;
+ const issue = state.issues.find(r => r.number === args.number);
+ const comment = state.comments.find(r => r.id === args.id);
+ switch (action) {
+ case 'list_issues':
+ value = state.issues.slice((args.page - 1) * 100, args.page * 100);
+ break;
+ case 'get_issue':
+ value = issue;
+ break;
+ case 'create_issue':
+ value = {
+ number: Math.max(0, ...state.issues.map(r => r.number)) + 1,
+ ...args,
+ state: 'open',
+ labels: [],
+ };
+ state.issues.push(value);
+ break;
+ case 'update_issue':
+ value = Object.assign(issue, args);
+ break;
+ case 'add_doing_label':
+ issue.labels = [...new Set([...issue.labels, 'atomic:doing'])];
+ value = issue;
+ break;
+ case 'remove_doing_label':
+ issue.labels = issue.labels.filter(l => l !== 'atomic:doing');
+ value = issue;
+ break;
+ case 'list_comments':
+ value = state.comments
+ .filter(c => c.issue_url.endsWith(`/${args.number}`))
+ .slice((args.page - 1) * 100, args.page * 100);
+ break;
+ case 'get_comment':
+ value = comment;
+ break;
+ case 'create_comment':
+ value = {
+ id: Math.max(0, ...state.comments.map(r => r.id)) + 1,
+ body: args.body,
+ issue_url: `https://api.github.com/repos/demo/issues/issues/${args.number}`,
+ user: { login: 'demo-user' },
+ };
+ state.comments.push(value);
+ break;
+ case 'update_comment':
+ value = Object.assign(comment, { body: args.body });
+ break;
+ default:
+ throw new Error(`Unsupported fixture action: ${action}`);
+ }
+ const receipt = {
+ status: value ? 200 : 404,
+ body: JSON.stringify(value ?? {}),
+ };
+ if (!action.startsWith('get_') && !action.startsWith('list_'))
+ state.receipts[id] = receipt;
+ await save();
+ return receipt;
+ };
+}
diff --git a/integrations/github-issues/devonian/proxy.test.mjs b/integrations/github-issues/devonian/proxy.test.mjs
new file mode 100644
index 0000000000..48bf983161
--- /dev/null
+++ b/integrations/github-issues/devonian/proxy.test.mjs
@@ -0,0 +1,100 @@
+import { expect, it } from 'vitest';
+import { proxyTransport } from './proxy.mjs';
+
+it('serializes rotating codes, preserves query strings and checkpoints successful writes', async () => {
+ let code = 'first';
+ const seen = [];
+ const journal = {};
+ let saves = 0;
+ const call = proxyTransport({
+ url: 'https://proxy.example',
+ repository: 'owner/repo',
+ journal,
+ getCode: () => code,
+ setCode: c => {
+ code = c;
+ },
+ save: async () => {
+ saves++;
+ },
+ fetcher: async (url, opts) => {
+ seen.push({ url, ...opts });
+ return new Response('{"id":1}', {
+ headers: { 'X-Connection-Code': `next-${seen.length}` },
+ });
+ },
+ });
+ await Promise.all([
+ call('list_issues', { page: 2 }, 'read'),
+ call('create_comment', { number: 1, body: 'Hello' }, 'write'),
+ ]);
+ expect(seen[0].url).toContain(
+ '/proxy/github-issues/repos/owner/repo/issues?state=all&per_page=100&page=2',
+ );
+ expect(seen.map(r => r.headers.Authorization)).toEqual([
+ 'Bearer first',
+ 'Bearer next-1',
+ ]);
+ expect(code).toBe('next-2');
+ expect(saves).toBe(2);
+ await call('create_comment', { number: 1, body: 'Hello' }, 'write');
+ expect(seen).toHaveLength(2);
+ await expect(
+ call('create_comment', { number: 1, body: 'Changed' }, 'write'),
+ ).rejects.toThrow('different arguments');
+});
+
+it('never retries an uncertain create after a lost response or restart', async () => {
+ const journal = {};
+ let writes = 0;
+ let code = 'first';
+ const options = {
+ url: 'https://proxy.example',
+ repository: 'owner/repo',
+ journal,
+ getCode: () => code,
+ setCode: c => {
+ code = c;
+ },
+ save: async () => {},
+ fetcher: async () => {
+ writes++;
+ throw new Error('network');
+ },
+ };
+ await expect(
+ proxyTransport(options)('create_issue', { title: 'Title' }, 'create'),
+ ).rejects.toThrow('Proxy request failed');
+ code = 'reconnected';
+ await expect(
+ proxyTransport(options)('create_issue', { title: 'Title' }, 'create'),
+ ).rejects.toThrow('Uncertain GitHub write');
+ expect(writes).toBe(1);
+});
+
+it('retains rotated codes on provider errors and identifies missing exposed headers', async () => {
+ let code = 'first';
+ const options = {
+ url: 'https://proxy.example',
+ repository: 'owner/repo',
+ journal: {},
+ getCode: () => code,
+ setCode: c => {
+ code = c;
+ },
+ save: async () => {},
+ fetcher: async () =>
+ new Response('{}', {
+ status: 403,
+ headers: { 'X-Connection-Code': 'rotated' },
+ }),
+ };
+ expect(
+ (await proxyTransport(options)('get_issue', { number: 1 }, 'read')).status,
+ ).toBe(403);
+ expect(code).toBe('rotated');
+ options.fetcher = async () => new Response('{}');
+ await expect(
+ proxyTransport(options)('get_issue', { number: 1 }, 'read'),
+ ).rejects.toThrow('expose X-Connection-Code');
+});
diff --git a/integrations/github-issues/devonian/vitest.config.mjs b/integrations/github-issues/devonian/vitest.config.mjs
new file mode 100644
index 0000000000..1b8f18ff79
--- /dev/null
+++ b/integrations/github-issues/devonian/vitest.config.mjs
@@ -0,0 +1,14 @@
+import { resolve } from 'node:path';
+
+export default {
+ resolve: {
+ alias: {
+ devonian: process.env.DEVONIAN_PATH
+ ? resolve(process.env.DEVONIAN_PATH, 'build/src/main.js')
+ : resolve('browser/data-browser/src/chunks/DevonianDemo/devonian.js'),
+ '@tomic/lib': resolve('browser/lib/src/index.ts'),
+ vitest: resolve('browser/node_modules/vitest/dist/index.js'),
+ },
+ },
+ test: { include: ['integrations/github-issues/devonian/*.test.*'] },
+};
diff --git a/integrations/github-issues/tracker-actions.ts b/integrations/github-issues/tracker-actions.ts
new file mode 100644
index 0000000000..47e9096c14
--- /dev/null
+++ b/integrations/github-issues/tracker-actions.ts
@@ -0,0 +1,190 @@
+/** Additional repository-scoped actions used by the Devonian tracker bridge. */
+import { endpoint, request } from './adapter.js';
+
+const integer = {
+ type: 'integer' as const,
+ description: 'Positive identifier or page number',
+};
+const string = {
+ type: 'string' as const,
+ description: 'Issue or comment field value',
+};
+const definitions = [
+ ['list_issues', 'List issues', 'list', { page: integer }, ['page']],
+ [
+ 'update_issue',
+ 'Update an issue',
+ 'update',
+ { number: integer, title: string, body: string, state: string },
+ ['number', 'title', 'body', 'state'],
+ ],
+ [
+ 'add_doing_label',
+ 'Mark an issue as doing',
+ 'doing-add',
+ { number: integer },
+ ['number'],
+ ],
+ [
+ 'remove_doing_label',
+ 'Remove the doing label',
+ 'doing-remove',
+ { number: integer },
+ ['number'],
+ ],
+ [
+ 'list_comments',
+ 'List issue comments',
+ 'comments-list',
+ { number: integer, page: integer },
+ ['number', 'page'],
+ ],
+ [
+ 'get_comment',
+ 'Get an issue comment',
+ 'comments-get',
+ { id: integer },
+ ['id'],
+ ],
+ [
+ 'create_comment',
+ 'Create an issue comment',
+ 'comments-create',
+ { number: integer, body: string },
+ ['number', 'body'],
+ ],
+ [
+ 'update_comment',
+ 'Update an issue comment',
+ 'comments-update',
+ { id: integer, body: string },
+ ['id', 'body'],
+ ],
+] as const;
+
+export const trackerActions = definitions.map(
+ ([name, title, operation, properties, required]) => ({
+ name,
+ title,
+ description: `${title} in this connected repository. Writes require approval.`,
+ operation,
+ inputSchema: {
+ type: 'object',
+ properties,
+ required: [...required],
+ additionalProperties: false,
+ },
+ }),
+);
+
+export function trackerOperations(root: string) {
+ return [
+ {
+ id: 'comments-list',
+ method: 'GET',
+ url: `${root}/{number}/comments`,
+ effect: 'read',
+ },
+ {
+ id: 'comments-get',
+ method: 'GET',
+ url: `${root}/comments/{id}`,
+ effect: 'read',
+ },
+ {
+ id: 'comments-create',
+ method: 'POST',
+ url: `${root}/{number}/comments`,
+ effect: 'write',
+ },
+ {
+ id: 'comments-update',
+ method: 'PATCH',
+ url: `${root}/comments/{id}`,
+ effect: 'write',
+ },
+ ];
+}
+
+export function trackerAction(
+ repository: string,
+ action: string,
+ args: Record,
+) {
+ const definition = definitions.find(d => d[0] === action);
+ if (!definition) return undefined;
+ const [, , operation, properties, required] = definition;
+ for (const key of required) {
+ if (!(key in args)) throw new Error(`Missing ${key}`);
+ }
+ for (const [key, value] of Object.entries(args)) {
+ if (!(key in properties)) throw new Error(`Unexpected ${key}`);
+ if (['number', 'id', 'page'].includes(key)) {
+ if (
+ !Number.isSafeInteger(value) ||
+ Number(value) <= 0 ||
+ (key === 'page' && Number(value) > 100)
+ )
+ throw new Error(`Invalid ${key}`);
+ } else if (typeof value !== 'string') throw new Error(`Invalid ${key}`);
+ }
+ const root = endpoint(repository);
+ switch (action) {
+ case 'list_issues':
+ return request(
+ operation,
+ 'GET',
+ `${root}?state=all&per_page=100&page=${args.page}&sort=created&direction=asc`,
+ 'action',
+ );
+ case 'list_comments':
+ return request(
+ operation,
+ 'GET',
+ `${root}/${args.number}/comments?per_page=100&page=${args.page}`,
+ 'action',
+ );
+ case 'get_comment':
+ return request(operation, 'GET', `${root}/comments/${args.id}`, 'action');
+ case 'create_comment':
+ case 'update_comment':
+ if (!(args.body as string).trim())
+ throw new Error('Comment body cannot be empty');
+ return request(
+ operation,
+ action === 'create_comment' ? 'POST' : 'PATCH',
+ action === 'create_comment'
+ ? `${root}/${args.number}/comments`
+ : `${root}/comments/${args.id}`,
+ 'action',
+ { body: args.body },
+ );
+ case 'add_doing_label':
+ return request(
+ operation,
+ 'POST',
+ `${root}/${args.number}/labels`,
+ 'action',
+ {
+ labels: ['atomic:doing'],
+ },
+ );
+ case 'remove_doing_label':
+ return request(
+ operation,
+ 'DELETE',
+ `${root}/${args.number}/labels/atomic%3Adoing`,
+ 'action',
+ );
+ case 'update_issue':
+ if (!['open', 'closed'].includes(String(args.state)))
+ throw new Error('Invalid issue state');
+ if (!(args.title as string).trim() || (args.title as string).length > 256)
+ throw new Error('Invalid issue title');
+ return request(operation, 'PATCH', `${root}/${args.number}`, 'action', {
+ title: args.title,
+ body: args.body,
+ state: args.state,
+ });
+ }
+}
diff --git a/integrations/localthought/README.md b/integrations/localthought/README.md
index d6f4504702..3674472724 100644
--- a/integrations/localthought/README.md
+++ b/integrations/localthought/README.md
@@ -1,40 +1,67 @@
-# LocalThought API plugins
-
-AtomicServer reads `/catalog` from `https://localthought.io` and offers one
-connection card per advertised platform. It loads `/catalog/{platform}.yaml`
-for collection discovery, required scope parameters, pagination and ontology.
-
-`syncables-rs` is pinned in `server/Cargo.toml`. Its `SyncClient` receives the
-catalog's overlaid OpenAPI document and a host-owned transport that forwards
-GET requests through `/proxy/{platform}/{path}`, preserving query parameters
-and pagination headers. Rotating connection codes are held in AtomicServer's
-secret store, scoped to a connection, drive and authenticated agent. The
-browser carries a one-time OAuth return code to signed completion and removes
-it from the address bar. Tenant secrets and provider tokens never enter graph
-resources, plugin source or browser storage.
-
-Each platform gets drive-local classes and properties derived by Syncables.
-The platform ID prefixes every generated schema identity. Scalars retain their
-datatypes; RFC3339 timestamps become milliseconds; arrays and nested objects
-use Atomic JSON properties. API-required fields are recommended in Atomic
-because nullable/partial API representations may omit them. Each resource type
-gets a table and view. The shared sandbox importer proposes changes for review,
-uses provider/resource/namespace/ID for reconciliation, and preserves local edits.
-A failed collection aborts the preview instead of applying a partial snapshot.
-
-This is a manual read/import flow, with limits of 200 requests, 5,000 records,
-10 MB per page/document and 120 seconds per import. No provider writes or
-background syncing are enabled. The existing direct GitHub token workflow
-remains available explicitly in the GitHub connection dialog.
-
-## Server configuration
-
-- `TENANT_SECRET`: the LocalThought tenant secret, stored only on AtomicServer.
-- `ATOMIC_INTEGRATION_FRONTEND_ORIGIN`: exact frontend origin, e.g.
- `https://your-atomic-app.example` (no trailing slash). Returns must use its
- `/app/integrations` path.
-- `ATOMIC_INTEGRATION_PROXY_URL`: optional; defaults to `https://localthought.io`.
- Loopback HTTP is accepted for development.
+# LocalThought browser integrations
+
+The LocalThought flow runs entirely in the browser: catalog discovery, tenant
+challenge signing, OAuth return handling, paginated Syncables reads, ontology
+creation, proposal review and local Store/OPFS writes. No AtomicServer HTTP
+instance is needed. LocalThought remains the remote OAuth and API proxy.
+
+Open Integrations, select a platform and paste your `TENANT_SECRET`. It is used
+in tab memory to sign the handoff and is not persisted. OAuth returns to the
+same frontend `/app/integrations` page. The short-lived return is bound to the
+agent, drive and proxy; its code is removed from the address bar immediately.
+Connection codes are stored in this browser's localStorage, outside the synced
+graph, and may be read by code running on this frontend origin. Clearing site
+data requires reconnecting. Existing server-held connections require reconnecting.
+Web Locks serialize rotating codes across tabs; a request consumes its code
+before dispatch and saves the replacement before processing data. Uncertain
+requests cannot silently replay credentials.
+
+Syncables is vendored temporarily under `syncables/` with upstream provenance in
+`UPSTREAM.md`; the matching upstream branch is `codex/browser-integrations`.
+`wasm/src/integrations.rs` exposes its in-memory engine through wasm-bindgen.
+The shipped pure import mapper reads a local snapshot and produces the existing
+reviewed intents; user-edited plugin source is not executed on this path.
+Local edits and repeated imports retain the existing reconciliation behavior.
+
+## Build and proxy requirements
+
+- Build `atomic-wasm` using `cd browser/data-browser && pnpm build:wasm`.
+- Set `VITE_INTEGRATION_PROXY_URL` at frontend build/dev time to override the
+ default `https://localthought.io`. HTTPS or loopback HTTP origins only.
+- Deploy the companion integration-proxy CORS change. It handles preflights for
+ explicit Authorization headers and exposes `X-Connection-Code`, `Link`,
+ pagination/count headers, `ETag` and `Retry-After`. Cookie credentials are not
+ enabled; login and consent use top-level navigation.
+- Native AtomicServer's `TENANT_SECRET`, `ATOMIC_INTEGRATION_PROXY_URL` and
+ `ATOMIC_INTEGRATION_FRONTEND_ORIGIN` no longer configure this flow. Its
+ `/integration-proxy/*` handlers and Syncables dependency have been removed.
+
+Limits remain 200 requests, 5,000 records, 10 MB per page/document and 120
+seconds of network work. Calendar imports require explicit UTC date bounds.
+Imports are manual; closed tabs do not run schedules. Other legacy integrations,
+server plugin execution, actions and schedules are outside this migration.
+
+## Checks
+
+```sh
+cargo check -p atomic-wasm --target wasm32-unknown-unknown
+browser/node_modules/.bin/vitest run --config integrations/localthought/vitest.config.ts
+node integrations/localthought/wasm-smoke.mjs # after building wasm/pkg
+```
+
+For the browser-only mock journey (no AtomicServer on port 19999):
+
+```sh
+MOCK_PROXY_PORT=19091 MOCK_FRONTEND_ORIGIN=http://localhost:6748 node integrations/localthought/mock-proxy.mjs
+# Separate terminal, browser/data-browser:
+VITE_INTEGRATION_PROXY_URL=http://127.0.0.1:19091 VITE_ATOMIC_SERVER_URL=http://127.0.0.1:19999 pnpm exec vite --host 127.0.0.1 --port 6748
+# Repository root:
+node integrations/localthought/browser-smoke.mjs
+```
+
+The mock is test-only. It uses synthetic credentials and data; never deploy it.
+
+## Historical server-flow verification
Live verification on 2026-09-09 succeeded against proxy Heroku release v38
(`5960ae43`): OAuth returned to AtomicServer, Syncables fetched 29 issue/PR
@@ -56,43 +83,52 @@ An unbounded fetch successfully traversed multiple pages but exceeded the
bounds and recurrence expansion are passed to Syncables as collection query
settings. The importer remains a manual snapshot, not a background sync.
-## Local mock and tests
-
-The mock is test-only, with a synthetic tenant, consent screen, expiring-by-use
-challenges, rotating single-use codes, and an OpenAPI document serving five
-Pets over two pages. Never deploy it as a real credential service.
-
-```sh
-node integrations/localthought/mock-proxy.mjs
-```
-
-Start AtomicServer with:
-
-```sh
-TENANT_SECRET=bW9jay10ZW5hbnQ.mock-signature \
-ATOMIC_INTEGRATION_PROXY_URL=http://127.0.0.1:19090 \
-ATOMIC_INTEGRATION_FRONTEND_ORIGIN=http://localhost:6747 \
-ATOMICSERVER_SKIP_JS_BUILD=true \
-cargo run -p atomic-server --features light,wasm-plugins -- --port 9883
-```
-
-Run one Vite server with `VITE_ATOMIC_SERVER_URL=http://localhost:9883`, then:
-
-```sh
-cd browser/e2e
-ATOMIC_MOCK_INTEGRATION_PROXY=1 pnpm exec playwright test tests/plugins.spec.ts \
- --project chromium --grep 'Pets imports'
-```
-
-Additional checks from the repository root:
+## Calendar view
+
+Google event imports now install a Calendar view alongside the source table.
+The projected date uses the day in Google's supplied start offset (or the
+unchanged all-day date), so mixed all-day/timed events share one DATE column.
+The original Start and End objects retain timezones and exclusive end values.
+The month view shows each event on its start day; it does not draw duration or
+multi-day spans. Additional notes identify recurring events, attendees,
+reminders and conferencing when those fields are returned by the catalog.
+Recurring instances are expanded by the existing bounded provider fetch.
+
+Use **Fetch and preview** again to refresh, then review and apply changes.
+Existing source identities and import baselines prevent duplicates and preserve
+Atomic-only fields and local edits. Cancellation records are retained with a
+note; absence from a bounded fetch never deletes an Atomic resource. Google
+may omit cancelled events from list results, so this is not a deletion feed.
+Removed optional provider fields are not cleared by the shared snapshot importer.
+This first version deliberately remains manual and one-way. No OAuth write
+scope is requested and edits in Atomic do not update Google.
+
+`calendar.test.ts` exercises mixed dates, offset boundaries, exclusive ends,
+recurrence/attendee notes, cancellations, malformed starts, cross-calendar
+identity and repeated imports with private local fields.
+
+## Browser-only Calendar regression
+
+`browser/e2e/tests/google-calendar-import.spec.mts` starts the shared mock
+integration-proxy from #1399 with a synthetic Google Calendar. The test enters
+the public fixture tenant secret, completes consent, and exercises real browser
+credential rotation, WASM pagination, local schema installation, proposal review,
+OPFS application, and Calendar rendering. It refreshes changed provider data
+and checks that native identities and Atomic-only notes survive reload.
+AtomicServer HTTP and all WebSockets are blocked throughout; only GET requests
+are permitted for provider data. The configured LocalThought origin is forwarded
+to the isolated HTTP fixture, so no live provider credentials or data are used.
+
+Run with a dev frontend built from this branch and its matching WASM bundle:
```sh
-node --test integrations/localthought/mock-proxy.test.mjs
-browser/node_modules/.bin/vitest run --config integrations/localthought/vitest.config.ts
-browser/node_modules/.bin/tsc -p integrations/localthought/tsconfig.json
-ATOMICSERVER_SKIP_JS_BUILD=true cargo test -p atomic-server --lib \
- --features light,wasm-plugins integration_proxy
+FRONTEND_URL=http://127.0.0.1:6747 SERVER_URL=http://127.0.0.1:19999 \
+ browser/e2e/node_modules/.bin/playwright test \
+ --config browser/e2e/playwright.config.ts \
+ browser/e2e/tests/google-calendar-import.spec.mts --project chromium
```
-Dagger's E2E server starts the mock alongside AtomicServer, and the focused
-GitHub Actions workflow runs the typed Pets journey on the API-plugin branches.
+If the frontend uses `VITE_INTEGRATION_PROXY_URL`, pass the same value to the
+test process. The test forwards that origin to its own fixture. Live Google
+OAuth on the browser path still depends on the proxy CORS deployment described
+above; this fixture test does not claim live-provider verification.
diff --git a/integrations/localthought/browser-smoke.mjs b/integrations/localthought/browser-smoke.mjs
new file mode 100644
index 0000000000..87ce370fcf
--- /dev/null
+++ b/integrations/localthought/browser-smoke.mjs
@@ -0,0 +1,63 @@
+/** Run against Vite + the mock proxy, with VITE_ATOMIC_SERVER_URL on a closed port. */
+import { chromium } from '../../browser/e2e/node_modules/@playwright/test/index.mjs';
+const browser = await chromium.launch({ headless: true });
+const page = await browser.newPage();
+page.setDefaultTimeout(45000);
+await page.route('http://127.0.0.1:19999/**', route => route.abort());
+const errors = [];
+page.on('pageerror', e => errors.push(e.message));
+try {
+ console.log('Opening offline dev drive');
+ await page.goto('http://localhost:6748/app/dev-drive');
+ await page.waitForURL(/app\/show\?subject=/, { timeout: 120000 });
+ console.log('Opening integrations');
+ await page.getByRole('link', { name: 'Integrations', exact: true }).click();
+ console.log('Opening Pets');
+ const pets = page.locator('[data-integration=pets]');
+ await pets.getByRole('button', { name: 'Set up connection' }).click();
+ await page
+ .getByLabel('LocalThought tenant secret')
+ .fill('bW9jay10ZW5hbnQ.mock-signature');
+ await page
+ .getByRole('button', { name: 'Install and connect', exact: true })
+ .click();
+ await page.getByRole('button', { name: 'Connect test account' }).click();
+ console.log('Fetching preview');
+ await page.getByRole('button', { name: 'Fetch and preview' }).click();
+ console.log('Applying preview');
+ await page
+ .getByRole('button', { name: 'Apply 5 changes', exact: true })
+ .click();
+ await page
+ .getByRole('link', { name: 'Open imported records', exact: true })
+ .click();
+ for (const name of ['Rex', 'Whiskers', 'Tweety', 'Nibbles', 'Bubbles'])
+ await page
+ .getByRole('main')
+ .getByText(name, { exact: true })
+ .first()
+ .waitFor();
+ await page.reload();
+ for (const name of ['Rex', 'Whiskers', 'Tweety', 'Nibbles', 'Bubbles'])
+ await page
+ .getByRole('main')
+ .getByText(name, { exact: true })
+ .first()
+ .waitFor();
+ console.log(
+ 'Browser-only OAuth, WASM import, review, OPFS apply and reload passed',
+ );
+} catch (error) {
+ console.error(
+ error.message,
+ '\nURL:',
+ page.url(),
+ '\nUI:',
+ (await page.locator('body').innerText()).slice(-7000),
+ '\nErrors:',
+ errors,
+ );
+ process.exitCode = 1;
+} finally {
+ await browser.close();
+}
diff --git a/integrations/localthought/browser.test.ts b/integrations/localthought/browser.test.ts
new file mode 100644
index 0000000000..87a8081208
--- /dev/null
+++ b/integrations/localthought/browser.test.ts
@@ -0,0 +1,160 @@
+import { afterEach, expect, it, vi } from 'vitest';
+import { BrowserIntegrations, proxyOrigin, sign, type Engine } from './browser';
+const secret = 'bW9jay10ZW5hbnQ.mock-signature';
+const origin = 'https://proxy.example';
+function setup() {
+ const values = new Map();
+ const storage = {
+ getItem: (k: string) => values.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ values.set(k, v);
+ },
+ } as Storage;
+ vi.stubGlobal('location', { origin: 'https://atomic.example' });
+ vi.stubGlobal('navigator', {
+ locks: { request: (_: string, f: () => unknown) => f() },
+ });
+ const http = vi.fn(async (url: string, init?: RequestInit) => {
+ expect(init?.credentials).toBe('omit');
+ expect(init?.redirect).toBe('error');
+ if (url.endsWith('/catalog')) return new Response('["pets"]');
+ if (url.endsWith('/session'))
+ return Response.json({ ts: 1, nonce: 'nonce', challenge: 'challenge' });
+ return new Response('{}');
+ });
+ const engine: Engine = {
+ describeIntegration: async () =>
+ JSON.stringify({ upstream: 'https://pets.example' }),
+ fetchIntegration: async (_text, _platform, _constants, _range, fetch) => {
+ const first = JSON.parse(await fetch('https://pets.example/pets'));
+ expect(first.headers['x-connection-code']).toBeUndefined();
+ await fetch('https://pets.example/pets?page=2');
+ return '{"records":[]}';
+ },
+ };
+ const client = new BrowserIntegrations(
+ storage,
+ async () => engine,
+ origin,
+ http as typeof fetch,
+ );
+ const start = () =>
+ client.start(
+ 'drive',
+ 'actor',
+ 'pets',
+ 'https://atomic.example/app/integrations',
+ secret,
+ );
+ return { values, storage, http, engine, client, start };
+}
+afterEach(() => vi.unstubAllGlobals());
+it('matches the tenant HMAC protocol and rejects non-origin proxy URLs', async () => {
+ expect(await sign('key', 'The quick brown fox jumps over the lazy dog')).toBe(
+ '97yD9DBThCSxMpjmqm-xQ-9NWaFJRhdZl0edvC0aPNg',
+ );
+ expect(() => proxyOrigin('https://proxy.example/path')).toThrow();
+ expect(() => proxyOrigin('http://proxy.example')).toThrow();
+});
+it('binds returns to actor, drive and expiry without saving tenant secrets', async () => {
+ const { client, start, values } = setup();
+ const { state, url } = await start();
+ expect(url).not.toContain(secret);
+ expect([...values.values()].join()).not.toContain(secret);
+ expect(() => client.finish('other', 'actor', state, 'code')).toThrow();
+ expect(() => client.finish('drive', 'other', state, 'code')).toThrow();
+ expect(client.finish('drive', 'actor', state, 'code').platform).toBe('pets');
+ expect(() => client.finish('drive', 'actor', state, 'code')).toThrow();
+});
+it('consumes before dispatch and preserves rotation and pagination', async () => {
+ const { client, start, http, values } = setup();
+ const { state } = await start();
+ client.finish('drive', 'actor', state, 'first');
+ const codes: string[] = [];
+ http.mockImplementation(async (url, init) => {
+ if (url.includes('/catalog/')) return new Response('{}');
+ expect(JSON.parse([...values.values()][0]).code).toBeUndefined();
+ codes.push((init?.headers as Record).Authorization);
+ return new Response('[]', {
+ headers: {
+ 'x-connection-code': 'second',
+ link: '; rel=next',
+ },
+ });
+ });
+ await client.fetchRecords('drive', 'actor', state, {});
+ expect(codes).toEqual(['Bearer first', 'Bearer second']);
+ expect(http.mock.calls.at(-1)?.[0]).toBe(`${origin}/proxy/pets/pets?page=2`);
+});
+it('never retries an uncertain consumed credential', async () => {
+ const { client, start, http } = setup();
+ const { state } = await start();
+ client.finish('drive', 'actor', state, 'first');
+ http.mockImplementation(async url => {
+ if (url.includes('/catalog/')) return new Response('{}');
+ throw new Error('connection lost');
+ });
+ await expect(
+ client.fetchRecords('drive', 'actor', state, {}),
+ ).rejects.toThrow('lost');
+ const calls = http.mock.calls.length;
+ await expect(
+ client.fetchRecords('drive', 'actor', state, {}),
+ ).rejects.toThrow('Reconnect');
+ expect(http.mock.calls).toHaveLength(calls);
+});
+it('rejects pagination to a different provider before spending a credential', async () => {
+ const { client, start, engine, values } = setup();
+ const { state } = await start();
+ client.finish('drive', 'actor', state, 'first');
+ engine.fetchIntegration = async (_t, _p, _c, _r, fetch) =>
+ fetch('https://evil.example/pets');
+ await expect(
+ client.fetchRecords('drive', 'actor', state, {}),
+ ).rejects.toThrow('origin');
+ expect(JSON.parse([...values.values()][0]).code).toBe('first');
+});
+
+it('calls the browser fetch function without binding it to the client', async () => {
+ const { storage, engine } = setup();
+ vi.stubGlobal('fetch', function (this: unknown) {
+ expect(this).not.toBeInstanceOf(BrowserIntegrations);
+ return Promise.resolve(new Response('["pets"]'));
+ });
+ const client = new BrowserIntegrations(storage, async () => engine, origin);
+ expect(await client.catalog()).toEqual(['pets']);
+});
+
+it('supports the demo callback and write credentials without using the import engine', async () => {
+ const { client, http, values } = setup();
+ const { state } = await client.start(
+ 'drive',
+ 'actor',
+ 'pets',
+ 'https://atomic.example/app/devonian-demo',
+ secret,
+ );
+ client.finish('drive', 'actor', state, 'first');
+ http.mockImplementation(async (_url, init) => {
+ expect(JSON.parse([...values.values()][0]).code).toBeUndefined();
+ expect(init?.method).toBe('POST');
+ expect(init?.body).toBe('{"title":"new"}');
+ return new Response('{"id":1}', {
+ status: 201,
+ headers: { 'X-Connection-Code': 'next' },
+ });
+ });
+ await expect(
+ client.request('drive', 'actor', state, 'github-issues', '/issues'),
+ ).rejects.toThrow('another platform');
+ await expect(
+ client.request('drive', 'actor', state, 'pets', '//evil.example'),
+ ).rejects.toThrow('Invalid proxy path');
+ expect(
+ await client.request('drive', 'actor', state, 'pets', '/issues', {
+ method: 'POST',
+ body: '{"title":"new"}',
+ }),
+ ).toEqual({ status: 201, body: '{"id":1}' });
+ expect(JSON.parse([...values.values()][0]).code).toBe('next');
+});
diff --git a/integrations/localthought/browser.ts b/integrations/localthought/browser.ts
new file mode 100644
index 0000000000..94350c048f
--- /dev/null
+++ b/integrations/localthought/browser.ts
@@ -0,0 +1,328 @@
+/** Browser-owned credentials. Never store these in Atomic graph resources. */
+export const DEFAULT_PROXY = 'https://localthought.io';
+const key = 'localthought-browser-v1:';
+export interface Connection {
+ drive: string;
+ actor: string;
+ platform: string;
+ origin: string;
+ expires: number;
+ code?: string;
+ ready: boolean;
+}
+export interface Engine {
+ describeIntegration(text: string): Promise;
+ fetchIntegration(
+ text: string,
+ platform: string,
+ constants: string,
+ range: string | undefined,
+ fetch: (url: string) => Promise,
+ ): Promise;
+}
+export function proxyOrigin(value = DEFAULT_PROXY): string {
+ const u = new URL(value);
+ if (
+ u.origin !== value ||
+ (u.protocol !== 'https:' &&
+ !(
+ u.protocol === 'http:' &&
+ ['localhost', '127.0.0.1'].includes(u.hostname)
+ ))
+ )
+ throw new Error('Proxy must be an HTTPS origin or localhost');
+ return value;
+}
+const base64url = (bytes: Uint8Array) =>
+ btoa(String.fromCharCode(...bytes))
+ .replaceAll('+', '-')
+ .replaceAll('/', '_')
+ .replaceAll('=', '');
+export async function sign(secret: string, text: string) {
+ const encoder = new TextEncoder();
+ const k = await crypto.subtle.importKey(
+ 'raw',
+ encoder.encode(secret),
+ { name: 'HMAC', hash: 'SHA-256' },
+ false,
+ ['sign'],
+ );
+ return base64url(
+ new Uint8Array(await crypto.subtle.sign('HMAC', k, encoder.encode(text))),
+ );
+}
+async function limitedText(response: Response): Promise {
+ if (!response.body) throw new Error('Empty proxy response');
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let size = 0,
+ text = '';
+ try {
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ size += value.byteLength;
+ if (size > 10 * 1024 * 1024)
+ throw new Error('Proxy response exceeds 10 MB');
+ text += decoder.decode(value, { stream: true });
+ }
+ return text + decoder.decode();
+ } finally {
+ await reader.cancel();
+ }
+}
+export class BrowserIntegrations {
+ constructor(
+ private storage: Storage,
+ private engine: () => Promise,
+ readonly origin = DEFAULT_PROXY,
+ private http: typeof fetch = (...args) => fetch(...args),
+ ) {
+ proxyOrigin(origin);
+ }
+ private async get(path: string, signal?: AbortSignal) {
+ const response = await this.http(`${this.origin}${path}`, {
+ credentials: 'omit',
+ redirect: 'error',
+ signal: signal ?? AbortSignal.timeout(30000),
+ });
+ if (!response.ok)
+ throw new Error(`LocalThought returned HTTP ${response.status}`);
+ return limitedText(response);
+ }
+ async catalog(signal?: AbortSignal): Promise {
+ const names = JSON.parse(await this.get('/catalog', signal));
+ if (
+ !Array.isArray(names) ||
+ names.length > 200 ||
+ names.some(s => typeof s !== 'string' || !/^[a-z0-9-]{1,80}$/.test(s))
+ )
+ throw new Error('Invalid platform catalog');
+ return names;
+ }
+ private async document(platform: string) {
+ if (!/^[a-z0-9-]{1,80}$/.test(platform))
+ throw new Error('Invalid platform');
+ return this.get(`/catalog/${platform}.yaml`);
+ }
+ async describe(platform: string) {
+ return JSON.parse(
+ await (
+ await this.engine()
+ ).describeIntegration(await this.document(platform)),
+ ) as {
+ parameters: string[];
+ collections: string[];
+ upstream: string;
+ };
+ }
+ async start(
+ drive: string,
+ actor: string,
+ platform: string,
+ returnUrl: string,
+ secret: string,
+ ) {
+ if (!(await this.catalog()).includes(platform))
+ throw new Error('Unknown platform');
+ const encoded = secret.split('.')[0];
+ let tenant: string;
+ try {
+ tenant = new TextDecoder('utf-8', { fatal: true }).decode(
+ Uint8Array.from(
+ atob(encoded.replaceAll('-', '+').replaceAll('_', '/')),
+ c => c.charCodeAt(0),
+ ),
+ );
+ } catch {
+ throw new Error('Invalid tenant secret');
+ }
+ if (!tenant || !secret.includes('.'))
+ throw new Error('Invalid tenant secret');
+ const callback = new URL(returnUrl);
+ if (
+ callback.origin !== location.origin ||
+ !['/app/integrations', '/app/devonian-demo'].includes(
+ callback.pathname,
+ ) ||
+ callback.search ||
+ callback.hash
+ )
+ throw new Error('Invalid integration return URL');
+ const state = base64url(crypto.getRandomValues(new Uint8Array(32)));
+ callback.searchParams.set('integration_state', state);
+ callback.searchParams.set('platform', platform);
+ const challenge = JSON.parse(await this.get('/session'));
+ const url = new URL(`${this.origin}/connect`);
+ for (const [k, v] of Object.entries({
+ redirect_uri: callback.href,
+ platform,
+ ts: String(challenge.ts),
+ nonce: challenge.nonce,
+ challenge: challenge.challenge,
+ tenant_id: tenant,
+ user_id: actor,
+ user_id_sig: await sign(secret, actor),
+ response: await sign(secret, challenge.challenge),
+ }))
+ url.searchParams.set(k, v as string);
+ // Only the short-lived handoff survives navigation, never the tenant secret.
+ this.storage.setItem(
+ key + state,
+ JSON.stringify({
+ drive,
+ actor,
+ platform,
+ origin: this.origin,
+ expires: Date.now() + 600000,
+ ready: false,
+ } satisfies Connection),
+ );
+ return { state, url: url.href };
+ }
+ private connection(id: string, drive: string, actor: string) {
+ const raw = this.storage.getItem(key + id);
+ if (!raw) throw new Error('Reconnect your account');
+ const c: Connection = JSON.parse(raw);
+ if (c.drive !== drive || c.actor !== actor || c.origin !== this.origin)
+ throw new Error('Connection belongs to another drive, agent or proxy');
+ return c;
+ }
+ finish(drive: string, actor: string, state: string, code: string) {
+ const c = this.connection(state, drive, actor);
+ if (c.ready || c.expires < Date.now() || !code || code.length > 4096)
+ throw new Error('Invalid, expired or completed connection');
+ this.storage.setItem(
+ key + state,
+ JSON.stringify({ ...c, ready: true, code }),
+ );
+ return { connection: state, platform: c.platform };
+ }
+ /** Shared rotating-code transport for browser-owned writes as well as reads. */
+ async request(
+ drive: string,
+ actor: string,
+ id: string,
+ platform: string,
+ path: string,
+ init: { method?: string; body?: string } = {},
+ ): Promise<{ status: number; body: string }> {
+ if (!navigator.locks)
+ throw new Error('This browser needs Web Locks for integrations');
+ if (!path.startsWith('/') || path.startsWith('//') || /[\\\\#]/.test(path))
+ throw new Error('Invalid proxy path');
+ const destination = new URL(`/proxy/${platform}${path}`, this.origin);
+ if (!destination.pathname.startsWith(`/proxy/${platform}/`))
+ throw new Error('Invalid proxy path');
+ return navigator.locks.request(key + id, async () => {
+ const c = this.connection(id, drive, actor);
+ if (c.platform !== platform)
+ throw new Error('Connection belongs to another platform');
+ const response = await this.send(
+ id,
+ drive,
+ actor,
+ path,
+ init,
+ AbortSignal.timeout(30000),
+ );
+ return { status: response.status, body: await limitedText(response) };
+ });
+ }
+ private async send(
+ id: string,
+ drive: string,
+ actor: string,
+ path: string,
+ init: { method?: string; body?: string },
+ signal: AbortSignal,
+ ) {
+ const current = this.connection(id, drive, actor);
+ if (!current.ready || !current.code)
+ throw new Error('Reconnect before retrying an uncertain request');
+ const code = current.code;
+ delete current.code;
+ this.storage.setItem(key + id, JSON.stringify(current));
+ const response = await this.http(
+ `${this.origin}/proxy/${current.platform}${path}`,
+ {
+ ...init,
+ headers: {
+ Authorization: `Bearer ${code}`,
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'omit',
+ redirect: 'error',
+ signal,
+ },
+ );
+ const next = response.headers.get('x-connection-code');
+ if (!next)
+ throw new Error(
+ 'Proxy did not expose a rotated code; reconnect and check CORS',
+ );
+ this.storage.setItem(key + id, JSON.stringify({ ...current, code: next }));
+ return response;
+ }
+ async fetchRecords(
+ drive: string,
+ actor: string,
+ id: string,
+ constants: Record,
+ range?: { start: string; end: string },
+ ) {
+ // Web Locks serialize rotating credentials across tabs as well as UI actions.
+ if (!navigator.locks)
+ throw new Error('This browser needs Web Locks for integrations');
+ return navigator.locks.request(key + id, async () => {
+ const c = this.connection(id, drive, actor);
+ if (!c.ready || !c.code) throw new Error('Reconnect your account');
+ const text = await this.document(c.platform);
+ const engine = await this.engine();
+ const { upstream } = JSON.parse(await engine.describeIntegration(text));
+ const base = new URL(upstream);
+ let requests = 0;
+ const signal = AbortSignal.timeout(120000);
+ const transport = async (raw: string) => {
+ signal.throwIfAborted();
+ const target = new URL(raw);
+ if (
+ target.origin !== base.origin ||
+ target.username ||
+ target.password ||
+ target.hash
+ )
+ throw new Error('Pagination left the catalog API origin');
+ if (++requests > 200)
+ throw new Error('Import exceeds 200 requests; narrow its scope');
+ const response = await this.send(
+ id,
+ drive,
+ actor,
+ `${target.pathname}${target.search}`,
+ {},
+ signal,
+ );
+ const headers = Object.fromEntries(
+ [...response.headers].filter(
+ ([name]) => name !== 'x-connection-code',
+ ),
+ );
+ return JSON.stringify({
+ status: response.status,
+ headers,
+ body: await limitedText(response),
+ });
+ };
+ const result = await engine.fetchIntegration(
+ text,
+ c.platform,
+ JSON.stringify(constants),
+ range ? JSON.stringify(range) : undefined,
+ transport,
+ );
+ signal.throwIfAborted();
+ return JSON.parse(result);
+ });
+ }
+}
diff --git a/integrations/localthought/calendar.test.ts b/integrations/localthought/calendar.test.ts
new file mode 100644
index 0000000000..1e14884ff4
--- /dev/null
+++ b/integrations/localthought/calendar.test.ts
@@ -0,0 +1,151 @@
+import { expect, it } from 'vitest';
+import { calendarProjection, calendarFields as fields } from './calendar';
+import { Datatype } from '../../browser/lib/src/index';
+import { run } from './plugin';
+import type { FetchedPlatform } from './schema';
+const fixture = (): FetchedPlatform => ({
+ platform: 'google-calendar',
+ ontology: {
+ description: '',
+ terms: [
+ {
+ path: 'event',
+ kind: 'class',
+ shortname: 'event',
+ description: '',
+ datatype: Datatype.STRING,
+ requires: [],
+ recommends: [],
+ },
+ ],
+ },
+ records: [
+ {
+ resource: 'event',
+ namespace: 'calendar-a',
+ id: '1',
+ name: 'Meeting',
+ values: {
+ start: {
+ dateTime: '2026-09-10T00:30:00+02:00',
+ timeZone: 'Europe/Amsterdam',
+ },
+ end: { dateTime: '2026-09-10T01:30:00+02:00' },
+ attendees: [{ email: 'test@example.com' }],
+ 'recurring-event-id': 'series',
+ reminders: { useDefault: true },
+ },
+ },
+ ],
+});
+it('projects timed events without shifting their day and retains provider structures', () => {
+ const input = fixture();
+ const output = calendarProjection(input);
+ expect(output.records[0].values).toMatchObject({
+ ...input.records[0].values,
+ [fields.day]: '2026-09-10',
+ [fields.allDay]: false,
+ });
+ expect(output.records[0].values[fields.notes]).toContain('Recurring');
+ expect(output.records[0].values[fields.notes]).toContain('Attendees');
+ expect(input.records[0].values[fields.day]).toBeUndefined();
+ expect(
+ output.ontology.terms.find(t => t.shortname === fields.day)?.datatype,
+ ).toBe(Datatype.DATE);
+});
+it('supports all-day dates and preserves exclusive multi-day ends', () => {
+ const input = fixture();
+ input.records[0].values = {
+ start: { date: '2026-09-10' },
+ end: { date: '2026-09-13' },
+ };
+ expect(calendarProjection(input).records[0].values).toMatchObject({
+ [fields.day]: '2026-09-10',
+ [fields.allDay]: true,
+ end: { date: '2026-09-13' },
+ });
+});
+it('retains cancelled events even when Google omits start', () => {
+ const input = fixture();
+ input.records[0].values = { status: 'cancelled' };
+ expect(calendarProjection(input).records[0].values[fields.notes]).toContain(
+ 'Cancelled',
+ );
+});
+it('fails the projection on malformed active events instead of silently losing rows', () => {
+ for (const start of [
+ { date: '2026-02-30' },
+ { date: '2026-09-10garbage' },
+ {},
+ { dateTime: '2026-09-10T12:00:00' },
+ ]) {
+ const input = fixture();
+ input.records[0].values = { start };
+ expect(() => calendarProjection(input)).toThrow();
+ }
+});
+it('leaves other platforms untouched', () => {
+ const input = fixture();
+ input.platform = 'pets';
+ expect(calendarProjection(input)).toBe(input);
+});
+it("repeated imports reconcile IDs, preserve local fields and don't delete out-of-window events", () => {
+ const data = calendarProjection(fixture());
+ const properties = Object.fromEntries(
+ Object.keys(data.records[0].values).map(k => [
+ k,
+ `https://example.com/${k}`,
+ ]),
+ );
+ const config = {
+ platform: data.platform,
+ destinations: {
+ event: { table: 'did:ad:table', rowClass: 'did:ad:event' },
+ },
+ properties,
+ records: data.records,
+ };
+ const first = run({ config, query: () => [], read: () => ({}) }).intents[0];
+ if (first.op !== 'create') throw new Error('Expected create');
+ const saved = {
+ ...first.set,
+ 'https://atomicdata.dev/properties/parent': 'did:ad:table',
+ 'https://atomicdata.dev/properties/isA': ['did:ad:event'],
+ 'https://example.com/private': 'my notes',
+ };
+ const host = {
+ config,
+ query: (p: string, v: string) => (saved[p] === v ? ['did:ad:row'] : []),
+ read: () => saved,
+ };
+ expect(run(host).intents).toHaveLength(0);
+ expect(
+ run({ ...host, config: { ...config, records: [] } }).intents,
+ ).toHaveLength(0);
+ const changed = calendarProjection(fixture());
+ changed.records[0].name = 'Updated meeting';
+ const result = run({
+ ...host,
+ config: { ...config, records: changed.records },
+ });
+ expect(result.intents[0]).toMatchObject({
+ op: 'set',
+ subject: 'did:ad:row',
+ set: { 'https://atomicdata.dev/properties/name': 'Updated meeting' },
+ });
+ expect(JSON.stringify(result.intents)).not.toContain('my notes');
+ const secondCalendar = { ...data.records[0], namespace: 'calendar-b' };
+ expect(
+ run({ ...host, config: { ...config, records: [secondCalendar] } })
+ .intents[0].op,
+ ).toBe('create');
+});
+it('recognizes the lowercase field names produced by the WASM ontology', () => {
+ const input = fixture();
+ delete input.records[0].values['recurring-event-id'];
+ input.records[0].values.recurringeventid = 'series';
+ input.records[0].values.conferencedata = { conferenceId: 'meeting' };
+ const notes = calendarProjection(input).records[0].values[fields.notes];
+ expect(notes).toContain('Recurring event');
+ expect(notes).toContain('Conferencing');
+});
diff --git a/integrations/localthought/calendar.ts b/integrations/localthought/calendar.ts
new file mode 100644
index 0000000000..f9706e8792
--- /dev/null
+++ b/integrations/localthought/calendar.ts
@@ -0,0 +1,146 @@
+// @wc-ignore-file
+import { Datatype } from '../../browser/lib/src/index.js';
+import type { JSONValue } from '../../browser/lib/src/value.js';
+import type { FetchedPlatform, Term } from './schema.js';
+
+export const calendarFields = {
+ day: 'atomic-calendar-day',
+ allDay: 'atomic-calendar-all-day',
+ notes: 'atomic-calendar-notes',
+};
+
+/** An additional projection, never a replacement for the provider's fields.
+ * One DATE column supports both all-day dates and timed events in a single view.
+ * Timed events use the date in Google's supplied offset; raw start/end retain
+ * the instant, zone and exclusive-end semantics for future richer rendering.
+ */
+export function calendarProjection(fetched: FetchedPlatform): FetchedPlatform {
+ if (fetched.platform !== 'google-calendar') return fetched;
+ const event = fetched.ontology.terms.find(
+ t => t.kind === 'class' && t.shortname === 'event',
+ );
+ if (!event) return fetched;
+ const definitions: [string, Datatype, string][] = [
+ [
+ calendarFields.day,
+ Datatype.DATE,
+ "Start date in the event's supplied offset; all-day dates stay unchanged.",
+ ],
+ [
+ calendarFields.allDay,
+ Datatype.BOOLEAN,
+ 'Whether Google represents this as an all-day event.',
+ ],
+ [
+ calendarFields.notes,
+ Datatype.STRING,
+ 'Display limitations and Google-only features. Edits here remain local.',
+ ],
+ ];
+ const terms: Term[] = definitions.map(
+ ([shortname, datatype, description]) => ({
+ path: `urn:atomic:google-calendar:${shortname}`,
+ kind: 'property',
+ shortname,
+ datatype,
+ description,
+ requires: [],
+ recommends: [],
+ }),
+ );
+ if (
+ fetched.ontology.terms.some(t =>
+ terms.some(extra => extra.shortname === t.shortname),
+ )
+ )
+ throw new Error(
+ 'Calendar projection property collides with provider ontology',
+ );
+ return {
+ ...fetched,
+ ontology: {
+ ...fetched.ontology,
+ terms: [
+ ...fetched.ontology.terms.map(t =>
+ t === event
+ ? {
+ ...t,
+ recommends: [
+ ...t.recommends,
+ ...terms.map(extra => extra.path),
+ ],
+ }
+ : t,
+ ),
+ ...terms,
+ ],
+ },
+ records: fetched.records.map(row => {
+ if (row.resource !== 'event') return row;
+ const start = object(row.values.start);
+ const end = object(row.values.end);
+ const date = start.date ?? start.dateTime;
+ const cancelled = row.values.status === 'cancelled';
+ if (
+ !cancelled &&
+ (typeof date !== 'string' || !validDay(date.slice(0, 10)))
+ )
+ throw new Error(`Calendar event ${row.id} has no valid start date`);
+ if (
+ start.date !== undefined &&
+ (typeof start.date !== 'string' || !validDay(start.date))
+ )
+ throw new Error(`Calendar event ${row.id} has an invalid all-day date`);
+ if (
+ start.dateTime !== undefined &&
+ (typeof start.dateTime !== 'string' ||
+ !/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(start.dateTime) ||
+ !Number.isFinite(Date.parse(start.dateTime)))
+ )
+ throw new Error(
+ `Calendar event ${row.id} has no offset-qualified start time`,
+ );
+ const notes = ['One-way import: edits stay in Atomic'];
+ if (cancelled) notes.push('Cancelled in Google; retained in Atomic');
+ if (
+ row.values['recurring-event-id'] ||
+ row.values.recurringEventId ||
+ row.values.recurringeventid ||
+ row.values.recurrence
+ )
+ notes.push('Recurring event: manage the series in Google');
+ if (Array.isArray(row.values.attendees) && row.values.attendees.length)
+ notes.push('Attendees and RSVP: manage in Google');
+ if (row.values.reminders) notes.push('Reminders: manage in Google');
+ if (
+ row.values['conference-data'] ||
+ row.values.conferenceData ||
+ row.values.conferencedata
+ )
+ notes.push('Conferencing: manage in Google');
+ if (end.date || end.dateTime)
+ notes.push('Duration retained in End; shown on start day only');
+ const values: Record = {
+ ...row.values,
+ [calendarFields.notes]: notes.join('. '),
+ };
+ if (typeof date === 'string' && validDay(date.slice(0, 10))) {
+ values[calendarFields.day] = date.slice(0, 10);
+ values[calendarFields.allDay] = typeof start.date === 'string';
+ }
+ return { ...row, values };
+ }),
+ };
+}
+function object(value: JSONValue | undefined): Record {
+ return value && typeof value === 'object' && !Array.isArray(value)
+ ? value
+ : {};
+}
+function validDay(value: string): boolean {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
+ const date = new Date(`${value}T00:00:00Z`);
+ return (
+ Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value
+ );
+}
diff --git a/integrations/localthought/mock-calendar.mjs b/integrations/localthought/mock-calendar.mjs
new file mode 100644
index 0000000000..13ba1e576a
--- /dev/null
+++ b/integrations/localthought/mock-calendar.mjs
@@ -0,0 +1,159 @@
+/** Synthetic Google Calendar fixtures. No real calendar data or provider writes. */
+const string = { type: 'string' };
+const dateTime = {
+ type: 'object',
+ properties: {
+ date: { ...string, format: 'date' },
+ dateTime: { ...string, format: 'date-time' },
+ timeZone: string,
+ },
+};
+export const calendarDocument = {
+ openapi: '3.0.3',
+ info: { title: 'Synthetic Calendar', version: 'v3' },
+ servers: [{ url: 'https://www.googleapis.com/calendar/v3' }],
+ paths: {
+ '/calendars/{calendarId}/events': {
+ get: {
+ parameters: [
+ { name: 'calendarId', in: 'path', required: true, schema: string },
+ ...['pageToken', 'timeMin', 'timeMax'].map(name => ({
+ name,
+ in: 'query',
+ schema: string,
+ })),
+ { name: 'singleEvents', in: 'query', schema: { type: 'boolean' } },
+ ],
+ 'x-pagination': [{ scheme: 'pageToken' }],
+ responses: {
+ 200: {
+ description: 'Events',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ items: {
+ type: 'array',
+ items: { $ref: '#/components/schemas/event' },
+ },
+ nextPageToken: string,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ components: {
+ paginationSchemes: {
+ pageToken: {
+ type: 'pageToken',
+ request: { queryParameters: { pageToken: { role: 'pageToken' } } },
+ response: { bodyFields: { nextPageToken: { role: 'nextPageToken' } } },
+ },
+ },
+ schemas: {
+ event: {
+ type: 'object',
+ properties: {
+ id: string,
+ summary: string,
+ status: string,
+ start: dateTime,
+ end: dateTime,
+ recurringEventId: string,
+ htmlLink: string,
+ attendees: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: { email: string, responseStatus: string },
+ },
+ },
+ },
+ },
+ },
+ crudResources: {
+ event: {
+ schema: { $ref: '#/components/schemas/event' },
+ identity: {
+ urlTemplate: '/calendars/{calendarId}/events/{eventId}',
+ bindings: { eventId: { field: 'id' } },
+ },
+ collections: {
+ events: { urlTemplate: '/calendars/{calendarId}/events' },
+ },
+ },
+ },
+ },
+};
+export function calendarFixture(day = new Date().toISOString().slice(0, 10)) {
+ const tomorrow = new Date(Date.parse(`${day}T00:00:00Z`) + 86400000)
+ .toISOString()
+ .slice(0, 10);
+ const events = [
+ {
+ id: 'all-day',
+ summary: 'Calendar all-day fixture',
+ status: 'confirmed',
+ start: { date: day },
+ end: { date: tomorrow },
+ },
+ {
+ id: 'timed',
+ summary: 'Calendar timed fixture',
+ status: 'confirmed',
+ start: {
+ dateTime: `${day}T00:30:00+02:00`,
+ timeZone: 'Europe/Amsterdam',
+ },
+ end: { dateTime: `${day}T01:30:00+02:00`, timeZone: 'Europe/Amsterdam' },
+ recurringEventId: 'series',
+ attendees: [
+ { email: 'synthetic@example.com', responseStatus: 'accepted' },
+ ],
+ },
+ ];
+ const requests = [];
+ return {
+ events,
+ requests,
+ request(method, url) {
+ requests.push({
+ method,
+ path: url.pathname,
+ query: Object.fromEntries(url.searchParams),
+ });
+ if (method !== 'GET') return { status: 405, body: {} };
+ if (
+ !/^\/proxy\/google-calendar\/calendar\/v3\/calendars\/[^/]+\/events$/.test(
+ url.pathname,
+ )
+ )
+ return { status: 404, body: {} };
+ // Require the production adapter to send its safety bounds and recurrence expansion.
+ if (
+ !url.searchParams.has('timeMin') ||
+ !url.searchParams.has('timeMax') ||
+ url.searchParams.get('singleEvents') !== 'true'
+ )
+ return {
+ status: 400,
+ body: { error: 'Missing bounded recurrence query' },
+ };
+ const token = url.searchParams.get('pageToken');
+ if (token && token !== 'second') return { status: 400, body: {} };
+ return {
+ status: 200,
+ body: structuredClone(
+ token
+ ? { items: events.slice(1) }
+ : { items: events.slice(0, 1), nextPageToken: 'second' },
+ ),
+ };
+ },
+ };
+}
diff --git a/integrations/localthought/mock-github.mjs b/integrations/localthought/mock-github.mjs
new file mode 100644
index 0000000000..629a1e28e1
--- /dev/null
+++ b/integrations/localthought/mock-github.mjs
@@ -0,0 +1,108 @@
+/** Stateful provider fixture, shared by the HTTP proxy and its test-side driver. */
+export function githubTracker() {
+ const repositories = new Map();
+ const repo = name => {
+ if (!repositories.has(name))
+ repositories.set(name, { issues: [], comments: [] });
+ return repositories.get(name);
+ };
+ let id = 0;
+ const now = () => new Date().toISOString();
+ const api = {
+ snapshot: name => structuredClone(repo(name)),
+ createIssue(name, input) {
+ const state = repo(name);
+ const number = state.issues.length + 1;
+ const issue = {
+ id: ++id,
+ number,
+ title: input.title,
+ body: input.body ?? '',
+ state: 'open',
+ labels: [],
+ url: `https://api.github.com/repos/${name}/issues/${number}`,
+ html_url: `https://github.com/${name}/issues/${number}`,
+ user: { login: 'mock-user' },
+ created_at: now(),
+ updated_at: now(),
+ };
+ state.issues.push(issue);
+ return structuredClone(issue);
+ },
+ updateIssue(name, number, input) {
+ const issue = repo(name).issues.find(i => i.number === number);
+ if (!issue) return;
+ for (const field of ['title', 'body', 'state', 'labels'])
+ if (input[field] !== undefined) issue[field] = input[field];
+ issue.updated_at = now();
+ return structuredClone(issue);
+ },
+ createComment(name, number, input) {
+ if (!repo(name).issues.some(i => i.number === number)) return;
+ const comment = {
+ id: ++id,
+ body: input.body,
+ issue_url: `https://api.github.com/repos/${name}/issues/${number}`,
+ user: { login: 'mock-commenter' },
+ created_at: now(),
+ updated_at: now(),
+ };
+ repo(name).comments.push(comment);
+ return structuredClone(comment);
+ },
+ request(method, url, input = {}) {
+ const match = url.pathname.match(
+ /^\/proxy\/github-issues\/repos\/([^/]+\/[^/]+)\/issues(?:\/(.*))?$/,
+ );
+ if (!match) return { status: 404, body: {} };
+ const [, name, tail = ''] = match;
+ const state = repo(name);
+ const page = Number(url.searchParams.get('page') ?? 1);
+ const size = Number(url.searchParams.get('per_page') ?? 100);
+ const paginate = rows => rows.slice((page - 1) * size, page * size);
+ let value;
+ if (!tail) {
+ if (method === 'GET') value = paginate(state.issues);
+ if (method === 'POST') value = api.createIssue(name, input);
+ } else if (/^comments\/\d+$/.test(tail)) {
+ const comment = state.comments.find(
+ c => c.id === Number(tail.split('/')[1]),
+ );
+ if (method === 'GET') value = comment;
+ if (method === 'PATCH' && comment)
+ value = Object.assign(comment, {
+ body: input.body,
+ updated_at: now(),
+ });
+ } else {
+ const [numberText, resource, label] = tail.split('/');
+ const number = Number(numberText);
+ const issue = state.issues.find(i => i.number === number);
+ if (issue && !resource) {
+ if (method === 'GET') value = issue;
+ if (method === 'PATCH') value = api.updateIssue(name, number, input);
+ } else if (issue && resource === 'comments') {
+ if (method === 'GET')
+ value = paginate(
+ state.comments.filter(c => c.issue_url === issue.url),
+ );
+ if (method === 'POST') value = api.createComment(name, number, input);
+ } else if (issue && resource === 'labels') {
+ if (method === 'POST')
+ value = issue.labels = [
+ ...new Set([...issue.labels, ...input.labels]),
+ ];
+ if (method === 'DELETE')
+ value = issue.labels = issue.labels.filter(
+ l => l !== decodeURIComponent(label),
+ );
+ }
+ }
+ return {
+ status: value === undefined ? 404 : method === 'POST' ? 201 : 200,
+ body: structuredClone(value ?? {}),
+ };
+ },
+ };
+ return api;
+}
diff --git a/integrations/localthought/mock-proxy.d.mts b/integrations/localthought/mock-proxy.d.mts
new file mode 100644
index 0000000000..72bdfcb1bd
--- /dev/null
+++ b/integrations/localthought/mock-proxy.d.mts
@@ -0,0 +1,49 @@
+import type { Server } from 'node:http';
+interface Issue {
+ id: number;
+ number: number;
+ title: string;
+ body: string;
+ state: string;
+ labels: string[];
+}
+interface Comment {
+ id: number;
+ body: string;
+ issue_url: string;
+}
+interface GitHubTracker {
+ snapshot(repository: string): { issues: Issue[]; comments: Comment[] };
+ createIssue(
+ repository: string,
+ input: { title: string; body?: string },
+ ): Issue;
+ updateIssue(
+ repository: string,
+ number: number,
+ input: Partial,
+ ): Issue | undefined;
+ createComment(
+ repository: string,
+ number: number,
+ input: { body: string },
+ ): Comment | undefined;
+}
+interface CalendarFixture {
+ events: Array<{
+ id: string;
+ summary: string;
+ status: string;
+ start: { date?: string; dateTime?: string; timeZone?: string };
+ end: { date?: string; dateTime?: string; timeZone?: string };
+ }>;
+ requests: Array<{
+ method: string;
+ path: string;
+ query: Record;
+ }>;
+}
+export const tenantSecret: string;
+export function mockProxy(options?: {
+ frontendOrigin?: string;
+}): Server & { github: GitHubTracker; calendar: CalendarFixture };
diff --git a/integrations/localthought/mock-proxy.mjs b/integrations/localthought/mock-proxy.mjs
index 8560e618b7..445f8d1749 100644
--- a/integrations/localthought/mock-proxy.mjs
+++ b/integrations/localthought/mock-proxy.mjs
@@ -1,48 +1,169 @@
/** Local-only integration-proxy fixture. Never deploy this service. */
+import { calendarDocument, calendarFixture } from './mock-calendar.mjs';
+import { githubTracker } from './mock-github.mjs';
import { readFileSync } from 'node:fs';
import { createServer } from 'node:http';
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { pathToFileURL } from 'node:url';
export const tenantSecret = 'bW9jay10ZW5hbnQ.mock-signature';
-const sign = value => createHmac('sha256', tenantSecret).update(value).digest('base64url');
-const equal = (a, b) => typeof a === 'string' && a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
-const pets = ['Rex', 'Whiskers', 'Tweety', 'Nibbles', 'Bubbles'].map((name, i) => ({ id: i + 1, name, species: ['Dog', 'Cat', 'Bird', 'Rabbit', 'Fish'][i], age: i + 1, vaccinated: i % 2 === 0, weight: i + 0.5, updated_at: '2026-09-09T00:00:00Z' }));
-export function mockProxy() {
+const sign = value =>
+ createHmac('sha256', tenantSecret).update(value).digest('base64url');
+const equal = (a, b) =>
+ typeof a === 'string' &&
+ a.length === b.length &&
+ timingSafeEqual(Buffer.from(a), Buffer.from(b));
+const pets = ['Rex', 'Whiskers', 'Tweety', 'Nibbles', 'Bubbles'].map(
+ (name, i) => ({
+ id: i + 1,
+ name,
+ species: ['Dog', 'Cat', 'Bird', 'Rabbit', 'Fish'][i],
+ age: i + 1,
+ vaccinated: i % 2 === 0,
+ weight: i + 0.5,
+ updated_at: '2026-09-09T00:00:00Z',
+ }),
+);
+export function mockProxy({
+ frontendOrigin = process.env.MOCK_FRONTEND_ORIGIN ?? 'http://localhost:6747',
+} = {}) {
+ const github = githubTracker();
+ const calendar = calendarFixture();
const codes = new Map();
const challenges = new Set();
- const issueCode = platform => { const code = randomBytes(32).toString('base64url'); codes.set(code, platform); return code; };
- return createServer((req, res) => {
+ const issueCode = platform => {
+ const code = randomBytes(32).toString('base64url');
+ codes.set(code, platform);
+ return code;
+ };
+ const server = createServer(async (req, res) => {
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader(
+ 'Access-Control-Allow-Methods',
+ 'GET, POST, PATCH, DELETE, OPTIONS',
+ );
+ res.setHeader(
+ 'Access-Control-Allow-Headers',
+ 'Authorization, Content-Type',
+ );
+ res.setHeader('Access-Control-Expose-Headers', 'X-Connection-Code, Link');
+ if (req.method === 'OPTIONS') {
+ res.writeHead(204);
+ return res.end();
+ }
+
const url = new URL(req.url, 'http://localhost');
- const json = (status, value, headers = {}) => { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(value)); };
- if (url.pathname === '/catalog') return json(200, ['github-issues', 'google-calendar', 'pets']);
- if (url.pathname === '/catalog/pets.yaml') { res.writeHead(200, { 'Content-Type': 'application/yaml' }); return res.end(readFileSync(new URL('./mock-document.json', import.meta.url))); }
- if (url.pathname === '/session') { const challenge = randomBytes(32).toString('base64url'); challenges.add(challenge); return json(200, { ts: Math.floor(Date.now() / 1000), nonce: randomBytes(16).toString('hex'), challenge }); }
+ const json = (status, value, headers = {}) => {
+ res.writeHead(status, { 'Content-Type': 'application/json', ...headers });
+ res.end(JSON.stringify(value));
+ };
+ if (url.pathname === '/catalog')
+ return json(200, ['github-issues', 'google-calendar', 'pets']);
+ if (url.pathname === '/catalog/pets.yaml') {
+ res.writeHead(200, { 'Content-Type': 'application/yaml' });
+ return res.end(
+ readFileSync(new URL('./mock-document.json', import.meta.url)),
+ );
+ }
+ if (url.pathname === '/catalog/google-calendar.yaml')
+ return json(200, calendarDocument);
+ if (url.pathname === '/session') {
+ const challenge = randomBytes(32).toString('base64url');
+ challenges.add(challenge);
+ return json(200, {
+ ts: Math.floor(Date.now() / 1000),
+ nonce: randomBytes(16).toString('hex'),
+ challenge,
+ });
+ }
if (url.pathname === '/connect') {
const p = url.searchParams;
const challenge = p.get('challenge');
- if (!challenges.has(challenge) || !equal(p.get('response'), sign(challenge)) || !equal(p.get('user_id_sig'), sign(p.get('user_id') ?? '')) || p.get('tenant_id') !== 'mock-tenant') return json(401, { error: 'Invalid tenant proof' });
+ if (
+ !challenges.has(challenge) ||
+ !equal(p.get('response'), sign(challenge)) ||
+ !equal(p.get('user_id_sig'), sign(p.get('user_id') ?? '')) ||
+ p.get('tenant_id') !== 'mock-tenant'
+ )
+ return json(401, { error: 'Invalid tenant proof' });
const redirect = new URL(p.get('redirect_uri'));
- if (redirect.origin !== (process.env.MOCK_FRONTEND_ORIGIN ?? 'http://localhost:6747') || redirect.pathname !== '/app/integrations') return json(400, { error: 'Invalid callback' });
+ if (
+ redirect.origin !== frontendOrigin ||
+ !['/app/integrations', '/app/devonian-demo'].includes(redirect.pathname)
+ )
+ return json(400, { error: 'Invalid callback' });
const platform = p.get('platform');
- if (!['github-issues', 'google-calendar', 'pets'].includes(platform)) return json(400, { error: 'Invalid platform' });
- if (req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'text/html' }); return res.end('Mock integration proxy Connect your test account.
'); }
+ if (!['github-issues', 'google-calendar', 'pets'].includes(platform))
+ return json(400, { error: 'Invalid platform' });
+ if (req.method === 'GET') {
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ return res.end(
+ 'Mock integration proxy Connect your test account.
',
+ );
+ }
if (req.method !== 'POST') return json(405, {});
challenges.delete(challenge);
redirect.searchParams.set('connection_code', issueCode(platform));
- res.writeHead(303, { Location: redirect.href }); return res.end();
+ res.writeHead(303, { Location: redirect.href });
+ return res.end();
}
if (url.pathname.startsWith('/proxy/')) {
const code = req.headers.authorization?.replace(/^Bearer /, '');
const platform = codes.get(code);
- if (!platform) return json(401, { error: 'Invalid or consumed connection code' });
+ if (!platform)
+ return json(401, { error: 'Invalid or consumed connection code' });
codes.delete(code);
- if (req.method !== 'GET' || !url.pathname.startsWith(`/proxy/${platform}/`)) return json(403, {});
- const data = platform === 'pets' ? (url.searchParams.get('page') === '2' ? pets.slice(2) : pets.slice(0, 2)) : platform === 'google-calendar' ? { items: [{ id: 'event-1', summary: 'Team meeting' }] } : [{ id: 42, title: 'Fetched GitHub issue', number: 42, state: 'open' }];
- return json(200, data, { 'X-Connection-Code': issueCode(platform), ...(platform === 'pets' && !url.searchParams.has('page') ? { Link: '; rel="next"' } : {}) });
+ const headers = { 'X-Connection-Code': issueCode(platform) };
+ if (!url.pathname.startsWith(`/proxy/${platform}/`))
+ return json(403, {}, headers);
+ if (platform === 'github-issues') {
+ try {
+ let body = '';
+ for await (const chunk of req) {
+ body += chunk;
+ if (body.length > 1024 * 1024) return json(413, {}, headers);
+ }
+ const result = github.request(
+ req.method,
+ url,
+ body ? JSON.parse(body) : {},
+ );
+ return json(result.status, result.body, headers);
+ } catch {
+ return json(400, { error: 'Invalid request body' }, headers);
+ }
+ }
+ if (platform === 'google-calendar') {
+ const result = calendar.request(req.method, url);
+ return json(result.status, result.body, headers);
+ }
+ if (req.method !== 'GET') return json(403, {}, headers);
+ const data =
+ platform === 'pets'
+ ? url.searchParams.get('page') === '2'
+ ? pets.slice(2)
+ : pets.slice(0, 2)
+ : { items: [{ id: 'event-1', summary: 'Team meeting' }] };
+ return json(200, data, {
+ ...headers,
+ ...(platform === 'pets' && !url.searchParams.has('page')
+ ? { Link: '; rel="next"' }
+ : {}),
+ });
}
json(404, {});
});
+ server.github = github;
+ server.calendar = calendar;
+ return server;
}
-if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
- mockProxy().listen(Number(process.env.MOCK_PROXY_PORT ?? 19090), process.env.MOCK_PROXY_HOST ?? '127.0.0.1', () => console.log('Mock integration proxy listening on http://127.0.0.1:19090'));
+if (
+ process.argv[1] &&
+ import.meta.url === pathToFileURL(process.argv[1]).href
+) {
+ mockProxy().listen(
+ Number(process.env.MOCK_PROXY_PORT ?? 19090),
+ process.env.MOCK_PROXY_HOST ?? '127.0.0.1',
+ () =>
+ console.log('Mock integration proxy listening on http://127.0.0.1:19090'),
+ );
}
diff --git a/integrations/localthought/syncables/Cargo.toml b/integrations/localthought/syncables/Cargo.toml
new file mode 100644
index 0000000000..d162e2ac8d
--- /dev/null
+++ b/integrations/localthought/syncables/Cargo.toml
@@ -0,0 +1,47 @@
+[package]
+name = "syncables"
+version = "0.1.0"
+edition = "2021"
+rust-version = "1.82"
+description = "Reads an OpenAPI document to run a mock API server and a client that syncs a local copy of the API's dataset."
+authors = ["Michiel de Jong "]
+license = "Apache-2.0"
+repository = "https://github.com/localthought/syncables-rs"
+readme = "README.md"
+keywords = ["openapi", "pagination", "sync", "mock-server", "local-first"]
+categories = ["web-programming", "api-bindings"]
+
+[dependencies]
+async-trait = "0.1"
+httpdate = "1"
+indexmap = { version = "2", features = ["serde"] }
+percent-encoding = "2"
+serde = { version = "1", features = ["derive"] }
+serde_json = { version = "1", features = ["preserve_order"] }
+serde_yaml_ng = "0.10"
+thiserror = "2"
+
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+tokio = { version = "1", features = ["fs"] }
+
+[dev-dependencies]
+uuid = { version = "1", features = ["v4"] }
+tokio = { version = "1", features = ["full"] }
+
+[lints.rust]
+missing_docs = "warn"
+
+[lints.clippy]
+all = { level = "warn", priority = -1 }
+pedantic = { level = "warn", priority = -1 }
+module_name_repetitions = "allow"
+# The module tree deliberately mirrors the TypeScript original's `src/`
+# layout one-to-one, which puts `client/client.rs` inside `client/`.
+module_inception = "allow"
+# "OpenAPI", "JSONPath" and friends are proper nouns, not code.
+doc_markdown = "allow"
+# `schema` vs. `scheme` are the spec's own two words, not a typo.
+similar_names = "allow"
+missing_errors_doc = "allow"
+missing_panics_doc = "allow"
+must_use_candidate = "allow"
diff --git a/integrations/localthought/syncables/LICENSE b/integrations/localthought/syncables/LICENSE
new file mode 100644
index 0000000000..8dada3edaf
--- /dev/null
+++ b/integrations/localthought/syncables/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "{}"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright {yyyy} {name of copyright owner}
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/integrations/localthought/syncables/README.md b/integrations/localthought/syncables/README.md
new file mode 100644
index 0000000000..f48da88994
--- /dev/null
+++ b/integrations/localthought/syncables/README.md
@@ -0,0 +1,209 @@
+# syncables-rs
+
+A Rust port of [localthought/syncables](https://github.com/localthought/syncables).
+
+Reads an OpenAPI document and gives you:
+
+- a **mock API server** that implements it, backed by a real (in-memory)
+ CRUD store per resource, seeded with fake data generated from the
+ document's schemas;
+- an **API client** that talks to any server implementing that OpenAPI
+ document and keeps a local copy of each resource collection in sync.
+
+Alongside that port, the crate also carries a second, unrelated surface:
+a **sync engine** (`SyncClient`) that reads a document's
+[CRUD Causality Extension](https://github.com/pondersource/openapi-extensions/tree/main/spec/crud-causality)
+(`components.crudResources`) and syncs records — including nested
+collections, walked once per parent record — into a host-provided
+`Storage` implementation, deriving a neutral, Atomic-Data-shaped ontology
+along the way without the crate itself depending on `atomic_lib`. This is
+new scope, not part of the original TypeScript port; see
+[Sync engine](#sync-engine) below.
+
+## Port status
+
+This section covers the original TypeScript port only — see
+[Sync engine](#sync-engine) below for that surface's status.
+
+This crate is **scaffolding**. The module tree mirrors the TypeScript
+original's `src/` one-to-one, and the following are ported and tested:
+
+| Area | Module | Status |
+| --- | --- | --- |
+| Document loading | `openapi::load`, `openapi::resolve_refs` | ported |
+| Overlays | `openapi::overlay` | ported |
+| OpenAPI type surface | `openapi::types` | ported |
+| Resource discovery | `resources::discover` | ported |
+| Path routing | `routing::router` | ported |
+| Fake data | `fake_data::generate` | ported |
+| Pagination | `pagination::{types, validate, autodetect, items, request_builder, response_parser}` | ported |
+| Mock server store | `mock_server::store` | ported |
+| Client storage | `client::storage` | ported |
+| Mock server handler | `mock_server::server` | **scaffolded** — public surface only |
+| Client sync/writes | `client::client` | **scaffolded** — public surface only |
+
+The two scaffolded modules carry their full ported public API, doc
+comments and constants; their function bodies are `todo!()`, each naming
+the TypeScript source file and function it is to be ported from. Nothing
+in this crate silently returns a wrong answer — the parts that exist are
+covered by tests, including acceptance tests against unmodified real-world
+documents.
+
+## Usage
+
+```sh
+cargo build
+cargo test
+cargo clippy --all-targets -- -D warnings
+cargo fmt --all --check
+```
+
+Once the client and mock server are ported, the shape will be:
+
+```rust,ignore
+use syncables::{create_api_client, create_mock_server, load_open_api_document, ApiClientOptions};
+
+let document = load_open_api_document("./petstore.yaml").await?;
+
+let server = create_mock_server(document.clone());
+let address = server.listen(None).await?;
+
+let client = create_api_client(document, ApiClientOptions::new(address.url));
+client.sync().await?; // pulls every discovered resource collection into local storage
+
+let pets = client.list("/pets").await?;
+```
+
+A "resource" is any pair of an OpenAPI collection path and its matching
+item path, e.g. `/pets` and `/pets/{petId}`. Paths without that pairing
+(health checks, one-off actions, etc.) are served from their documented
+examples/schemas but aren't treated as syncable resources.
+
+## Sync engine
+
+New scope, not part of the original TypeScript port; tracked by
+[issues #1–#9](https://github.com/localthought/syncables-rs/issues/1).
+Where the port above discovers resources from plain collection/item path
+pairing, the sync engine derives a richer resource model from a
+document's [CRUD Causality Extension](https://github.com/pondersource/openapi-extensions/tree/main/spec/crud-causality)
+(`components.crudResources`) — including nested collections, like a
+repository's issues and each issue's comments — and drives a full,
+paginated read of every collection into a host-provided `Storage`,
+deriving a neutral ontology (Classes and Properties, Atomic-Data-shaped
+but not Atomic-Data-typed) along the way.
+
+| Area | Module | Status |
+| --- | --- | --- |
+| Resource model (`crudResources`, `x-crud`) | `sync::resource_model` | ported and tested |
+| Binding configured constants into path templates | `sync::constants` | ported and tested |
+| Credentials, API base URL | `sync::credentials` | ported and tested |
+| Ontology derivation | `sync::ontology` | ported and tested |
+| `Storage` trait, `InMemoryStorage` | `sync::storage` | ported and tested |
+| `SyncClient::sync()` — full read | `sync::client` | ported and tested |
+| `SyncClient` — local-first write-back | `sync::client` | **not implemented** — [#9](https://github.com/localthought/syncables-rs/issues/9) |
+
+```rust,ignore
+use std::sync::Arc;
+use syncables::{ClientConfig, Credentials, InMemoryStorage, SyncClient};
+
+let config = ClientConfig {
+ document: "./github-issues.openapi.yaml".into(),
+ overlays: vec!["./auth-overlay.yaml".into(), "./crud-causality-overlay.yaml".into()],
+ credentials: Credentials::Bearer(std::env::var("GITHUB_TOKEN")?),
+ constants: [("owner".to_string(), "localthought".to_string()),
+ ("repo".to_string(), "test-repo-1".to_string())].into(),
+ ontology_base_url: "https://my-ontologies.com".to_string(),
+};
+
+let client = SyncClient::new(config, Arc::new(my_fetch_impl))?;
+let storage = InMemoryStorage::new();
+let report = client.sync(&storage).await?; // walks issues, then each issue's comments
+```
+
+`SyncClient::new` takes an `Arc` (the same injectable-transport
+trait `ApiClient` above uses) alongside `ClientConfig` — the crate has no
+HTTP client dependency of its own, so a host supplies one. This is the one
+deliberate divergence from the `ClientConfig`/`SyncClient` contract
+[`localthought/reflector-rs`](https://github.com/localthought/reflector-rs)
+is already written against in its `src/syncables.rs`, which otherwise this
+module matches field-for-field; that module is meant to be deleted once
+reflector-rs points its `use`s here instead.
+
+## Differences from the TypeScript original
+
+The port keeps the original's names, comments and behaviour wherever it
+can. Where Rust forced a decision, it went like this:
+
+- **`Record` → `serde_json::Map`**, and
+ `unknown` → `serde_json::Value`. Maps that need document order
+ (schema properties, paths, collections) use `IndexMap`, and
+ `serde_json` is built with `preserve_order`: `locate_items_field` picks
+ the *first* array-typed property, so order is load-bearing.
+- **The injectable `fetch` becomes a `Fetch` trait**
+ (`client::client::Fetch`). As in the original, it is the only extension
+ point — there is no built-in notion of auth, so authenticating means
+ supplying an implementation that adds the right header to every request.
+- **`StorageAdapter` is an `async_trait`**, with `InMemoryStorageAdapter`
+ as the default, matching the original's pluggable adapter.
+- **Pagination roles stay strings** rather than becoming closed enums: the
+ spec allows `x-` extension roles, and `validate` is what decides
+ validity, so an unknown role has to survive parsing to be reported.
+ `PaginationSchemeObject::type` likewise deserializes leniently, so one
+ malformed scheme (e.g. Giphy's former `type: offset`) does not fail the
+ whole document.
+- **Errors are a `thiserror` enum** (`syncables::Error`) instead of thrown
+ strings; every fallible function returns `syncables::Result`.
+- **Numbers are `f64`** in `PaginationResponseState`, mirroring JavaScript
+ number semantics for values read out of arbitrary JSON bodies.
+
+## Tests
+
+`tests/unit/` mirrors the original's `__tests__/unit/` layout module for
+module. Cargo builds it as one test binary (`tests/unit/main.rs`), so the
+module tree is declared there rather than discovered per file.
+
+`tests/fixtures/real-world/` holds real OpenAPI documents and pagination
+overlays vendored unmodified from apis.guru and localthought/overlays (see
+the header comment in each file for provenance). The acceptance tests run
+the ported pipeline against these and deliberately document real quirks
+rather than working around them. When extending them, keep that spirit:
+assert what actually happens against the unmodified real document, not an
+idealized result.
+
+## NLnet milestone 1
+
+The TypeScript original is the reference implementation for
+[milestone 1](https://github.com/tubsproject/syncables/blob/main/nlnet-milestones.md#1-syncables)
+of the project's NLnet grant. This port tracks it.
+
+## Generative AI use
+
+syncables-rs is developed collaboratively with **Claude Code**
+(Anthropic), an agentic coding assistant: a human directs the design and
+reviews, edits, and tests the changes it proposes before they're
+committed.
+
+As an NLnet-funded project, this follows
+[NLnet's Generative AI policy](https://nlnet.nl/foundation/policies/generativeAI/):
+
+- Commits produced with AI assistance carry a `Claude-Session: `
+ trailer identifying the session that produced them.
+- [`docs/ai-logs/`](docs/ai-logs) holds prompt/output disclosure logs,
+ redacted for secrets and personal information.
+- AI-drafted content is reviewed and edited by a human before being
+ committed; it is not represented as unassisted human work.
+
+## License
+
+Apache-2.0, matching the original.
+
+## Browser / WASM
+
+`cargo check --target wasm32-unknown-unknown --lib` builds the engine without
+Tokio filesystem or native runtime dependencies. Load catalog text with
+`openapi::load::parse_yaml`, pass the resulting value to
+`load_open_api_document`, then call `SyncClient::sync_document(&doc, &storage)`.
+This path never opens `ClientConfig.document` or `overlays`; apply overlays in
+memory before calling it. File sources return an explicit unsupported error
+in WASM. Implement browser `Fetch` with `#[async_trait(?Send)]`; it may hold
+a JS callback. Native `Fetch` keeps its Send + Sync contract.
diff --git a/integrations/localthought/syncables/UPSTREAM.md b/integrations/localthought/syncables/UPSTREAM.md
new file mode 100644
index 0000000000..1cd8d62970
--- /dev/null
+++ b/integrations/localthought/syncables/UPSTREAM.md
@@ -0,0 +1 @@
+Vendored from localthought/syncables-rs commit 0ab3521 (codex/browser-integrations), based on d48e4d9bad3ed9ec826d1c2040e989171701965d. This snapshot adds sync_document and target-specific transport/filesystem support. Replace with a pinned upstream dependency after that branch is merged. Apache-2.0; see LICENSE.
diff --git a/integrations/localthought/syncables/src/client/client.rs b/integrations/localthought/syncables/src/client/client.rs
new file mode 100644
index 0000000000..152700df4c
--- /dev/null
+++ b/integrations/localthought/syncables/src/client/client.rs
@@ -0,0 +1,369 @@
+//! The API client: talks to any server implementing the document, and
+//! keeps a local copy of each resource collection in sync.
+//!
+//! Reads are served from local storage ([`StorageAdapter`];
+//! [`InMemoryStorageAdapter`] is the default). `sync()` and the standalone
+//! `paginate()` both walk every page of a paginated GET operation before
+//! returning.
+//!
+//! `create`/`update`/`remove` are local-first: each writes to storage
+//! immediately and returns without waiting on the network, then applies
+//! itself against the server in the background via a per-record write
+//! queue (keyed by `{resource}:{id}`, one write in flight at a time so
+//! writes to the same record land in server order), retrying failures with
+//! exponential backoff.
+//!
+//! Nothing in this crate reads an OpenAPI document's
+//! `security`/`securitySchemes` — there is no built-in notion of auth.
+//! [`ApiClientOptions::fetch`] is the only extension point, so
+//! authenticating (a bearer token, an API key from an env var, etc.) means
+//! passing a [`Fetch`] implementation that adds the right header to every
+//! request.
+//!
+//! # Port status
+//!
+//! Scaffolding. The public surface, options and types below are ported;
+//! the sync/write/pagination machinery is not yet implemented.
+
+// The `async` on the stubs below is part of the ported API surface,
+// not an accident of the current `todo!()` bodies.
+#![allow(clippy::unused_async)]
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use async_trait::async_trait;
+use indexmap::IndexMap;
+use serde_json::{Map, Value};
+
+use crate::error::Result;
+use crate::openapi::types::OpenApiDocument;
+use crate::resources::discover::{discover_resources, ResourceRoute};
+
+use super::storage::{InMemoryStorageAdapter, StorageAdapter};
+
+/// Hard ceiling on pages walked in one traversal, mirroring the original's
+/// `MAX_PAGES`.
+pub const MAX_PAGES: usize = 50;
+
+/// One outgoing HTTP request.
+#[derive(Debug, Clone)]
+pub struct HttpRequest {
+ /// HTTP method.
+ pub method: String,
+ /// Absolute URL.
+ pub url: String,
+ /// Request headers.
+ pub headers: IndexMap,
+ /// Request body, if any.
+ pub body: Option>,
+}
+
+/// One HTTP response.
+#[derive(Debug, Clone)]
+pub struct HttpResponse {
+ /// HTTP status code.
+ pub status: u16,
+ /// Response headers.
+ pub headers: IndexMap,
+ /// Response body.
+ pub body: Vec,
+}
+
+/// The client's only extension point for how requests reach the network.
+///
+/// This is the Rust equivalent of the original's injectable `fetch`.
+#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
+#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
+pub trait Fetch: FetchBounds {
+ /// Sends one request and returns the response.
+ async fn fetch(&self, request: HttpRequest) -> Result;
+}
+
+/// How a failed background write is retried.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RetryOptions {
+ /// Delay before the first retry of a failed write. Doubles on each
+ /// subsequent attempt. Default 200ms.
+ pub base_delay: Duration,
+ /// Ceiling for the exponential backoff between retries. Default 30s.
+ pub max_delay: Duration,
+ /// Stop auto-retrying a write after this many attempts. Default is
+ /// unlimited (keep retrying until it succeeds).
+ pub max_attempts: Option,
+}
+
+impl Default for RetryOptions {
+ fn default() -> Self {
+ Self {
+ base_delay: Duration::from_millis(200),
+ max_delay: Duration::from_secs(30),
+ max_attempts: None,
+ }
+ }
+}
+
+/// How to build an [`ApiClient`].
+pub struct ApiClientOptions {
+ /// Base URL of the server to talk to.
+ pub base_url: String,
+ /// Where the local copy lives. Defaults to [`InMemoryStorageAdapter`].
+ pub storage: Option>,
+ /// How requests reach the network.
+ pub fetch: Option>,
+ /// How failed background writes are retried.
+ pub retry: RetryOptions,
+ /// Record property that holds a resource's identity — the value used
+ /// as the local storage key, read back from a create response to
+ /// reconcile the server-assigned id, and substituted into the item
+ /// URL's path variable.
+ ///
+ /// Defaults to `id`. Set this when the API addresses a resource by a
+ /// different field (e.g. GitHub issues are keyed by `number`, not the
+ /// global `id` the payload also carries).
+ pub identity_field: String,
+}
+
+impl ApiClientOptions {
+ /// Options pointing at `base_url`, with everything else defaulted.
+ pub fn new(base_url: impl Into) -> Self {
+ Self {
+ base_url: base_url.into(),
+ storage: None,
+ fetch: None,
+ retry: RetryOptions::default(),
+ identity_field: "id".to_string(),
+ }
+ }
+}
+
+/// Options for one [`ApiClient::paginate`] traversal.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct PaginateOptions {
+ /// Page size to request. Falls back to the server's own default when
+ /// omitted.
+ pub page_size: Option,
+}
+
+/// What one [`ApiClient::sync`] changed.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct SyncResult {
+ /// Collection paths whose local copy was actually added to, updated,
+ /// or pruned by this sync.
+ pub changed: Vec,
+}
+
+/// Called after each sync while polling.
+pub type SyncCallback = Box;
+/// Called when a sync fails while polling.
+pub type ErrorCallback = Box;
+
+/// How [`ApiClient::start_polling`] should behave.
+pub struct PollOptions {
+ /// How often to call `sync()`. An initial sync runs immediately.
+ pub interval: Duration,
+ /// Called after every sync while polling, including ones where nothing
+ /// changed.
+ pub on_sync: Option,
+ /// Called when a sync fails while polling; polling continues on the
+ /// next interval.
+ pub on_error: Option,
+}
+
+/// Cancels a running poll loop.
+#[derive(Debug)]
+pub struct PollingHandle {
+ _private: (),
+}
+
+impl PollingHandle {
+ /// Stops future polling. Does not cancel a sync already in flight.
+ pub fn stop(self) {
+ todo!("port src/client/client.ts: startPolling")
+ }
+}
+
+/// Which kind of write is still pending.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PendingWriteType {
+ /// A `create` not yet confirmed by the server.
+ Create,
+ /// An `update` not yet confirmed by the server.
+ Update,
+ /// A `remove` not yet confirmed by the server.
+ Delete,
+}
+
+/// A write local storage already reflects, but the server has not yet
+/// confirmed.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PendingWriteInfo {
+ /// Collection path the write belongs to.
+ pub resource: String,
+ /// The id the write is filed under locally. For an unsettled `create`,
+ /// this is the client-generated id, not (yet) whatever the server
+ /// assigns.
+ pub id: String,
+ /// Which kind of write this is.
+ pub write_type: PendingWriteType,
+ /// How many attempts to reach the server have failed so far.
+ ///
+ /// If [`RetryOptions::max_attempts`] is set and reached, this stops
+ /// growing and the write stops auto-retrying — it stays listed until
+ /// `create`/`update`/`remove` is called again for the same record.
+ pub attempts: u32,
+ /// The most recent failure, if at least one attempt has failed.
+ pub last_error: Option,
+}
+
+/// A local-first client for a server implementing the document.
+pub struct ApiClient {
+ document: Arc,
+ routes: Arc>,
+ storage: Arc,
+ fetch: Option>,
+ options: Arc,
+}
+
+impl ApiClient {
+ /// Collection paths this client knows how to sync.
+ pub fn resources(&self) -> Vec {
+ self.routes
+ .iter()
+ .map(|route| route.collection_path.clone())
+ .collect()
+ }
+
+ /// The document this client was built from.
+ pub fn document(&self) -> &OpenApiDocument {
+ &self.document
+ }
+
+ /// Where the local copy lives.
+ pub fn storage(&self) -> &Arc {
+ &self.storage
+ }
+
+ /// Pulls every discovered resource collection into local storage.
+ ///
+ /// Safe to call repeatedly: for a non-paginated collection it
+ /// conditionally re-fetches, keyed by exact request URL, sending
+ /// `If-None-Match`/`If-Modified-Since` from the prior response's
+ /// `ETag`/`Last-Modified` and treating a `304` as "nothing to do".
+ /// Even on a fresh `200`, or for a paginated collection, it only
+ /// touches storage for items that actually changed.
+ pub async fn sync(&self) -> Result {
+ let _ = &self.fetch;
+ let _ = &self.options;
+ todo!("port src/client/client.ts: sync")
+ }
+
+ /// Calls [`Self::sync`] on `options.interval`, skipping a tick if the
+ /// previous sync is still running.
+ // `options` is consumed by the poll loop once this is implemented.
+ #[allow(clippy::needless_pass_by_value)]
+ pub fn start_polling(&self, options: PollOptions) -> PollingHandle {
+ let _ = options;
+ todo!("port src/client/client.ts: startPolling")
+ }
+
+ /// Every locally held record of `resource`.
+ pub async fn list(&self, resource: &str) -> Result>> {
+ self.storage.list(resource).await
+ }
+
+ /// One locally held record, by id.
+ pub async fn get(&self, resource: &str, id: &str) -> Result>> {
+ self.storage.get(resource, id).await
+ }
+
+ /// Writes `data` to local storage immediately, under a client-generated
+ /// id (or `data[identity_field]`, if already set) and returns without
+ /// waiting on the network.
+ ///
+ /// The write to the server happens in the background and is retried on
+ /// failure — see [`Self::pending_writes`] for its outcome so far. If
+ /// the server assigns a different id than the one used locally, the
+ /// record is moved to it once the write settles.
+ pub async fn create(
+ &self,
+ resource: &str,
+ data: Map,
+ ) -> Result> {
+ let _ = (resource, data);
+ todo!("port src/client/client.ts: create")
+ }
+
+ /// Merges `data` into the local copy of `id` immediately and returns
+ /// without waiting on the network; the corresponding `PUT` (or `PATCH`,
+ /// per [`ResourceRoute::update_method`]) is sent, and retried on
+ /// failure, in the background.
+ pub async fn update(
+ &self,
+ resource: &str,
+ id: &str,
+ data: Map,
+ ) -> Result> {
+ let _ = (resource, id, data);
+ todo!("port src/client/client.ts: update")
+ }
+
+ /// Removes `id` from local storage immediately and returns without
+ /// waiting on the network; the corresponding `DELETE` is sent, and
+ /// retried on failure, in the background.
+ pub async fn remove(&self, resource: &str, id: &str) -> Result<()> {
+ let _ = (resource, id);
+ todo!("port src/client/client.ts: remove")
+ }
+
+ /// Writes not yet confirmed by the server, across every resource (or
+ /// just `resource`, if given).
+ pub fn pending_writes(&self, resource: Option<&str>) -> Vec {
+ let _ = resource;
+ todo!("port src/client/client.ts: pendingWrites")
+ }
+
+ /// Fetches every item from a GET list operation at `path`, walking
+ /// every page per its resolved pagination scheme (explicit
+ /// `x-pagination` or auto-detected from `components.paginationSchemes`).
+ ///
+ /// `path` need not be a discovered resource — any GET operation in the
+ /// document works, e.g. a search/listing endpoint with no paired item
+ /// route.
+ pub async fn paginate(
+ &self,
+ path: &str,
+ options: PaginateOptions,
+ ) -> Result>> {
+ let _ = (path, options);
+ todo!("port src/client/client.ts: paginate")
+ }
+}
+
+/// Builds a client for `document` against the server in `options`.
+pub fn create_api_client(document: OpenApiDocument, options: ApiClientOptions) -> ApiClient {
+ let routes = discover_resources(&document.paths);
+ let storage = options
+ .storage
+ .clone()
+ .unwrap_or_else(|| Arc::new(InMemoryStorageAdapter::new()));
+ let fetch = options.fetch.clone();
+ ApiClient {
+ document: Arc::new(document),
+ routes: Arc::new(routes),
+ storage,
+ fetch,
+ options: Arc::new(options),
+ }
+}
+
+/// Native transports must be thread safe; browser transports run on one JS thread.
+#[cfg(not(target_arch = "wasm32"))]
+pub trait FetchBounds: Send + Sync {}
+#[cfg(not(target_arch = "wasm32"))]
+impl FetchBounds for T {}
+/// Browser transports may own JavaScript callbacks.
+#[cfg(target_arch = "wasm32")]
+pub trait FetchBounds {}
+#[cfg(target_arch = "wasm32")]
+impl FetchBounds for T {}
diff --git a/integrations/localthought/syncables/src/client/mod.rs b/integrations/localthought/syncables/src/client/mod.rs
new file mode 100644
index 0000000000..27ea15e9c5
--- /dev/null
+++ b/integrations/localthought/syncables/src/client/mod.rs
@@ -0,0 +1,5 @@
+//! An API client that talks to any server implementing the OpenAPI
+//! document and keeps a local copy of each resource collection in sync.
+
+pub mod client;
+pub mod storage;
diff --git a/integrations/localthought/syncables/src/client/storage.rs b/integrations/localthought/syncables/src/client/storage.rs
new file mode 100644
index 0000000000..65b2a20bef
--- /dev/null
+++ b/integrations/localthought/syncables/src/client/storage.rs
@@ -0,0 +1,84 @@
+//! Where the client keeps its local copy of each resource collection.
+
+use std::collections::HashMap;
+use std::sync::Mutex;
+
+use async_trait::async_trait;
+use indexmap::IndexMap;
+use serde_json::{Map, Value};
+
+use crate::error::Result;
+
+/// Pluggable local storage for the client's copy of a collection.
+///
+/// Implement this to persist somewhere other than memory;
+/// [`InMemoryStorageAdapter`] is the default.
+#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
+#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
+pub trait StorageAdapter: Send + Sync {
+ /// Every record held for `resource`.
+ async fn list(&self, resource: &str) -> Result>>;
+ /// One record, by id.
+ async fn get(&self, resource: &str, id: &str) -> Result>>;
+ /// Inserts or replaces a record.
+ async fn put(&self, resource: &str, id: &str, value: Map) -> Result<()>;
+ /// Removes a record.
+ async fn delete(&self, resource: &str, id: &str) -> Result<()>;
+}
+
+/// One collection per resource path, each keyed by record id.
+type Collections = HashMap>>;
+
+/// The default [`StorageAdapter`]: everything in process memory.
+#[derive(Debug, Default)]
+pub struct InMemoryStorageAdapter {
+ collections: Mutex,
+}
+
+impl InMemoryStorageAdapter {
+ /// An adapter with no collections.
+ pub fn new() -> Self {
+ Self::default()
+ }
+}
+
+#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
+#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
+impl StorageAdapter for InMemoryStorageAdapter {
+ async fn list(&self, resource: &str) -> Result>> {
+ let mut collections = self.collections.lock().expect("storage mutex poisoned");
+ Ok(collections
+ .entry(resource.to_string())
+ .or_default()
+ .values()
+ .cloned()
+ .collect())
+ }
+
+ async fn get(&self, resource: &str, id: &str) -> Result>> {
+ let mut collections = self.collections.lock().expect("storage mutex poisoned");
+ Ok(collections
+ .entry(resource.to_string())
+ .or_default()
+ .get(id)
+ .cloned())
+ }
+
+ async fn put(&self, resource: &str, id: &str, value: Map) -> Result<()> {
+ let mut collections = self.collections.lock().expect("storage mutex poisoned");
+ collections
+ .entry(resource.to_string())
+ .or_default()
+ .insert(id.to_string(), value);
+ Ok(())
+ }
+
+ async fn delete(&self, resource: &str, id: &str) -> Result<()> {
+ let mut collections = self.collections.lock().expect("storage mutex poisoned");
+ collections
+ .entry(resource.to_string())
+ .or_default()
+ .shift_remove(id);
+ Ok(())
+ }
+}
diff --git a/integrations/localthought/syncables/src/error.rs b/integrations/localthought/syncables/src/error.rs
new file mode 100644
index 0000000000..ebddbd6c05
--- /dev/null
+++ b/integrations/localthought/syncables/src/error.rs
@@ -0,0 +1,95 @@
+//! The crate's error type.
+
+use std::path::PathBuf;
+
+use thiserror::Error;
+
+/// Anything that can go wrong loading a document or talking to a server.
+#[derive(Debug, Error)]
+#[non_exhaustive]
+pub enum Error {
+ /// Reading a document from disk failed.
+ #[error("i/o error: {0}")]
+ Io(#[from] std::io::Error),
+
+ /// A document could not be parsed as YAML or JSON.
+ #[error("could not parse document: {0}")]
+ Yaml(#[from] serde_yaml_ng::Error),
+
+ /// A document did not fit the OpenAPI type surface this crate expects.
+ #[error("could not read document: {0}")]
+ Json(#[from] serde_json::Error),
+
+ /// A document or overlay file could not be read or parsed. Wraps the
+ /// underlying [`Error::Io`] or [`Error::Yaml`] with the path that caused
+ /// it, since a bare i/o or parse error doesn't otherwise name the file.
+ #[error("could not load \"{path}\": {source}")]
+ FileLoad {
+ /// The file that could not be loaded.
+ path: PathBuf,
+ /// The underlying read or parse failure.
+ #[source]
+ source: Box,
+ },
+
+ /// An overlay used a JSONPath target outside the supported subset.
+ #[error(
+ "unsupported overlay target \"{0}\": only \"$\", simple dot-paths like \
+ \"$.components...\", and quoted bracket segments like \
+ \"$.paths['/pets'].get\" are supported"
+ )]
+ UnsupportedOverlayTarget(String),
+
+ /// An overlay target segment resolved to something that isn't an object.
+ #[error("overlay target segment \"{0}\" does not resolve to an object")]
+ OverlayTargetNotAnObject(String),
+
+ /// An overlay tried to `remove` the document root.
+ #[error("overlay cannot remove the document root")]
+ OverlayRemovesRoot,
+
+ /// The client could not reach the server, or the server rejected the request.
+ #[error("http error: {0}")]
+ Http(String),
+
+ /// A resource path was asked for that the document does not declare.
+ #[error("unknown resource \"{0}\"")]
+ UnknownResource(String),
+
+ /// The document declares no `components.crudResources`; the
+ /// CRUD-causality overlay hasn't been applied.
+ #[error("document declares no crudResources; apply the CRUD-causality overlay")]
+ NoCrudResources,
+
+ /// A constant named a parameter the document does not declare (path or
+ /// query, on any operation). A typo'd key here would otherwise sync
+ /// nothing, or scope the sync far wider than intended.
+ #[error("constant \"{0}\" does not name a parameter this document declares")]
+ UnknownConstant(String),
+
+ /// A path template variable is bound by neither a constant, a parent
+ /// record's context provider, nor the resource's own identity binding.
+ #[error(
+ "path variable \"{0}\" is not bound by a constant, a parent record, \
+ or an identity binding"
+ )]
+ UnboundContextParam(String),
+
+ /// Two differently-named resources or fields normalized to the same
+ /// ontology shortname — the ontology would then mint one term for two
+ /// distinct things.
+ #[error(
+ "\"{first}\" and \"{second}\" both normalize to the ontology shortname \"{shortname}\""
+ )]
+ ShortnameCollision {
+ /// The shortname both names normalized to.
+ shortname: String,
+ /// The name that claimed the shortname first.
+ first: String,
+ /// The name that collided with it.
+ second: String,
+ },
+}
+
+/// `Result` specialized to this crate's [`Error`].
+pub type Result = std::result::Result;
diff --git a/integrations/localthought/syncables/src/fake_data/generate.rs b/integrations/localthought/syncables/src/fake_data/generate.rs
new file mode 100644
index 0000000000..9602c95912
--- /dev/null
+++ b/integrations/localthought/syncables/src/fake_data/generate.rs
@@ -0,0 +1,83 @@
+//! Schema-to-value synthesis, shared by the mock server for seeding and
+//! for example responses.
+//!
+//! A schema's own fixed `example` is reused verbatim on every call, so
+//! callers generating more than one item from the same schema (resource
+//! seeding, paginated list generation) must inject their own unique `id`
+//! afterward rather than relying on the generated value to differ per item.
+
+use std::sync::atomic::{AtomicU64, Ordering};
+
+use serde_json::{Map, Number, Value};
+
+use crate::openapi::types::SchemaObject;
+
+static STRING_COUNTER: AtomicU64 = AtomicU64::new(0);
+
+/// Synthesizes a value matching a JSON Schema, preferring an `example` on
+/// the schema itself when present.
+pub fn generate_from_schema(schema: Option<&SchemaObject>) -> Value {
+ let Some(schema) = schema else {
+ return Value::Null;
+ };
+ if let Some(example) = &schema.example {
+ return example.clone();
+ }
+ if let Some(enum_values) = &schema.enum_values {
+ if let Some(first) = enum_values.first() {
+ return first.clone();
+ }
+ }
+ if let Some(all_of) = &schema.all_of {
+ let mut merged = Map::new();
+ for sub in all_of {
+ if let Value::Object(object) = generate_from_schema(Some(sub)) {
+ merged.extend(object);
+ }
+ }
+ return Value::Object(merged);
+ }
+ if let Some(one_of) = &schema.one_of {
+ if let Some(first) = one_of.first() {
+ return generate_from_schema(Some(first));
+ }
+ }
+ if let Some(any_of) = &schema.any_of {
+ if let Some(first) = any_of.first() {
+ return generate_from_schema(Some(first));
+ }
+ }
+
+ match schema.schema_type.as_deref() {
+ Some("string") => Value::String(generate_string(schema)),
+ Some("integer" | "number") => {
+ Number::from_f64(schema.minimum.unwrap_or(1.0)).map_or(Value::Null, Value::Number)
+ }
+ Some("boolean") => Value::Bool(true),
+ Some("array") => Value::Array(vec![generate_from_schema(schema.items.as_deref())]),
+ Some("object") | None => Value::Object(generate_object(schema)),
+ Some(_) => Value::Null,
+ }
+}
+
+fn generate_object(schema: &SchemaObject) -> Map {
+ let mut result = Map::new();
+ for (key, property_schema) in schema.properties.iter().flatten() {
+ result.insert(key.clone(), generate_from_schema(Some(property_schema)));
+ }
+ result
+}
+
+fn generate_string(schema: &SchemaObject) -> String {
+ match schema.format.as_deref() {
+ Some("date-time") => "1970-01-01T00:00:00.000Z".to_string(),
+ Some("date") => "1970-01-01".to_string(),
+ Some("uuid") => "00000000-0000-4000-8000-000000000000".to_string(),
+ Some("email") => "user@example.com".to_string(),
+ Some("uri" | "url") => "https://example.com".to_string(),
+ _ => {
+ let next = STRING_COUNTER.fetch_add(1, Ordering::Relaxed) + 1;
+ format!("string-{next}")
+ }
+ }
+}
diff --git a/integrations/localthought/syncables/src/fake_data/mod.rs b/integrations/localthought/syncables/src/fake_data/mod.rs
new file mode 100644
index 0000000000..8c6a87ef33
--- /dev/null
+++ b/integrations/localthought/syncables/src/fake_data/mod.rs
@@ -0,0 +1,3 @@
+//! Synthesizing values from OpenAPI schemas.
+
+pub mod generate;
diff --git a/integrations/localthought/syncables/src/lib.rs b/integrations/localthought/syncables/src/lib.rs
new file mode 100644
index 0000000000..0b9e3fc9f3
--- /dev/null
+++ b/integrations/localthought/syncables/src/lib.rs
@@ -0,0 +1,81 @@
+//! Reads an OpenAPI document and gives you:
+//!
+//! - a **mock API server** ([`create_mock_server`]) that implements it,
+//! backed by a real (in-memory) CRUD store per resource, seeded with
+//! fake data generated from the document's schemas;
+//! - an **API client** ([`create_api_client`]) that talks to any server
+//! implementing that OpenAPI document and keeps a local copy of each
+//! resource collection in sync.
+//!
+//! Both understand the [OpenAPI Pagination Schemes Extension](https://github.com/pondersource/openapi-pagination-schemes-extension)
+//! when a document declares `components.paginationSchemes`.
+//!
+//! A "resource" is any pair of an OpenAPI collection path and its matching
+//! item path, e.g. `/pets` and `/pets/{petId}`. Paths without that pairing
+//! (health checks, one-off actions, etc.) are served from their documented
+//! examples/schemas but aren't treated as syncable resources.
+//!
+//! # Port status
+//!
+//! This crate is a port of [localthought/syncables](https://github.com/localthought/syncables)
+//! (TypeScript) and is **scaffolding**: the module layout mirrors the
+//! original's `src/` one-to-one, and the document, resource, pagination
+//! and storage layers are ported. The mock server's request handler
+//! ([`mock_server::server`]) and the client's sync/write machinery
+//! ([`client::client`]) carry their full public surface but are not
+//! implemented yet — those functions `todo!()`, each naming the
+//! TypeScript source it is to be ported from.
+
+pub mod client;
+pub mod error;
+pub mod fake_data;
+pub mod mock_server;
+pub mod openapi;
+pub mod pagination;
+pub mod resources;
+pub mod routing;
+pub mod sync;
+
+pub use crate::error::{Error, Result};
+
+pub use crate::openapi::load::{load_open_api_document, OpenApiSource};
+pub use crate::openapi::overlay::{
+ apply_overlay, load_open_api_document_with_overlays, load_overlay, OverlayAction,
+ OverlayDocument,
+};
+pub use crate::openapi::resolve_refs::resolve_refs;
+pub use crate::openapi::types::{
+ OpenApiDocument, OperationObject, ParameterObject, SchemaObject, ServerObject,
+};
+
+pub use crate::resources::discover::{discover_resources, ResourceRoute};
+
+pub use crate::fake_data::generate::generate_from_schema;
+
+pub use crate::mock_server::server::{create_mock_server, MockServer};
+
+pub use crate::client::client::{
+ create_api_client, ApiClient, ApiClientOptions, PaginateOptions, PollOptions, PollingHandle,
+ SyncResult,
+};
+pub use crate::client::storage::{InMemoryStorageAdapter, StorageAdapter};
+
+pub use crate::pagination::autodetect::{resolve_effective_scheme, EffectiveScheme};
+pub use crate::pagination::types::{
+ AutoDetectObject, PaginationApplicationObject, PaginationResponseState, PaginationSchemeObject,
+ PaginationSchemesMap, RequestRole, ResponseRole, SchemeType,
+};
+pub use crate::pagination::validate::validate_pagination_scheme;
+
+pub use crate::sync::client::{ClientConfig, SyncClient, SyncError, SyncReport};
+pub use crate::sync::constants::{bind_url, validate_constants};
+pub use crate::sync::credentials::{base_url, Credentials};
+pub use crate::sync::ontology::{
+ derive_ontology, ontology_shortname, Ontology, OntologyTerm, TermKind,
+};
+pub use crate::sync::resource_model::{
+ crud_operation, discover_resource_model, AddedField, CollectionMembership, ContextProvider,
+ CrudAction, CrudOperation, CrudResourceObject, IdentityBindingObject, ManagedCollection,
+ ResourceCollectionObject, ResourceIdentityObject, ResourceModel,
+};
+pub use crate::sync::storage::{InMemoryStorage, Record, Storage, StorageError};
diff --git a/integrations/localthought/syncables/src/mock_server/mod.rs b/integrations/localthought/syncables/src/mock_server/mod.rs
new file mode 100644
index 0000000000..68e080ed5e
--- /dev/null
+++ b/integrations/localthought/syncables/src/mock_server/mod.rs
@@ -0,0 +1,6 @@
+//! A mock API server that implements an OpenAPI document, backed by a real
+//! (in-memory) CRUD store per resource, seeded with fake data generated
+//! from the document's schemas.
+
+pub mod server;
+pub mod store;
diff --git a/integrations/localthought/syncables/src/mock_server/server.rs b/integrations/localthought/syncables/src/mock_server/server.rs
new file mode 100644
index 0000000000..2dd754b1b5
--- /dev/null
+++ b/integrations/localthought/syncables/src/mock_server/server.rs
@@ -0,0 +1,91 @@
+//! The mock server: an HTTP request handler generated from an OpenAPI
+//! document.
+//!
+//! For a `GET` operation it first checks whether a pagination scheme
+//! applies (see [`crate::pagination::autodetect`]) and, if so, serves it
+//! as a paginated list; otherwise it treats the match as a resource
+//! ([`ResourceStore`], CRUD semantics based on collection vs. item path
+//! and HTTP method) or falls back to serving the operation's documented
+//! example/generated schema response verbatim. Resource collections are
+//! lazily seeded with [`SEED_COUNT`] fake records on first `GET`.
+//!
+//! # Port status
+//!
+//! Scaffolding. The types, constants and the public surface below are
+//! ported; the request handler itself is not yet implemented.
+
+// The `async` on the stubs below is part of the ported API surface,
+// not an accident of the current `todo!()` bodies.
+#![allow(clippy::unused_async)]
+
+use std::sync::{Arc, Mutex};
+
+use crate::error::Result;
+use crate::openapi::types::OpenApiDocument;
+use crate::resources::discover::{discover_resources, ResourceRoute};
+
+use super::store::ResourceStore;
+
+/// Fake records seeded into a resource collection on its first `GET`.
+pub const SEED_COUNT: usize = 3;
+/// Total fake items generated for a paginated list endpoint, once per path template.
+pub const PAGINATED_TOTAL_COUNT: usize = 7;
+/// Page size used when the request doesn't specify one.
+pub const DEFAULT_PAGE_SIZE: usize = 3;
+
+/// Where a started mock server is listening.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ServerAddress {
+ /// The bound TCP port.
+ pub port: u16,
+ /// The base URL clients should use, e.g. `http://127.0.0.1:8080`.
+ pub url: String,
+}
+
+/// A mock server built from an OpenAPI document.
+#[derive(Clone)]
+pub struct MockServer {
+ document: Arc,
+ resources: Arc>,
+ store: Arc>,
+}
+
+impl MockServer {
+ /// The document this server implements.
+ pub fn document(&self) -> &OpenApiDocument {
+ &self.document
+ }
+
+ /// The resources discovered in the document.
+ pub fn resources(&self) -> &[ResourceRoute] {
+ &self.resources
+ }
+
+ /// The backing CRUD store.
+ pub fn store(&self) -> &Mutex {
+ &self.store
+ }
+
+ /// Binds a port and starts serving.
+ ///
+ /// Pass `None` for `port` to bind an ephemeral one.
+ pub async fn listen(&self, port: Option) -> Result {
+ let _ = port;
+ todo!("port src/mock-server/server.ts: handleRequest and the HTTP listener")
+ }
+
+ /// Stops serving.
+ pub async fn close(&self) -> Result<()> {
+ todo!("port src/mock-server/server.ts: server shutdown")
+ }
+}
+
+/// Builds a mock server for `document`.
+pub fn create_mock_server(document: OpenApiDocument) -> MockServer {
+ let resources = discover_resources(&document.paths);
+ MockServer {
+ document: Arc::new(document),
+ resources: Arc::new(resources),
+ store: Arc::new(Mutex::new(ResourceStore::new())),
+ }
+}
diff --git a/integrations/localthought/syncables/src/mock_server/store.rs b/integrations/localthought/syncables/src/mock_server/store.rs
new file mode 100644
index 0000000000..003c1c3919
--- /dev/null
+++ b/integrations/localthought/syncables/src/mock_server/store.rs
@@ -0,0 +1,89 @@
+//! The mock server's in-memory CRUD store, one collection per resource path.
+
+use std::collections::HashMap;
+use std::time::SystemTime;
+
+use indexmap::IndexMap;
+use serde_json::{Map, Value};
+
+#[derive(Debug, Clone)]
+struct CollectionMeta {
+ version: u64,
+ last_modified: String,
+}
+
+/// One in-memory `Map` per collection path, with CRUD semantics based on
+/// collection vs. item path and HTTP method.
+#[derive(Debug, Default)]
+pub struct ResourceStore {
+ collections: HashMap>>,
+ meta: HashMap,
+}
+
+impl ResourceStore {
+ /// A store with no collections.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Whether `resource`'s collection has been created (seeded) yet.
+ pub fn has(&self, resource: &str) -> bool {
+ self.collections.contains_key(resource)
+ }
+
+ /// Every record in `resource`'s collection, in insertion order.
+ pub fn list(&mut self, resource: &str) -> Vec> {
+ self.collection(resource).values().cloned().collect()
+ }
+
+ /// One record, by id.
+ pub fn get(&mut self, resource: &str, id: &str) -> Option> {
+ self.collection(resource).get(id).cloned()
+ }
+
+ /// Inserts or replaces a record.
+ pub fn put(&mut self, resource: &str, id: &str, value: Map) {
+ self.collection(resource).insert(id.to_string(), value);
+ self.touch(resource);
+ }
+
+ /// Removes a record, reporting whether it existed.
+ pub fn delete(&mut self, resource: &str, id: &str) -> bool {
+ let deleted = self.collection(resource).shift_remove(id).is_some();
+ if deleted {
+ self.touch(resource);
+ }
+ deleted
+ }
+
+ /// Weak ETag for the current state of `resource`'s collection, once it
+ /// has been populated at least once.
+ pub fn etag(&self, resource: &str) -> Option {
+ self.meta
+ .get(resource)
+ .map(|meta| format!("W/\"{}\"", meta.version))
+ }
+
+ /// RFC 7231 HTTP-date of the last mutation to `resource`'s collection,
+ /// if any.
+ pub fn last_modified(&self, resource: &str) -> Option {
+ self.meta
+ .get(resource)
+ .map(|meta| meta.last_modified.clone())
+ }
+
+ fn touch(&mut self, resource: &str) {
+ let version = self.meta.get(resource).map_or(0, |meta| meta.version) + 1;
+ self.meta.insert(
+ resource.to_string(),
+ CollectionMeta {
+ version,
+ last_modified: httpdate::fmt_http_date(SystemTime::now()),
+ },
+ );
+ }
+
+ fn collection(&mut self, resource: &str) -> &mut IndexMap> {
+ self.collections.entry(resource.to_string()).or_default()
+ }
+}
diff --git a/integrations/localthought/syncables/src/openapi/load.rs b/integrations/localthought/syncables/src/openapi/load.rs
new file mode 100644
index 0000000000..7df664c9a3
--- /dev/null
+++ b/integrations/localthought/syncables/src/openapi/load.rs
@@ -0,0 +1,91 @@
+//! Loading an OpenAPI document from a file path or an in-memory value.
+
+use std::path::Path;
+
+use serde_json::Value;
+
+use super::resolve_refs::resolve_refs;
+use super::types::OpenApiDocument;
+use crate::error::{Error, Result};
+
+/// Where a document comes from: a file path, or an already-parsed value.
+///
+/// The TypeScript original takes `string | Record`;
+/// this is the same union, made explicit.
+#[derive(Debug, Clone)]
+pub enum OpenApiSource<'a> {
+ /// A path to a YAML or JSON document on disk.
+ Path(&'a Path),
+ /// An in-memory document.
+ Value(Value),
+}
+
+impl<'a> From<&'a str> for OpenApiSource<'a> {
+ fn from(path: &'a str) -> Self {
+ Self::Path(Path::new(path))
+ }
+}
+
+impl<'a> From<&'a Path> for OpenApiSource<'a> {
+ fn from(path: &'a Path) -> Self {
+ Self::Path(path)
+ }
+}
+
+impl From for OpenApiSource<'_> {
+ fn from(value: Value) -> Self {
+ Self::Value(value)
+ }
+}
+
+/// Loads an OpenAPI document from a JSON/YAML file path or an in-memory
+/// value, and resolves all local `$ref`s.
+///
+/// YAML is a superset of JSON, so the file format does not need to be
+/// detected separately.
+pub async fn load_open_api_document<'a>(
+ source: impl Into> + Send,
+) -> Result {
+ let raw = match source.into() {
+ OpenApiSource::Path(path) => load_yaml_file(path).await?,
+ OpenApiSource::Value(value) => value,
+ };
+ let resolved = resolve_refs(&raw);
+ serde_json::from_value(resolved).map_err(Error::from)
+}
+
+/// Parses YAML (or JSON) text into a [`Value`].
+pub fn parse_yaml(text: &str) -> Result {
+ serde_yaml_ng::from_str(text).map_err(Error::from)
+}
+
+/// Reads and parses a YAML/JSON file, wrapping any i/o or parse failure in
+/// [`Error::FileLoad`] so it names the offending path — a bare
+/// [`std::io::Error`] from a failed read doesn't otherwise mention which
+/// file was missing or unreadable.
+#[cfg(not(target_arch = "wasm32"))]
+pub(crate) async fn load_yaml_file(path: &Path) -> Result {
+ async {
+ let text = tokio::fs::read_to_string(path).await?;
+ parse_yaml(&text)
+ }
+ .await
+ .map_err(|source| Error::FileLoad {
+ path: path.to_path_buf(),
+ source: Box::new(source),
+ })
+}
+
+#[cfg(target_arch = "wasm32")]
+pub(crate) async fn load_yaml_file(path: &Path) -> Result {
+ Err(Error::FileLoad {
+ path: path.to_path_buf(),
+ source: Box::new(
+ std::io::Error::new(
+ std::io::ErrorKind::Unsupported,
+ "Use an in-memory OpenAPI document in the browser",
+ )
+ .into(),
+ ),
+ })
+}
diff --git a/integrations/localthought/syncables/src/openapi/mod.rs b/integrations/localthought/syncables/src/openapi/mod.rs
new file mode 100644
index 0000000000..81904227c8
--- /dev/null
+++ b/integrations/localthought/syncables/src/openapi/mod.rs
@@ -0,0 +1,12 @@
+//! Reading an OpenAPI document and preparing it for everything downstream.
+//!
+//! [`load`] reads a document from a file path or an in-memory value and
+//! passes it through [`resolve_refs`], which inlines all local `#/...`
+//! JSON-pointer `$ref`s. Everything downstream assumes refs are already
+//! resolved; [`types`] holds the minimal OpenAPI type surface actually
+//! used (not a full spec typing).
+
+pub mod load;
+pub mod overlay;
+pub mod resolve_refs;
+pub mod types;
diff --git a/integrations/localthought/syncables/src/openapi/overlay.rs b/integrations/localthought/syncables/src/openapi/overlay.rs
new file mode 100644
index 0000000000..20751b120c
--- /dev/null
+++ b/integrations/localthought/syncables/src/openapi/overlay.rs
@@ -0,0 +1,209 @@
+//! An intentionally minimal [OpenAPI Overlay](https://spec.openapis.org/overlay/v1.0.0.html)
+//! implementation: `update`/`remove` actions against `$`, plain dot-paths
+//! like `$.components`, and quoted bracket segments like
+//! `$.paths['/pets/{petId}'].get` — not the full JSONPath grammar (no
+//! wildcards, filters, or numeric/array indexing).
+
+use std::path::PathBuf;
+
+use serde::{Deserialize, Serialize};
+use serde_json::{Map, Value};
+
+use super::load::{load_open_api_document, load_yaml_file, OpenApiSource};
+use super::types::OpenApiDocument;
+use crate::error::{Error, Result};
+
+/// A single overlay action.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct OverlayAction {
+ /// JSONPath target — `$`, a dot-path such as `$.components`, or a
+ /// dot-path with quoted bracket segments such as
+ /// `$.paths['/pets/{petId}'].get`.
+ pub target: String,
+ /// Object to deep-merge onto the target.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub update: Option>,
+ /// When true, delete the target instead of merging.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub remove: Option,
+}
+
+/// Metadata of an overlay document.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct OverlayInfo {
+ /// Overlay title.
+ pub title: String,
+ /// Overlay version.
+ pub version: String,
+}
+
+/// An OpenAPI Overlay document.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct OverlayDocument {
+ /// Overlay specification version.
+ pub overlay: String,
+ /// Overlay metadata.
+ pub info: OverlayInfo,
+ /// Actions to apply, in order.
+ pub actions: Vec,
+}
+
+/// Loads an OpenAPI Overlay document from a YAML/JSON file path or value.
+pub async fn load_overlay<'a>(
+ source: impl Into> + Send,
+) -> Result {
+ let raw = match source.into() {
+ OpenApiSource::Path(path) => load_yaml_file(path).await?,
+ OpenApiSource::Value(value) => value,
+ };
+ serde_json::from_value(raw).map_err(Error::from)
+}
+
+/// Loads an OpenAPI document and applies a list of Overlays to it, in the
+/// order given — a later overlay may refine what an earlier one added. Each
+/// overlay is loaded from a file path.
+///
+/// Overlays are applied to the document after its own `$ref`s are resolved
+/// (mirroring the TypeScript original's `buildDocumentFrom` in
+/// `src/sync/document.ts` of `localthought/reflector`), so an overlay's
+/// `update` can safely assume there are no refs left to chase.
+pub async fn load_open_api_document_with_overlays<'a>(
+ document: impl Into> + Send,
+ overlay_paths: &[PathBuf],
+) -> Result {
+ let document = load_open_api_document(document).await?;
+ let mut value = serde_json::to_value(document).map_err(Error::from)?;
+ for path in overlay_paths {
+ let overlay = load_overlay(path.as_path()).await?;
+ value = apply_overlay(&value, &overlay)?;
+ }
+ serde_json::from_value(value).map_err(Error::from)
+}
+
+fn deep_merge_value(existing: Option<&Value>, incoming: &Value) -> Value {
+ match (existing, incoming) {
+ (Some(Value::Object(existing)), Value::Object(incoming)) => {
+ let mut merged = existing.clone();
+ for (key, value) in incoming {
+ merged.insert(key.clone(), deep_merge_value(merged.get(key), value));
+ }
+ Value::Object(merged)
+ }
+ _ => incoming.clone(),
+ }
+}
+
+/// Tokenizes the intentionally small subset of Overlay JSONPath targets this
+/// crate supports into property segments: `$` (the document root), a
+/// dot-path like `$.components.schemas.Foo`, and quoted bracket segments
+/// like `$.paths['/repos/{owner}/{repo}/issues'].get` — needed because a
+/// path template contains slashes and braces that a plain `.split('.')`
+/// would mangle. No wildcards, filters, or numeric/array indexing.
+fn parse_target(target: &str) -> Result> {
+ let unsupported = || Error::UnsupportedOverlayTarget(target.to_string());
+
+ if target == "$" {
+ return Ok(Vec::new());
+ }
+ if !target.starts_with('$') {
+ return Err(unsupported());
+ }
+
+ let mut segments = Vec::new();
+ let bytes = target.as_bytes();
+ let mut index = 1;
+ while index < bytes.len() {
+ match bytes[index] {
+ b'.' => {
+ index += 1;
+ let start = index;
+ while index < bytes.len() && bytes[index] != b'.' && bytes[index] != b'[' {
+ index += 1;
+ }
+ if index == start {
+ return Err(unsupported());
+ }
+ segments.push(target[start..index].to_string());
+ }
+ b'[' => {
+ let quote = *bytes.get(index + 1).ok_or_else(unsupported)?;
+ if quote != b'\'' && quote != b'"' {
+ return Err(unsupported());
+ }
+ let key_start = index + 2;
+ let end = target[key_start..]
+ .find(quote as char)
+ .map(|offset| key_start + offset)
+ .ok_or_else(unsupported)?;
+ segments.push(target[key_start..end].to_string());
+ index = end + 1;
+ if bytes.get(index) != Some(&b']') {
+ return Err(unsupported());
+ }
+ index += 1;
+ }
+ _ => return Err(unsupported()),
+ }
+ }
+ Ok(segments)
+}
+
+fn navigate<'v>(
+ root: &'v mut Value,
+ segments: &[String],
+ create_missing: bool,
+) -> Result> {
+ let mut node = root;
+ for segment in segments {
+ let object = node
+ .as_object_mut()
+ .ok_or_else(|| Error::OverlayTargetNotAnObject(segment.clone()))?;
+ if !object.contains_key(segment) {
+ if !create_missing {
+ return Ok(None);
+ }
+ object.insert(segment.clone(), Value::Object(Map::new()));
+ }
+ let child = object
+ .get_mut(segment)
+ .expect("segment inserted or already present");
+ if !child.is_object() {
+ return Err(Error::OverlayTargetNotAnObject(segment.clone()));
+ }
+ node = child;
+ }
+ Ok(Some(node))
+}
+
+/// Applies an overlay to a document, returning a new document.
+///
+/// Supports `update` (deep-merged onto the target) and `remove` actions.
+pub fn apply_overlay(document: &Value, overlay: &OverlayDocument) -> Result {
+ let mut result = document.clone();
+
+ for action in &overlay.actions {
+ let segments = parse_target(&action.target)?;
+ if action.remove == Some(true) {
+ let Some((key, parent_segments)) = segments.split_last() else {
+ return Err(Error::OverlayRemovesRoot);
+ };
+ if let Some(parent) = navigate(&mut result, parent_segments, false)? {
+ if let Some(object) = parent.as_object_mut() {
+ object.shift_remove(key);
+ }
+ }
+ } else if let Some(update) = &action.update {
+ let target = navigate(&mut result, &segments, true)?
+ .expect("navigate with create_missing always yields a node");
+ let object = target
+ .as_object_mut()
+ .ok_or_else(|| Error::OverlayTargetNotAnObject(action.target.clone()))?;
+ for (key, value) in update {
+ let merged = deep_merge_value(object.get(key), value);
+ object.insert(key.clone(), merged);
+ }
+ }
+ }
+
+ Ok(result)
+}
diff --git a/integrations/localthought/syncables/src/openapi/resolve_refs.rs b/integrations/localthought/syncables/src/openapi/resolve_refs.rs
new file mode 100644
index 0000000000..7a9c24a36a
--- /dev/null
+++ b/integrations/localthought/syncables/src/openapi/resolve_refs.rs
@@ -0,0 +1,92 @@
+//! Resolves local `#/...` JSON pointer `$ref`s in place.
+//!
+//! Each `{ "$ref": ... }` node is replaced with the value it points to.
+//! Reused (non-cyclic) targets are resolved once and cached so diamond
+//! references share a result; a target still being resolved when it is
+//! referenced again is a genuine cycle, so that occurrence is left
+//! unresolved to avoid recursing forever. Non-local refs (external files,
+//! URLs) are left unresolved too, since there is nothing in the document
+//! to resolve them against — real-world documents sometimes use these in
+//! vendor extensions unrelated to the schemas this crate cares about.
+
+use std::collections::{HashMap, HashSet};
+
+use serde_json::Value;
+
+/// Resolves every local `$ref` in `document`, returning a new value.
+pub fn resolve_refs(document: &Value) -> Value {
+ let mut resolver = Resolver {
+ root: document,
+ resolving: HashSet::new(),
+ resolved: HashMap::new(),
+ };
+ resolver.walk(document)
+}
+
+/// A JSON pointer identifying a node, used as the cache/cycle key.
+///
+/// The TypeScript original keys its `Set`/`Map` on object *identity*;
+/// `serde_json` values are cloned rather than shared, so the pointer that
+/// reached a node stands in for that identity here.
+type NodeKey = String;
+
+struct Resolver<'a> {
+ root: &'a Value,
+ resolving: HashSet,
+ resolved: HashMap,
+}
+
+impl Resolver<'_> {
+ fn resolve_pointer(&self, reference: &str) -> Option<&Value> {
+ let trimmed = reference.strip_prefix("#/")?;
+ let mut node = self.root;
+ for segment in trimmed.split('/') {
+ let segment = segment.replace("~1", "/").replace("~0", "~");
+ node = node.get(&segment)?;
+ }
+ Some(node)
+ }
+
+ fn walk(&mut self, node: &Value) -> Value {
+ match node {
+ Value::Array(items) => Value::Array(items.iter().map(|i| self.walk(i)).collect()),
+ Value::Object(object) => {
+ if let Some(Value::String(reference)) = object.get("$ref") {
+ return self.walk_ref(node, &reference.clone());
+ }
+ let mut result = serde_json::Map::new();
+ for (key, value) in object {
+ result.insert(key.clone(), self.walk(value));
+ }
+ Value::Object(result)
+ }
+ other => other.clone(),
+ }
+ }
+
+ fn walk_ref(&mut self, node: &Value, reference: &str) -> Value {
+ if !reference.starts_with("#/") {
+ return node.clone();
+ }
+ let Some(target) = self.resolve_pointer(reference) else {
+ return Value::Null;
+ };
+ if !target.is_object() && !target.is_array() {
+ return target.clone();
+ }
+ let key: NodeKey = reference.to_string();
+ if let Some(cached) = self.resolved.get(&key) {
+ return cached.clone();
+ }
+ if self.resolving.contains(&key) {
+ return target.clone();
+ }
+
+ let target = target.clone();
+ self.resolving.insert(key.clone());
+ let result = self.walk(&target);
+ self.resolving.remove(&key);
+ self.resolved.insert(key, result.clone());
+ result
+ }
+}
diff --git a/integrations/localthought/syncables/src/openapi/types.rs b/integrations/localthought/syncables/src/openapi/types.rs
new file mode 100644
index 0000000000..ebdc5c7305
--- /dev/null
+++ b/integrations/localthought/syncables/src/openapi/types.rs
@@ -0,0 +1,250 @@
+//! The minimal OpenAPI type surface this crate actually uses — not a full
+//! spec typing. Every struct keeps an `extensions` catch-all so a document
+//! round-trips through these types without losing vendor extensions
+//! (`x-pagination` in particular is read back out of `OperationObject`).
+
+use indexmap::IndexMap;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use crate::pagination::types::PaginationSchemesMap;
+
+/// A JSON object with insertion order preserved.
+///
+/// Order is load-bearing: [`crate::pagination::items::locate_items_field`]
+/// picks the *first* array-typed property of a response schema.
+pub type JsonMap = IndexMap;
+
+/// A JSON Schema subset, as it appears inside an OpenAPI document.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct SchemaObject {
+ /// `type` keyword (`string`, `integer`, `object`, `array`, ...).
+ #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
+ pub schema_type: Option,
+ /// `format` keyword (`date-time`, `uuid`, `email`, ...).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub format: Option,
+ /// Properties of an object schema, in document order.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub properties: Option>,
+ /// Names of required properties.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub required: Option>,
+ /// Item schema of an array schema.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub items: Option>,
+ /// Allowed values; the first is used when generating fake data.
+ #[serde(rename = "enum", default, skip_serializing_if = "Option::is_none")]
+ pub enum_values: Option>,
+ /// A fixed example, preferred over synthesis when generating fake data.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub example: Option,
+ /// Lower bound for numeric schemas.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub minimum: Option,
+ /// `oneOf` branches.
+ #[serde(rename = "oneOf", default, skip_serializing_if = "Option::is_none")]
+ pub one_of: Option>,
+ /// `anyOf` branches.
+ #[serde(rename = "anyOf", default, skip_serializing_if = "Option::is_none")]
+ pub any_of: Option>,
+ /// `allOf` branches, merged when generating data or listing properties.
+ #[serde(rename = "allOf", default, skip_serializing_if = "Option::is_none")]
+ pub all_of: Option>,
+ /// Any other keyword present on the schema.
+ #[serde(flatten)]
+ pub extensions: JsonMap,
+}
+
+/// One entry of a `content` map, keyed by media type.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct MediaTypeObject {
+ /// Schema of the payload.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub schema: Option,
+ /// A fixed example payload, preferred over the schema when serving mocks.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub example: Option,
+}
+
+/// A documented response for one status code.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct ResponseObject {
+ /// Human-readable description.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub description: Option,
+ /// Response payloads, keyed by media type.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub content: Option>,
+}
+
+/// A documented request body.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct RequestBodyObject {
+ /// Whether the body is required.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub required: Option,
+ /// Request payloads, keyed by media type.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub content: Option>,
+}
+
+/// Where a parameter is carried.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum ParameterLocation {
+ /// Query string parameter.
+ Query,
+ /// Path template variable.
+ Path,
+ /// Request header.
+ Header,
+ /// Cookie.
+ Cookie,
+}
+
+/// A single operation parameter.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ParameterObject {
+ /// Parameter name.
+ pub name: String,
+ /// Where the parameter is carried.
+ #[serde(rename = "in")]
+ pub location: ParameterLocation,
+ /// Whether the parameter is required.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub required: Option,
+ /// Schema of the parameter's value.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub schema: Option,
+ /// Any other key present on the parameter.
+ #[serde(flatten)]
+ pub extensions: JsonMap,
+}
+
+/// One HTTP operation on a path.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct OperationObject {
+ /// `operationId`, when the document declares one.
+ #[serde(
+ rename = "operationId",
+ default,
+ skip_serializing_if = "Option::is_none"
+ )]
+ pub operation_id: Option,
+ /// Declared parameters.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub parameters: Option>,
+ /// Declared request body.
+ #[serde(
+ rename = "requestBody",
+ default,
+ skip_serializing_if = "Option::is_none"
+ )]
+ pub request_body: Option,
+ /// Documented responses, keyed by status code.
+ #[serde(default)]
+ pub responses: IndexMap,
+ /// Any other key on the operation — notably `x-pagination`.
+ #[serde(flatten)]
+ pub extensions: JsonMap,
+}
+
+/// The set of operations declared on one path template.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct PathItem {
+ /// `GET` operation.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub get: Option,
+ /// `PUT` operation.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub put: Option,
+ /// `POST` operation.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub post: Option,
+ /// `PATCH` operation.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub patch: Option,
+ /// `DELETE` operation.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub delete: Option,
+ /// Any other key on the path item.
+ #[serde(flatten)]
+ pub extensions: JsonMap,
+}
+
+impl PathItem {
+ /// The operation declared for `method` (case-insensitive), if any.
+ pub fn operation(&self, method: &str) -> Option<&OperationObject> {
+ match method.to_ascii_uppercase().as_str() {
+ "GET" => self.get.as_ref(),
+ "PUT" => self.put.as_ref(),
+ "POST" => self.post.as_ref(),
+ "PATCH" => self.patch.as_ref(),
+ "DELETE" => self.delete.as_ref(),
+ _ => None,
+ }
+ }
+}
+
+/// Document metadata.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct InfoObject {
+ /// Document title.
+ pub title: String,
+ /// Document version.
+ pub version: String,
+}
+
+/// One entry of the document's top-level `servers` list.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct ServerObject {
+ /// Root URL of the API, e.g. `https://api.github.com`.
+ pub url: String,
+ /// Any other key on the server entry.
+ #[serde(flatten)]
+ pub extensions: JsonMap,
+}
+
+/// The `components` section, narrowed to what this crate reads.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct ComponentsObject {
+ /// Reusable schemas.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub schemas: Option>,
+ /// Pagination schemes, per the OpenAPI Pagination Schemes Extension.
+ #[serde(
+ rename = "paginationSchemes",
+ default,
+ skip_serializing_if = "Option::is_none"
+ )]
+ pub pagination_schemes: Option,
+ /// Any other key under `components`.
+ #[serde(flatten)]
+ pub extensions: JsonMap,
+}
+
+/// An OpenAPI document with all local `$ref`s already resolved.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct OpenApiDocument {
+ /// OpenAPI version string.
+ #[serde(default)]
+ pub openapi: String,
+ /// Document metadata.
+ #[serde(default)]
+ pub info: InfoObject,
+ /// Path templates, in document order.
+ #[serde(default)]
+ pub paths: IndexMap,
+ /// The API's base URL(s). The credential layer targets requests at the
+ /// first entry rather than any URL configured separately, so a document
+ /// can't be pointed at the wrong host by mistake.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub servers: Option>,
+ /// Reusable components.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub components: Option,
+ /// Any other top-level key.
+ #[serde(flatten)]
+ pub extensions: JsonMap,
+}
diff --git a/integrations/localthought/syncables/src/pagination/autodetect.rs b/integrations/localthought/syncables/src/pagination/autodetect.rs
new file mode 100644
index 0000000000..c604c0e98f
--- /dev/null
+++ b/integrations/localthought/syncables/src/pagination/autodetect.rs
@@ -0,0 +1,169 @@
+//! Resolving which pagination scheme applies to an operation.
+
+use std::collections::HashSet;
+
+use indexmap::IndexMap;
+use serde_json::Value;
+
+use super::types::{
+ AutoDetect, AutoDetectObject, PaginationApplicationObject, PaginationSchemeObject,
+};
+use super::validate::validate_pagination_scheme;
+use crate::openapi::types::{OpenApiDocument, OperationObject, ParameterLocation};
+
+/// The scheme that applies to an operation, and the name it is declared under.
+#[derive(Debug, Clone, PartialEq)]
+pub struct EffectiveScheme {
+ /// Key in `components.paginationSchemes`.
+ pub scheme_name: String,
+ /// The scheme itself, with any per-operation overrides merged in.
+ pub scheme: PaginationSchemeObject,
+}
+
+/// Schemes that fail validation (spec §9) are excluded here rather than
+/// thrown on eagerly — one malformed scheme in a document (see e.g.
+/// Giphy's `type: offset`, which isn't a valid scheme type) shouldn't
+/// prevent using the rest of the document or its other schemes.
+fn valid_schemes(document: &OpenApiDocument) -> IndexMap {
+ document
+ .components
+ .as_ref()
+ .and_then(|c| c.pagination_schemes.as_ref())
+ .into_iter()
+ .flatten()
+ .filter(|(name, scheme)| validate_pagination_scheme(name, scheme).is_empty())
+ .map(|(name, scheme)| (name.clone(), scheme.clone()))
+ .collect()
+}
+
+fn query_param_names(operation: &OperationObject) -> HashSet<&str> {
+ operation
+ .parameters
+ .as_deref()
+ .unwrap_or_default()
+ .iter()
+ .filter(|parameter| parameter.location == ParameterLocation::Query)
+ .map(|parameter| parameter.name.as_str())
+ .collect()
+}
+
+fn body_field_names(operation: &OperationObject) -> HashSet<&str> {
+ operation
+ .request_body
+ .as_ref()
+ .and_then(|body| body.content.as_ref())
+ .and_then(|content| content.get("application/json"))
+ .and_then(|media| media.schema.as_ref())
+ .and_then(|schema| schema.properties.as_ref())
+ .into_iter()
+ .flatten()
+ .map(|(name, _)| name.as_str())
+ .collect()
+}
+
+/// Default auto-detection rules (spec §6.2/§6.3): a dimension only
+/// contributes to the match when the scheme actually declares fields for
+/// it — an empty declaration isn't treated as vacuously satisfied, or
+/// every scheme with no query parameters would match every operation.
+fn auto_detect_matches(scheme: &PaginationSchemeObject, operation: &OperationObject) -> bool {
+ let options = match &scheme.auto_detect {
+ Some(AutoDetect::Enabled(false)) => return false,
+ Some(AutoDetect::Options(options)) => (**options).clone(),
+ _ => AutoDetectObject::default(),
+ };
+ let require_all = options.require_all.unwrap_or(true);
+ let mut results: Vec = Vec::new();
+
+ let request = scheme.request.as_ref();
+
+ if options.match_query_params.unwrap_or(true) {
+ let required: Vec<&String> = request
+ .and_then(|r| r.query_parameters.as_ref())
+ .into_iter()
+ .flatten()
+ .map(|(name, _)| name)
+ .collect();
+ if !required.is_empty() {
+ let declared = query_param_names(operation);
+ results.push(required.iter().all(|name| declared.contains(name.as_str())));
+ }
+ }
+
+ if options.match_body_fields.unwrap_or(true) {
+ let required: Vec<&String> = request
+ .and_then(|r| r.body_fields.as_ref())
+ .into_iter()
+ .flatten()
+ .map(|(name, _)| name)
+ .collect();
+ if !required.is_empty() {
+ let declared = body_field_names(operation);
+ results.push(required.iter().all(|name| declared.contains(name.as_str())));
+ }
+ }
+
+ if results.is_empty() {
+ return false;
+ }
+ if require_all {
+ results.iter().all(|matched| *matched)
+ } else {
+ results.iter().any(|matched| *matched)
+ }
+}
+
+fn deep_merge(base: &Value, overrides: &Value) -> Value {
+ match (base, overrides) {
+ (Value::Object(base), Value::Object(overrides)) => {
+ let mut result = base.clone();
+ for (key, value) in overrides {
+ let merged = match result.get(key) {
+ Some(existing) if existing.is_object() && value.is_object() => {
+ deep_merge(existing, value)
+ }
+ _ => value.clone(),
+ };
+ result.insert(key.clone(), merged);
+ }
+ Value::Object(result)
+ }
+ _ => overrides.clone(),
+ }
+}
+
+/// Resolves the pagination scheme that applies to an operation: an
+/// explicit `x-pagination` application (with overrides merged in) takes
+/// priority, falling back to auto-detection against the document's valid
+/// `paginationSchemes`.
+pub fn resolve_effective_scheme(
+ document: &OpenApiDocument,
+ operation: &OperationObject,
+) -> Option {
+ let schemes = valid_schemes(document);
+
+ if let Some(explicit) = operation.extensions.get("x-pagination") {
+ let applications: Vec =
+ serde_json::from_value(explicit.clone()).unwrap_or_default();
+ let application = applications.into_iter().next()?;
+ let base = schemes.get(&application.scheme)?;
+ let scheme = match &application.overrides {
+ Some(overrides) => {
+ let merged = deep_merge(&serde_json::to_value(base).ok()?, overrides);
+ serde_json::from_value(merged).ok()?
+ }
+ None => base.clone(),
+ };
+ return Some(EffectiveScheme {
+ scheme_name: application.scheme,
+ scheme,
+ });
+ }
+
+ schemes
+ .into_iter()
+ .find(|(_, scheme)| auto_detect_matches(scheme, operation))
+ .map(|(scheme_name, scheme)| EffectiveScheme {
+ scheme_name,
+ scheme,
+ })
+}
diff --git a/integrations/localthought/syncables/src/pagination/items.rs b/integrations/localthought/syncables/src/pagination/items.rs
new file mode 100644
index 0000000000..af0f3d14f1
--- /dev/null
+++ b/integrations/localthought/syncables/src/pagination/items.rs
@@ -0,0 +1,91 @@
+//! Locating which response property actually holds the list of items.
+//!
+//! The extension itself only describes pagination metadata, not where
+//! items live, so this excludes whatever fields the scheme claims as
+//! metadata, then picks the remaining array-typed property (falling back
+//! to common envelope names). This is also what makes real enveloped
+//! responses (e.g. `{ data: [...], meta, pagination }`) work at all,
+//! pagination or not.
+
+use std::collections::HashSet;
+
+use indexmap::IndexMap;
+
+use super::types::PaginationSchemeObject;
+use crate::openapi::types::SchemaObject;
+
+/// Field names real-world APIs commonly wrap a list response in, tried
+/// when the response schema itself doesn't unambiguously point at one
+/// array property.
+const COMMON_ITEMS_FIELDS: [&str; 5] = ["items", "data", "results", "records", "content"];
+
+/// Flattens a schema's own properties together with any `allOf` branches'
+/// properties into one map.
+///
+/// Real paginated response schemas often compose a shared "paging" base
+/// schema with a branch that adds the concrete `items` property (e.g.
+/// Spotify's `PagingSimplifiedAlbumObject`).
+pub fn effective_properties(schema: Option<&SchemaObject>) -> IndexMap {
+ let Some(schema) = schema else {
+ return IndexMap::new();
+ };
+ if let Some(all_of) = &schema.all_of {
+ let mut merged = IndexMap::new();
+ for branch in all_of {
+ merged.extend(effective_properties(Some(branch)));
+ }
+ return merged;
+ }
+ schema.properties.clone().unwrap_or_default()
+}
+
+/// The top-level field names a pagination scheme claims for its own metadata.
+fn metadata_field_roots(scheme: Option<&PaginationSchemeObject>) -> HashSet {
+ scheme
+ .and_then(|s| s.response.as_ref())
+ .and_then(|r| r.body_fields.as_ref())
+ .into_iter()
+ .flatten()
+ .map(|(key, _)| key.split('.').next().unwrap_or(key).to_string())
+ .collect()
+}
+
+/// Finds the property in a response schema that holds the actual list of
+/// items: the first array-typed property that isn't claimed by the
+/// pagination scheme as a metadata field, falling back to common
+/// enveloping field names.
+pub fn locate_items_field(
+ schema: Option<&SchemaObject>,
+ scheme: Option<&PaginationSchemeObject>,
+) -> Option {
+ let properties = effective_properties(schema);
+ let excluded = metadata_field_roots(scheme);
+
+ for (name, property_schema) in &properties {
+ if !excluded.contains(name) && property_schema.schema_type.as_deref() == Some("array") {
+ return Some(name.clone());
+ }
+ }
+
+ COMMON_ITEMS_FIELDS
+ .iter()
+ .find(|name| properties.contains_key(**name) && !excluded.contains(**name))
+ .map(|name| (*name).to_string())
+}
+
+/// The schema of a single item, given the schema of the whole (enveloped)
+/// response.
+pub fn item_schema_for(
+ schema: Option<&SchemaObject>,
+ scheme: Option<&PaginationSchemeObject>,
+) -> Option {
+ if let Some(schema) = schema {
+ if schema.schema_type.as_deref() == Some("array") {
+ return schema.items.as_deref().cloned();
+ }
+ }
+ let field = locate_items_field(schema, scheme)?;
+ effective_properties(schema)
+ .get(&field)
+ .and_then(|property| property.items.as_deref().cloned())
+}
diff --git a/integrations/localthought/syncables/src/pagination/mod.rs b/integrations/localthought/syncables/src/pagination/mod.rs
new file mode 100644
index 0000000000..cd61a08926
--- /dev/null
+++ b/integrations/localthought/syncables/src/pagination/mod.rs
@@ -0,0 +1,20 @@
+//! The [OpenAPI Pagination Schemes Extension](https://github.com/pondersource/openapi-pagination-schemes-extension)
+//! (`components.paginationSchemes`), applied to third-party documents via
+//! [OpenAPI Overlays](https://spec.openapis.org/overlay/v1.0.0.html).
+//!
+//! **Pagination is orthogonal to the collection/item resource model.** In
+//! real APIs, the paths that pair into a "resource" (batch-get-by-IDs
+//! style, e.g. Giphy's `/gifs`, Spotify's `/albums`) are often *not* the
+//! paginated ones — real pagination usually lives on separate search/list
+//! endpoints (`/gifs/trending`, `/artists/{id}/albums`) that have no
+//! sibling item path and are therefore invisible to
+//! [`crate::resources::discover`]. So pagination support in both the mock
+//! server and `paginate()` operates on *any* GET operation matched by a
+//! scheme, not just discovered resources.
+
+pub mod autodetect;
+pub mod items;
+pub mod request_builder;
+pub mod response_parser;
+pub mod types;
+pub mod validate;
diff --git a/integrations/localthought/syncables/src/pagination/request_builder.rs b/integrations/localthought/syncables/src/pagination/request_builder.rs
new file mode 100644
index 0000000000..318b247d3e
--- /dev/null
+++ b/integrations/localthought/syncables/src/pagination/request_builder.rs
@@ -0,0 +1,144 @@
+//! Building the query for one page request, and computing the next cursor.
+
+use super::types::{PaginationQuery, PaginationResponseState, PaginationSchemeObject, SchemeType};
+
+/// Hard ceiling on pages walked in one paginated traversal, so a
+/// misconfigured `Link` header — or any response that always claims
+/// another page exists — cannot spin forever. Mirrors
+/// [`crate::client::client::MAX_PAGES`], the equivalent constant for the
+/// crate's existing `ApiClient` surface.
+pub const MAX_PAGES: usize = 50;
+
+/// Where the client is in a paginated traversal, independent of scheme type.
+#[derive(Debug, Clone, Default, PartialEq)]
+pub struct PageCursor {
+ /// Item offset, for offset-based schemes.
+ pub offset: Option,
+ /// Page number, for page-based schemes.
+ pub page: Option,
+ /// Opaque token, for token/cursor-based schemes.
+ pub page_token: Option,
+}
+
+fn fields_with_role(scheme: &PaginationSchemeObject, role: &str) -> Vec {
+ scheme
+ .request
+ .as_ref()
+ .and_then(|request| request.query_parameters.as_ref())
+ .into_iter()
+ .flatten()
+ .filter(|(_, field)| field.role.as_deref() == Some(role))
+ .map(|(name, _)| name.clone())
+ .collect()
+}
+
+/// Builds the query parameters for one page request from the current cursor.
+pub fn build_query(
+ scheme: &PaginationSchemeObject,
+ cursor: &PageCursor,
+ page_size: Option,
+) -> PaginationQuery {
+ let mut query = PaginationQuery::new();
+
+ if let Some(page_size) = page_size {
+ for name in fields_with_role(scheme, "pageSize") {
+ query.insert(name, page_size.to_string());
+ }
+ }
+ for name in fields_with_role(scheme, "offset") {
+ query.insert(name, cursor.offset.unwrap_or(0).to_string());
+ }
+ for name in fields_with_role(scheme, "page") {
+ query.insert(name, cursor.page.unwrap_or(1).to_string());
+ }
+ if let Some(page_token) = &cursor.page_token {
+ for name in fields_with_role(scheme, "pageToken")
+ .into_iter()
+ .chain(fields_with_role(scheme, "cursor"))
+ {
+ query.insert(name, page_token.clone());
+ }
+ }
+
+ query
+}
+
+/// Computes the cursor for the next page from the previous cursor and the
+/// parsed response state.
+///
+/// Returns `None` when there is no next page, or when the scheme type is
+/// `nextLink` — for that type the caller should follow
+/// [`PaginationResponseState::next_link`] directly rather than rebuilding
+/// query parameters.
+pub fn next_cursor(
+ scheme: &PaginationSchemeObject,
+ cursor: &PageCursor,
+ state: &PaginationResponseState,
+ items_returned: u64,
+) -> Option {
+ if !state.has_next_page {
+ return None;
+ }
+
+ match scheme.typed()? {
+ SchemeType::PageToken => state.next_page_token.clone().map(|token| PageCursor {
+ page_token: Some(token),
+ ..PageCursor::default()
+ }),
+ SchemeType::NextLink => None,
+ SchemeType::PageNumber => {
+ if !fields_with_role(scheme, "offset").is_empty() {
+ return Some(PageCursor {
+ offset: Some(cursor.offset.unwrap_or(0) + items_returned),
+ ..PageCursor::default()
+ });
+ }
+ if !fields_with_role(scheme, "page").is_empty() {
+ return Some(PageCursor {
+ page: Some(cursor.page.unwrap_or(1) + 1),
+ ..PageCursor::default()
+ });
+ }
+ None
+ }
+ }
+}
+
+/// What a paginated traversal should do next, after parsing one page's
+/// response.
+#[derive(Debug, Clone, PartialEq)]
+pub enum PageStep {
+ /// Follow this absolute URL directly (a `nextLink` scheme) — built from
+ /// the response's own `Link` header, not rebuilt from the operation's
+ /// path template.
+ FollowLink(String),
+ /// Request the next page with this cursor.
+ NextPage(PageCursor),
+ /// No more pages: the response says so, or [`MAX_PAGES`] was reached.
+ Done,
+}
+
+/// Decides the next step of a paginated traversal, wrapping [`next_cursor`]
+/// with the two cases it deliberately doesn't handle: a `nextLink` scheme
+/// (whose next page is a URL, not a cursor) and the [`MAX_PAGES`] cap.
+///
+/// `pages_fetched` is the number of pages fetched so far, including the one
+/// `state` describes (i.e. after fetching the first page, pass `1`).
+pub fn next_step(
+ scheme: &PaginationSchemeObject,
+ cursor: &PageCursor,
+ state: &PaginationResponseState,
+ items_returned: u64,
+ pages_fetched: usize,
+) -> PageStep {
+ if !state.has_next_page || pages_fetched >= MAX_PAGES {
+ return PageStep::Done;
+ }
+ if scheme.typed() == Some(SchemeType::NextLink) {
+ return state
+ .next_link
+ .clone()
+ .map_or(PageStep::Done, PageStep::FollowLink);
+ }
+ next_cursor(scheme, cursor, state, items_returned).map_or(PageStep::Done, PageStep::NextPage)
+}
diff --git a/integrations/localthought/syncables/src/pagination/response_parser.rs b/integrations/localthought/syncables/src/pagination/response_parser.rs
new file mode 100644
index 0000000000..90e007aa42
--- /dev/null
+++ b/integrations/localthought/syncables/src/pagination/response_parser.rs
@@ -0,0 +1,237 @@
+//! Parsing pagination state back out of a server response.
+
+use indexmap::IndexMap;
+use serde_json::{Map, Value};
+
+use super::types::{PaginationResponseState, PaginationSchemeObject, ResponseRole, SchemeType};
+
+/// Reads a dot-separated path out of a JSON object, e.g. `pagination.total_count`.
+pub fn read_nested_field<'v>(body: &'v Value, path: &str) -> Option<&'v Value> {
+ let mut node = body;
+ for segment in path.split('.') {
+ node = node.as_object()?.get(segment)?;
+ }
+ Some(node)
+}
+
+/// Writes a value at a dot-separated path, creating intermediate objects
+/// as needed.
+pub fn set_nested_field(body: &mut Map, path: &str, value: Value) {
+ let segments: Vec<&str> = path.split('.').collect();
+ let Some((last, parents)) = segments.split_last() else {
+ return;
+ };
+
+ let mut node = body;
+ for segment in parents {
+ let entry = node
+ .entry((*segment).to_string())
+ .or_insert_with(|| Value::Object(Map::new()));
+ if !entry.is_object() {
+ *entry = Value::Object(Map::new());
+ }
+ node = entry.as_object_mut().expect("just ensured object");
+ }
+ node.insert((*last).to_string(), value);
+}
+
+/// Parses an RFC 8288 `Link` header value and extracts the URL with
+/// `rel="next"`.
+///
+/// Example: `; rel="next", <...>; rel="prev"`
+pub fn parse_link_header(header: &str) -> Option {
+ if header.is_empty() {
+ return None;
+ }
+ for part in split_links(header) {
+ let part = part.trim_start();
+ let Some(rest) = part.strip_prefix('<') else {
+ continue;
+ };
+ let Some(end) = rest.find('>') else {
+ continue;
+ };
+ let (url, attrs) = rest.split_at(end);
+ if rel_is_next(&attrs[1..]) {
+ return Some(url.to_string());
+ }
+ }
+ None
+}
+
+/// Splits on commas that begin a new `` link-value, mirroring the
+/// original's `/,\s*(?=<)/` lookahead.
+fn split_links(header: &str) -> Vec<&str> {
+ let bytes = header.as_bytes();
+ let mut parts = Vec::new();
+ let mut start = 0;
+ for (index, byte) in bytes.iter().enumerate() {
+ if *byte != b',' {
+ continue;
+ }
+ let after = header[index + 1..].trim_start();
+ if after.starts_with('<') {
+ parts.push(&header[start..index]);
+ start = index + 1;
+ }
+ }
+ parts.push(&header[start..]);
+ parts
+}
+
+/// Finds `rel=next` (quoted or bare) among a link-value's attributes.
+fn rel_is_next(attrs: &str) -> bool {
+ let lowered = attrs.to_ascii_lowercase();
+ let mut search = lowered.as_str();
+ while let Some(index) = search.find("rel") {
+ let before_is_boundary = index == 0
+ || !search.as_bytes()[index - 1].is_ascii_alphanumeric()
+ && search.as_bytes()[index - 1] != b'_';
+ let after = search[index + 3..].trim_start();
+ if before_is_boundary {
+ if let Some(value) = after.strip_prefix('=') {
+ let value = value.trim_start().trim_start_matches('"');
+ let value = value
+ .split(|c: char| c == '"' || c == ';' || c == ',' || c.is_whitespace())
+ .next()
+ .unwrap_or("");
+ if value == "next" {
+ return true;
+ }
+ }
+ }
+ search = &search[index + 3..];
+ }
+ false
+}
+
+fn to_string_or_none(value: Option<&Value>) -> Option {
+ match value? {
+ Value::Null => None,
+ Value::String(s) if s.is_empty() => None,
+ Value::String(s) => Some(s.clone()),
+ other => Some(other.to_string()),
+ }
+}
+
+fn to_number_or_none(value: Option<&Value>) -> Option {
+ match value? {
+ Value::Number(n) => n.as_f64(),
+ Value::String(s) => s.trim().parse::().ok(),
+ Value::Bool(b) => Some(f64::from(u8::from(*b))),
+ _ => None,
+ }
+}
+
+fn extract_by_role(
+ scheme: &PaginationSchemeObject,
+ body: &Value,
+ headers: &IndexMap,
+) -> IndexMap {
+ let mut roles = IndexMap::new();
+
+ let response = scheme.response.as_ref();
+
+ for (path, field) in response
+ .and_then(|r| r.body_fields.as_ref())
+ .into_iter()
+ .flatten()
+ {
+ let Some(role) = &field.role else { continue };
+ if let Some(value) = read_nested_field(body, path) {
+ roles.insert(role.clone(), value.clone());
+ }
+ }
+
+ for (name, field) in response
+ .and_then(|r| r.headers.as_ref())
+ .into_iter()
+ .flatten()
+ {
+ let Some(role) = &field.role else { continue };
+ let raw = headers
+ .get(name)
+ .or_else(|| headers.get(&name.to_lowercase()))
+ .or_else(|| headers.get(&name.to_uppercase()));
+ let Some(raw) = raw else { continue };
+ if role == "nextLink" {
+ if let Some(parsed) = parse_link_header(raw) {
+ roles.insert("nextLink".to_string(), Value::String(parsed));
+ }
+ } else {
+ roles.insert(role.clone(), Value::String(raw.clone()));
+ }
+ }
+
+ roles
+}
+
+/// A `nextLink`/`nextPageToken` value is a strong, type-agnostic signal
+/// that another page exists — real APIs sometimes include one even on a
+/// scheme whose `type` is `pageNumber` (e.g. Spotify's offset-based
+/// endpoints all carry a `next` URL). Checking it first, ahead of the
+/// type-specific counting rules, means traversal still terminates
+/// correctly for those schemes even without a `currentPage`/`totalPages`
+/// role declared.
+///
+/// `totalCount` (role: `all` per spec §4.5) is checked next against
+/// `items_fetched_so_far`, which the *caller* tracks — some real schemes
+/// (e.g. Giphy's) report `totalCount` and `pageSize` but no `currentPage`
+/// at all, so there's nothing here to compute "current page * pageSize"
+/// from; the client already knows exactly how many items it has pulled
+/// across all pages so far, which is the more direct signal anyway.
+fn derive_has_next_page(
+ scheme_type: Option,
+ state: &PaginationResponseState,
+ items_fetched_so_far: Option,
+) -> bool {
+ if state.next_link.is_some() || state.next_page_token.is_some() {
+ return true;
+ }
+ if let (Some(total_count), Some(fetched)) = (state.total_count, items_fetched_so_far) {
+ #[allow(clippy::cast_precision_loss)]
+ return (fetched as f64) < total_count;
+ }
+ if scheme_type == Some(SchemeType::PageNumber) {
+ if let (Some(current_page), Some(total_pages)) = (state.current_page, state.total_pages) {
+ return current_page < total_pages;
+ }
+ if let (Some(current_page), Some(total_count), Some(page_size)) =
+ (state.current_page, state.total_count, state.page_size)
+ {
+ return current_page * page_size < total_count;
+ }
+ }
+ false
+}
+
+/// Parses a server response into pagination state, per the resolved scheme.
+///
+/// `items_fetched_so_far` — the cumulative item count across all pages
+/// fetched so far, including this one — lets `has_next_page` be derived
+/// from a plain `totalCount` field even when no `currentPage` role is
+/// declared.
+pub fn parse_pagination_state(
+ scheme: &PaginationSchemeObject,
+ body: &Value,
+ headers: &IndexMap,
+ items_fetched_so_far: Option,
+) -> PaginationResponseState {
+ let roles = extract_by_role(scheme, body, headers);
+
+ let mut state = PaginationResponseState {
+ next_page_token: to_string_or_none(
+ roles
+ .get("nextPageToken")
+ .or_else(|| roles.get("nextCursor")),
+ ),
+ next_link: to_string_or_none(roles.get("nextLink")),
+ current_page: to_number_or_none(roles.get("currentPage")),
+ total_count: to_number_or_none(roles.get("totalCount")),
+ total_pages: to_number_or_none(roles.get("totalPages")),
+ page_size: to_number_or_none(roles.get("pageSize")),
+ has_next_page: false,
+ };
+ state.has_next_page = derive_has_next_page(scheme.typed(), &state, items_fetched_so_far);
+ state
+}
diff --git a/integrations/localthought/syncables/src/pagination/types.rs b/integrations/localthought/syncables/src/pagination/types.rs
new file mode 100644
index 0000000000..be15cfad62
--- /dev/null
+++ b/integrations/localthought/syncables/src/pagination/types.rs
@@ -0,0 +1,255 @@
+//! Type definitions for the [OpenAPI Pagination Schemes Extension](https://github.com/pondersource/openapi-pagination-schemes-extension).
+//!
+//! Spec version 0.1.0. Field names, optionality, and enum values are taken
+//! verbatim from the spec so a [`PaginationSchemeObject`] can be lifted
+//! from an OAS document without transformation.
+//!
+//! Roles are modelled as string newtypes rather than closed enums: the
+//! spec allows `x-`-prefixed extension roles alongside the standard ones,
+//! and [`crate::pagination::validate`] is what decides validity, so an
+//! unknown role has to survive deserialization to be reported.
+
+use indexmap::IndexMap;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+/// Which family of pagination a scheme belongs to.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum SchemeType {
+ /// Page- or offset-numbered traversal.
+ #[serde(rename = "pageNumber")]
+ PageNumber,
+ /// Opaque-token traversal.
+ #[serde(rename = "pageToken")]
+ PageToken,
+ /// Follow-the-link traversal.
+ #[serde(rename = "nextLink")]
+ NextLink,
+}
+
+/// The role a request field plays. Spec-defined values are `page`,
+/// `pageSize`, `offset`, `pageToken` and `cursor`.
+pub type RequestRole = String;
+
+/// The role a response field plays. Spec-defined values are
+/// `nextPageToken`, `nextCursor`, `nextLink`, `totalCount`, `totalPages`,
+/// `pageSize` and `currentPage`.
+pub type ResponseRole = String;
+
+/// One declared request field.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct RequestFieldObject {
+ /// Human-readable description.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub description: Option,
+ /// Schema of the field's value.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub schema: Option,
+ /// The field's role in the scheme.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub role: Option,
+ /// Whether the field is required.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub required: Option,
+ /// `x-`-prefixed extension keys.
+ #[serde(flatten)]
+ pub extensions: IndexMap,
+}
+
+/// Request-side fields a scheme declares, grouped by where they travel.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct RequestPaginationFieldsObject {
+ /// Query parameters.
+ #[serde(
+ rename = "queryParameters",
+ default,
+ skip_serializing_if = "Option::is_none"
+ )]
+ pub query_parameters: Option>,
+ /// Request body fields.
+ #[serde(
+ rename = "bodyFields",
+ default,
+ skip_serializing_if = "Option::is_none"
+ )]
+ pub body_fields: Option>,
+ /// Request headers.
+ ///
+ /// Part of the type surface (mirroring the spec); nothing reads it yet.
+ #[serde(
+ rename = "headerFields",
+ default,
+ skip_serializing_if = "Option::is_none"
+ )]
+ pub header_fields: Option>,
+ /// `x-`-prefixed extension keys.
+ #[serde(flatten)]
+ pub extensions: IndexMap,
+}
+
+/// One declared response field.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct ResponseFieldObject {
+ /// Human-readable description.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub description: Option,
+ /// Schema of the field's value.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub schema: Option,
+ /// The field's role in the scheme.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub role: Option,
+ /// `x-`-prefixed extension keys.
+ #[serde(flatten)]
+ pub extensions: IndexMap,
+}
+
+/// Response-side fields a scheme declares, grouped by where they travel.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct ResponsePaginationFieldsObject {
+ /// Response body fields; keys may be dotted paths into nested objects.
+ #[serde(
+ rename = "bodyFields",
+ default,
+ skip_serializing_if = "Option::is_none"
+ )]
+ pub body_fields: Option>,
+ /// Response headers.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub headers: Option>,
+ /// `x-`-prefixed extension keys.
+ #[serde(flatten)]
+ pub extensions: IndexMap,
+}
+
+/// Fine-grained auto-detection options.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct AutoDetectObject {
+ /// Match the scheme's declared query parameters against the operation's.
+ #[serde(
+ rename = "matchQueryParams",
+ default,
+ skip_serializing_if = "Option::is_none"
+ )]
+ pub match_query_params: Option,
+ /// Match the scheme's declared body fields against the operation's.
+ #[serde(
+ rename = "matchBodyFields",
+ default,
+ skip_serializing_if = "Option::is_none"
+ )]
+ pub match_body_fields: Option,
+ /// Part of the type surface (mirroring the spec); nothing reads it yet.
+ #[serde(
+ rename = "matchResponseFields",
+ default,
+ skip_serializing_if = "Option::is_none"
+ )]
+ pub match_response_fields: Option,
+ /// Part of the type surface (mirroring the spec); nothing reads it yet.
+ #[serde(
+ rename = "matchHeaders",
+ default,
+ skip_serializing_if = "Option::is_none"
+ )]
+ pub match_headers: Option,
+ /// Require every considered dimension to match, rather than any.
+ #[serde(
+ rename = "requireAll",
+ default,
+ skip_serializing_if = "Option::is_none"
+ )]
+ pub require_all: Option,
+ /// `x-`-prefixed extension keys.
+ #[serde(flatten)]
+ pub extensions: IndexMap,
+}
+
+/// `autoDetect` is either a plain toggle or a set of options.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum AutoDetect {
+ /// `autoDetect: true` / `autoDetect: false`.
+ Enabled(bool),
+ /// `autoDetect: { ... }`.
+ Options(Box),
+}
+
+/// One entry of `components.paginationSchemes`.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PaginationSchemeObject {
+ /// Which family of pagination this scheme belongs to.
+ ///
+ /// Deserialized leniently: an invalid value (e.g. Giphy's `offset`)
+ /// has to survive parsing so [`crate::pagination::validate`] can
+ /// report it rather than failing the whole document.
+ #[serde(rename = "type")]
+ pub scheme_type: Value,
+ /// Human-readable description.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub description: Option,
+ /// Whether and how the scheme may be auto-detected.
+ #[serde(
+ rename = "autoDetect",
+ default,
+ skip_serializing_if = "Option::is_none"
+ )]
+ pub auto_detect: Option,
+ /// Request-side declared fields.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub request: Option,
+ /// Response-side declared fields.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub response: Option,
+ /// `x-`-prefixed extension keys.
+ #[serde(flatten)]
+ pub extensions: IndexMap,
+}
+
+impl PaginationSchemeObject {
+ /// The scheme's type, when it is one the spec defines.
+ pub fn typed(&self) -> Option {
+ serde_json::from_value(self.scheme_type.clone()).ok()
+ }
+}
+
+/// One entry of an operation's `x-pagination` array.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PaginationApplicationObject {
+ /// Name of the scheme in `components.paginationSchemes`.
+ pub scheme: String,
+ /// Per-operation overrides, deep-merged onto the named scheme.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub overrides: Option,
+ /// Human-readable description.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub description: Option,
+ /// `x-`-prefixed extension keys.
+ #[serde(flatten)]
+ pub extensions: IndexMap,
+}
+
+/// `components.paginationSchemes`, keyed by scheme name.
+pub type PaginationSchemesMap = IndexMap;
+
+/// Everything derivable from a server response about the state of pagination.
+#[derive(Debug, Clone, Default, PartialEq)]
+pub struct PaginationResponseState {
+ /// Token to request the next page with, if the response carried one.
+ pub next_page_token: Option,
+ /// URL of the next page, if the response carried one.
+ pub next_link: Option,
+ /// The page number this response represents.
+ pub current_page: Option,
+ /// Total number of items across all pages.
+ pub total_count: Option,
+ /// Total number of pages.
+ pub total_pages: Option,
+ /// Number of items per page.
+ pub page_size: Option,
+ /// Whether another page exists after this one.
+ pub has_next_page: bool,
+}
+
+/// Query parameters to send for a single page request.
+pub type PaginationQuery = IndexMap;
diff --git a/integrations/localthought/syncables/src/pagination/validate.rs b/integrations/localthought/syncables/src/pagination/validate.rs
new file mode 100644
index 0000000000..faa1ae1b18
--- /dev/null
+++ b/integrations/localthought/syncables/src/pagination/validate.rs
@@ -0,0 +1,112 @@
+//! Validating a pagination scheme against the extension's own rules (§9).
+
+use indexmap::IndexMap;
+
+use super::types::{PaginationSchemeObject, RequestFieldObject, ResponseFieldObject};
+
+const SCHEME_TYPES: [&str; 3] = ["pageNumber", "pageToken", "nextLink"];
+const REQUEST_ROLES: [&str; 5] = ["page", "pageSize", "offset", "pageToken", "cursor"];
+const RESPONSE_ROLES: [&str; 7] = [
+ "nextPageToken",
+ "nextCursor",
+ "nextLink",
+ "totalCount",
+ "totalPages",
+ "pageSize",
+ "currentPage",
+];
+
+fn is_extension_key(key: &str) -> bool {
+ key.starts_with("x-")
+}
+
+fn check_request_roles(
+ errors: &mut Vec,
+ path: &str,
+ section: &str,
+ fields: Option<&IndexMap>,
+) {
+ for (field_name, field) in fields.into_iter().flatten() {
+ if let Some(role) = &field.role {
+ if !is_extension_key(role) && !REQUEST_ROLES.contains(&role.as_str()) {
+ errors.push(format!(
+ "{path}.request.{section}.{field_name}.role is not a valid request role (got \"{role}\")"
+ ));
+ }
+ }
+ }
+}
+
+fn check_response_roles(
+ errors: &mut Vec,
+ path: &str,
+ section: &str,
+ fields: Option<&IndexMap>,
+) {
+ for (field_name, field) in fields.into_iter().flatten() {
+ if let Some(role) = &field.role {
+ if !is_extension_key(role) && !RESPONSE_ROLES.contains(&role.as_str()) {
+ errors.push(format!(
+ "{path}.response.{section}.{field_name}.role is not a valid response role (got \"{role}\")"
+ ));
+ }
+ }
+ }
+}
+
+/// Validates a single scheme against spec section 9.
+///
+/// Returns a list of human-readable errors (each naming the offending
+/// location), empty if the scheme is valid. Schemes with errors are
+/// excluded from auto-detection rather than thrown on — one malformed
+/// scheme in a document shouldn't prevent using the others.
+pub fn validate_pagination_scheme(name: &str, scheme: &PaginationSchemeObject) -> Vec {
+ let mut errors = Vec::new();
+ let path = format!("paginationSchemes.{name}");
+
+ let declared_type = scheme
+ .scheme_type
+ .as_str()
+ .map_or_else(|| scheme.scheme_type.to_string(), str::to_string);
+ if !SCHEME_TYPES.contains(&declared_type.as_str()) {
+ errors.push(format!(
+ "{path}.type must be one of pageNumber, pageToken, or nextLink (got \"{declared_type}\")"
+ ));
+ }
+
+ if scheme.request.is_none() && scheme.response.is_none() {
+ errors.push(format!(
+ "{path} must define at least one of \"request\" or \"response\""
+ ));
+ }
+
+ let request = scheme.request.as_ref();
+ check_request_roles(
+ &mut errors,
+ &path,
+ "queryParameters",
+ request.and_then(|r| r.query_parameters.as_ref()),
+ );
+ check_request_roles(
+ &mut errors,
+ &path,
+ "bodyFields",
+ request.and_then(|r| r.body_fields.as_ref()),
+ );
+
+ let response = scheme.response.as_ref();
+ check_response_roles(
+ &mut errors,
+ &path,
+ "bodyFields",
+ response.and_then(|r| r.body_fields.as_ref()),
+ );
+ check_response_roles(
+ &mut errors,
+ &path,
+ "headers",
+ response.and_then(|r| r.headers.as_ref()),
+ );
+
+ errors
+}
diff --git a/integrations/localthought/syncables/src/resources/discover.rs b/integrations/localthought/syncables/src/resources/discover.rs
new file mode 100644
index 0000000000..44fb66e66c
--- /dev/null
+++ b/integrations/localthought/syncables/src/resources/discover.rs
@@ -0,0 +1,106 @@
+//! Pairing collection paths with their item paths.
+//!
+//! This pairing is the core concept the rest of the crate builds on: a
+//! "resource" only exists where a collection path (`/pets`) has a direct
+//! item-path child (`/pets/{petId}`). Paths without such a pairing
+//! (health checks, one-off actions) are not resources and are handled
+//! separately as raw request/response passthroughs.
+
+use indexmap::IndexMap;
+
+use crate::openapi::types::PathItem;
+
+/// HTTP method a background `update` sends.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum UpdateMethod {
+ /// Full replace.
+ Put,
+ /// Partial update, for APIs (e.g. GitHub) that expose no `PUT`.
+ Patch,
+}
+
+impl UpdateMethod {
+ /// The method name as it goes on the wire.
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Put => "PUT",
+ Self::Patch => "PATCH",
+ }
+ }
+}
+
+/// A collection path paired with its item path.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ResourceRoute {
+ /// The collection path, e.g. `/pets`.
+ pub collection_path: String,
+ /// The item path, e.g. `/pets/{petId}`.
+ pub item_path: String,
+ /// Name of the item path's trailing variable, e.g. `petId`.
+ pub item_param: String,
+ /// HTTP method a background `update` sends.
+ ///
+ /// Derived from the item path's declared operations: `PUT` when the
+ /// item path has a `put` operation (a full replace), otherwise
+ /// `PATCH` when it only offers a partial update.
+ pub update_method: UpdateMethod,
+}
+
+/// Chooses the update method for an item path from the operations it
+/// declares: prefer `PUT` (replace) when present, fall back to `PATCH`
+/// when the path only offers a partial update, and default to `PUT` when
+/// neither is declared.
+fn update_method_for(paths: &IndexMap, item_path: &str) -> UpdateMethod {
+ match paths.get(item_path) {
+ Some(item) if item.put.is_none() && item.patch.is_some() => UpdateMethod::Patch,
+ _ => UpdateMethod::Put,
+ }
+}
+
+/// Pairs each collection path with its item path so mock server and
+/// client can apply CRUD semantics.
+pub fn discover_resources(paths: &IndexMap) -> Vec {
+ let all_paths: Vec<&String> = paths.keys().collect();
+
+ let mut resources = Vec::new();
+ for collection_path in all_paths.iter().filter(|p| !is_item_path(p)) {
+ let item_path = all_paths
+ .iter()
+ .find(|p| is_item_path(p) && is_direct_child(collection_path, p));
+ if let Some(item_path) = item_path {
+ resources.push(ResourceRoute {
+ collection_path: (*collection_path).clone(),
+ item_path: (*item_path).clone(),
+ item_param: extract_param_name(item_path),
+ update_method: update_method_for(paths, item_path),
+ });
+ }
+ }
+ resources
+}
+
+fn is_item_path(path: &str) -> bool {
+ path.split('/')
+ .rfind(|s| !s.is_empty())
+ .is_some_and(|last| last.starts_with('{') && last.ends_with('}'))
+}
+
+fn extract_param_name(item_path: &str) -> String {
+ let last = item_path.split('/').rfind(|s| !s.is_empty()).unwrap_or("");
+ last.get(1..last.len().saturating_sub(1))
+ .unwrap_or("")
+ .to_string()
+}
+
+fn is_direct_child(collection_path: &str, candidate: &str) -> bool {
+ let collection: Vec<&str> = collection_path
+ .split('/')
+ .filter(|s| !s.is_empty())
+ .collect();
+ let candidate: Vec<&str> = candidate.split('/').filter(|s| !s.is_empty()).collect();
+ candidate.len() == collection.len() + 1
+ && collection
+ .iter()
+ .enumerate()
+ .all(|(index, segment)| candidate.get(index) == Some(segment))
+}
diff --git a/integrations/localthought/syncables/src/resources/mod.rs b/integrations/localthought/syncables/src/resources/mod.rs
new file mode 100644
index 0000000000..03d24596b1
--- /dev/null
+++ b/integrations/localthought/syncables/src/resources/mod.rs
@@ -0,0 +1,3 @@
+//! Turning `document.paths` into the list of syncable resources.
+
+pub mod discover;
diff --git a/integrations/localthought/syncables/src/routing/mod.rs b/integrations/localthought/syncables/src/routing/mod.rs
new file mode 100644
index 0000000000..65ca30eb38
--- /dev/null
+++ b/integrations/localthought/syncables/src/routing/mod.rs
@@ -0,0 +1,3 @@
+//! Matching incoming request paths against OpenAPI path templates.
+
+pub mod router;
diff --git a/integrations/localthought/syncables/src/routing/router.rs b/integrations/localthought/syncables/src/routing/router.rs
new file mode 100644
index 0000000000..8ded223da7
--- /dev/null
+++ b/integrations/localthought/syncables/src/routing/router.rs
@@ -0,0 +1,59 @@
+//! Matching a concrete request path against OpenAPI path templates.
+
+use std::borrow::Cow;
+
+use indexmap::IndexMap;
+use percent_encoding::percent_decode_str;
+
+/// A template that matched, together with the path variables it bound.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RouteMatch {
+ /// The OpenAPI path template that matched, e.g. `/pets/{petId}`.
+ pub template: String,
+ /// Path variables bound by the match, percent-decoded.
+ pub params: IndexMap,
+}
+
+fn decode(segment: &str) -> String {
+ match percent_decode_str(segment).decode_utf8() {
+ Ok(Cow::Borrowed(value)) => value.to_string(),
+ Ok(Cow::Owned(value)) => value,
+ Err(_) => segment.to_string(),
+ }
+}
+
+fn match_template(template: &str, actual: &str) -> Option> {
+ let template_segments: Vec<&str> = template.split('/').filter(|s| !s.is_empty()).collect();
+ let actual_segments: Vec<&str> = actual.split('/').filter(|s| !s.is_empty()).collect();
+ if template_segments.len() != actual_segments.len() {
+ return None;
+ }
+
+ let mut params = IndexMap::new();
+ for (index, template_segment) in template_segments.iter().enumerate() {
+ let actual_segment = actual_segments.get(index).copied().unwrap_or("");
+ if let Some(name) = template_segment
+ .strip_prefix('{')
+ .and_then(|s| s.strip_suffix('}'))
+ {
+ params.insert(name.to_string(), decode(actual_segment));
+ } else if *template_segment != actual_segment {
+ return None;
+ }
+ }
+ Some(params)
+}
+
+/// Returns the first template in `templates` that matches `actual`.
+pub fn find_route>(templates: &[S], actual: &str) -> Option {
+ for template in templates {
+ let template = template.as_ref();
+ if let Some(params) = match_template(template, actual) {
+ return Some(RouteMatch {
+ template: template.to_string(),
+ params,
+ });
+ }
+ }
+ None
+}
diff --git a/integrations/localthought/syncables/src/sync/client.rs b/integrations/localthought/syncables/src/sync/client.rs
new file mode 100644
index 0000000000..237cd80021
--- /dev/null
+++ b/integrations/localthought/syncables/src/sync/client.rs
@@ -0,0 +1,520 @@
+//! `SyncClient`: the read half of [issue #9](https://github.com/localthought/syncables-rs/issues/9)
+//! — "the call reflector-rs makes." Loads the document and its overlays
+//! (#2), derives the resource model (#3) and validates the configured
+//! constants (#6), derives and stores the ontology (#8) before any record,
+//! then walks every managed collection — following pagination to the end
+//! (#4) and binding constants (#6) — putting each record into a
+//! host-provided [`Storage`] (#7).
+//!
+//! **Local-first writes are out of scope here.** The issue explicitly
+//! allows that: "Steps beyond \[the full read\] can land later." `create`/
+//! `update`/`remove`, the per-record write queue and retry/backoff, and
+//! reading back `x-crud`'s `addedFields` from a create response are a
+//! separate, later piece of #9.
+//!
+//! `ClientConfig`/`SyncReport`/`SyncError` are copied field-for-field from
+//! the contract [`localthought/reflector-rs`](https://github.com/localthought/reflector-rs)
+//! is already written against, in its `src/syncables.rs`; that module is
+//! meant to be deleted once reflector-rs points its `use`s here instead.
+//! One deliberate divergence: that stub's `ClientConfig` carries no way to
+//! actually reach the network, and `SyncClient::new` takes only a
+//! `ClientConfig`. This crate has no HTTP client dependency (mirroring
+//! [`crate::client::client`]'s existing design, where
+//! [`ApiClientOptions::fetch`](crate::client::client::ApiClientOptions::fetch)
+//! is the host's only extension point for making requests), so
+//! [`SyncClient::new`] additionally takes a [`Fetch`] implementation.
+
+use std::collections::BTreeMap;
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use indexmap::IndexMap;
+use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
+use serde_json::{Map, Value};
+
+use crate::client::client::{Fetch, HttpRequest};
+use crate::error::Error;
+use crate::openapi::overlay::load_open_api_document_with_overlays;
+use crate::openapi::types::{OpenApiDocument, SchemaObject};
+use crate::pagination::autodetect::resolve_effective_scheme;
+use crate::pagination::items::locate_items_field;
+use crate::pagination::request_builder::{build_query, next_step, PageCursor, PageStep};
+use crate::pagination::response_parser::parse_pagination_state;
+use crate::pagination::types::PaginationSchemeObject;
+
+use super::constants::{bind_url, validate_constants};
+use super::credentials::{base_url, Credentials};
+use super::ontology::derive_ontology;
+use super::resource_model::{
+ discover_resource_model, ContextProvider, ManagedCollection, ResourceModel,
+};
+use super::storage::{Record, Storage, StorageError};
+
+/// Everything the engine needs to derive and run a sync.
+#[derive(Clone, Debug)]
+pub struct ClientConfig {
+ /// Path to the OpenAPI document describing the API.
+ pub document: PathBuf,
+ /// Overlays applied to that document, in order.
+ pub overlays: Vec,
+ /// The credential sent to the API.
+ pub credentials: Credentials,
+ /// Values bound into the document's path and query parameters. This is
+ /// what narrows a sync to one issue tracker (`owner`/`repo`) instead of
+ /// every tracker the credential can reach.
+ pub constants: BTreeMap,
+ /// Canonical base URL the derived ontology's terms are minted under,
+ /// e.g. `https://my-ontologies.com`. No trailing slash.
+ pub ontology_base_url: String,
+}
+
+/// What one [`SyncClient::sync`] did.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct SyncReport {
+ /// Records read from the API and written to storage, per resource.
+ pub read: BTreeMap,
+ /// Ontology terms stored.
+ pub ontology_terms: usize,
+ /// Non-fatal problems: one collection failing does not abandon the
+ /// rest of the sync.
+ pub errors: Vec,
+}
+
+/// Errors the engine itself raises.
+#[derive(Debug)]
+pub enum SyncError {
+ /// The document or its overlays could not be loaded or reconciled.
+ Document(String),
+ /// The API rejected or failed a request.
+ Transport(String),
+ /// The [`Storage`] the host supplied failed.
+ Storage(StorageError),
+ /// This build of the engine has no behaviour behind the given call yet.
+ NotImplemented(&'static str),
+}
+
+impl std::fmt::Display for SyncError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ SyncError::Document(message) => write!(f, "OpenAPI document error: {message}"),
+ SyncError::Transport(message) => write!(f, "transport error: {message}"),
+ SyncError::Storage(error) => write!(f, "storage error: {error}"),
+ SyncError::NotImplemented(what) => write!(f, "not implemented yet: {what}"),
+ }
+ }
+}
+
+impl std::error::Error for SyncError {}
+
+impl From for SyncError {
+ /// Every failure this crate's document/overlay/resource-model/ontology
+ /// pipeline raises is, from the engine's point of view, a problem with
+ /// the document or its configuration.
+ fn from(error: Error) -> Self {
+ SyncError::Document(error.to_string())
+ }
+}
+
+/// The sync engine.
+pub struct SyncClient {
+ config: ClientConfig,
+ fetch: Arc,
+}
+
+impl std::fmt::Debug for SyncClient {
+ /// `Fetch` implementations aren't required to be `Debug`, so this
+ /// shows the configuration only.
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("SyncClient")
+ .field("config", &self.config)
+ .finish_non_exhaustive()
+ }
+}
+
+impl SyncClient {
+ /// Builds a client for `config`, reaching the network through `fetch`.
+ ///
+ /// Errors if `config.ontology_base_url` is empty — ontology terms need
+ /// a canonical, resolvable base URL.
+ pub fn new(config: ClientConfig, fetch: Arc) -> Result {
+ if config.ontology_base_url.is_empty() {
+ return Err(SyncError::Document(
+ "ontology_base_url is required: ontology terms need a canonical, resolvable base URL"
+ .to_string(),
+ ));
+ }
+ Ok(SyncClient { config, fetch })
+ }
+
+ /// The configuration this client was built from.
+ #[must_use]
+ pub fn config(&self) -> &ClientConfig {
+ &self.config
+ }
+
+ /// Reads everything the document describes — narrowed by
+ /// [`ClientConfig::constants`] — into `storage`, and stores the
+ /// ontology derived from the document alongside it, before any record.
+ ///
+ /// One collection failing partway through — a 404, an unparseable
+ /// response, a storage error — is recorded in the returned
+ /// [`SyncReport::errors`] and does not abandon the rest of the sync.
+ pub async fn sync(&self, storage: &dyn Storage) -> Result {
+ let document = load_open_api_document_with_overlays(
+ self.config.document.as_path(),
+ &self.config.overlays,
+ )
+ .await?;
+ self.sync_document(&document, storage).await
+ }
+
+ /// Sync an already loaded, resolved document without filesystem access.
+ pub async fn sync_document(
+ &self,
+ document: &OpenApiDocument,
+ storage: &dyn Storage,
+ ) -> Result {
+ let model = discover_resource_model(document)?;
+ validate_constants(document, &model, &self.config.constants)?;
+
+ // Fail before writing anything if there's nowhere to sync from —
+ // no point minting an ontology for a sync that can't run at all.
+ let base = base_url(document)
+ .ok_or_else(|| SyncError::Document("document declares no servers".to_string()))?;
+
+ let ontology = derive_ontology(document)?;
+ storage
+ .put_ontology(&ontology)
+ .await
+ .map_err(SyncError::Storage)?;
+
+ let mut report = SyncReport {
+ ontology_terms: ontology.terms.len(),
+ ..SyncReport::default()
+ };
+ self.walk_all(document, &model, base, storage, &mut report)
+ .await;
+ Ok(report)
+ }
+
+ /// Walks every managed collection in dependency order: a collection
+ /// whose context parameters are all constants runs once; one with a
+ /// parent-record-provided parameter (see
+ /// [`ResourceModel::provider_for`]) runs once per parent record
+ /// already read, its own records feeding any collection nested under
+ /// it in turn.
+ async fn walk_all(
+ &self,
+ document: &OpenApiDocument,
+ model: &ResourceModel,
+ base: &str,
+ storage: &dyn Storage,
+ report: &mut SyncReport,
+ ) {
+ let mut records_by_collection: BTreeMap>> = BTreeMap::new();
+ let mut pending: Vec<&ManagedCollection> = model.collections.iter().collect();
+
+ loop {
+ let mut still_pending = Vec::new();
+ let mut progressed = false;
+
+ for collection in pending {
+ let provider_params: Vec<(&str, &ContextProvider)> = collection
+ .context_params
+ .iter()
+ .filter(|param| !self.config.constants.contains_key(param.as_str()))
+ .filter_map(|param| {
+ model
+ .provider_for(param)
+ .map(|provider| (param.as_str(), provider))
+ })
+ .collect();
+
+ let ready = provider_params
+ .iter()
+ .all(|(_, provider)| records_by_collection.contains_key(&provider.collection));
+ if !ready {
+ still_pending.push(collection);
+ continue;
+ }
+ progressed = true;
+
+ let mut collection_records = Vec::new();
+ for values in binding_combinations(
+ &self.config.constants,
+ &provider_params,
+ &records_by_collection,
+ ) {
+ match self
+ .walk_collection(document, base, collection, &values)
+ .await
+ {
+ Ok(records) => {
+ let namespace = collection
+ .context_params
+ .iter()
+ .map(|param| values.get(param).cloned().unwrap_or_default())
+ .collect::>()
+ .join("/");
+ for record in &records {
+ let id = record
+ .get(&collection.id_field)
+ .map(json_to_string)
+ .unwrap_or_default();
+ let stored = Record {
+ namespace: namespace.clone(),
+ resource: collection.resource.clone(),
+ id,
+ value: record.clone(),
+ };
+ if let Err(error) = storage.put(&stored).await {
+ report.errors.push(format!("{}: {error}", collection.name));
+ }
+ }
+ collection_records.extend(records);
+ }
+ Err(message) => report
+ .errors
+ .push(format!("{}: {message}", collection.name)),
+ }
+ }
+
+ *report.read.entry(collection.resource.clone()).or_insert(0) +=
+ collection_records.len();
+ records_by_collection.insert(collection.name.clone(), collection_records);
+ }
+
+ if still_pending.is_empty() {
+ break;
+ }
+ if !progressed {
+ // Unreachable once `validate_constants` has passed against
+ // the same model, but never spin: report and stop rather
+ // than loop forever if it somehow is.
+ for collection in still_pending {
+ report.errors.push(format!(
+ "{}: could not resolve its context parameters",
+ collection.name
+ ));
+ }
+ break;
+ }
+ pending = still_pending;
+ }
+ }
+
+ /// Fetches every item of one managed collection under `values`,
+ /// walking every page per its resolved pagination scheme.
+ async fn walk_collection(
+ &self,
+ document: &OpenApiDocument,
+ base: &str,
+ collection: &ManagedCollection,
+ values: &BTreeMap,
+ ) -> std::result::Result>, String> {
+ let operation = document
+ .paths
+ .get(&collection.collection_url)
+ .and_then(|item| item.get.as_ref())
+ .ok_or_else(|| format!("{} declares no GET operation", collection.collection_url))?;
+
+ let effective = resolve_effective_scheme(document, operation);
+ let response_schema = operation
+ .responses
+ .get("200")
+ .and_then(|response| response.content.as_ref())
+ .and_then(|content| content.get("application/json"))
+ .and_then(|media| media.schema.as_ref());
+
+ let path =
+ bind_url(&collection.collection_url, values).map_err(|error| error.to_string())?;
+
+ let mut items = Vec::new();
+ let mut cursor = PageCursor::default();
+ let mut pages_fetched = 0usize;
+ let mut next_url: Option = None;
+
+ loop {
+ let request_url = if let Some(url) = next_url.take() {
+ url
+ } else {
+ let mut query = collection.list_query.clone();
+ if let Some(effective) = &effective {
+ for (name, value) in build_query(&effective.scheme, &cursor, None) {
+ query.insert(name, value);
+ }
+ }
+ request_url(base, &path, &query)
+ };
+
+ let mut headers = IndexMap::new();
+ if let Some(authorization) = self.config.credentials.authorization_header() {
+ headers.insert("Authorization".to_string(), authorization);
+ }
+ let diagnostic_url = redacted_request_url(&request_url);
+ let response = self
+ .fetch
+ .fetch(HttpRequest {
+ method: "GET".to_string(),
+ url: request_url,
+ headers,
+ body: None,
+ })
+ .await
+ .map_err(|error| error.to_string())?;
+
+ if !(200..300).contains(&response.status) {
+ return Err(format!(
+ "GET {} responded {}",
+ diagnostic_url, response.status
+ ));
+ }
+
+ let body: Value =
+ serde_json::from_slice(&response.body).map_err(|error| error.to_string())?;
+ let page_items = response_items(
+ response_schema,
+ effective.as_ref().map(|e| &e.scheme),
+ &body,
+ )?;
+ let page_count = u64::try_from(page_items.len()).unwrap_or(u64::MAX);
+ items.extend(page_items);
+ pages_fetched += 1;
+
+ let Some(effective) = &effective else { break };
+ let items_so_far = u64::try_from(items.len()).unwrap_or(u64::MAX);
+ let state = parse_pagination_state(
+ &effective.scheme,
+ &body,
+ &response.headers,
+ Some(items_so_far),
+ );
+ match next_step(
+ &effective.scheme,
+ &cursor,
+ &state,
+ page_count,
+ pages_fetched,
+ ) {
+ PageStep::Done => break,
+ PageStep::NextPage(next) => cursor = next,
+ PageStep::FollowLink(url) => next_url = Some(url),
+ }
+ }
+
+ Ok(items)
+ }
+}
+
+/// Returns a request URL that is useful in an error message without exposing
+/// values sent as query parameters. Authentication normally travels in a
+/// header, but OpenAPI also permits query-parameter API keys.
+fn redacted_request_url(url: &str) -> String {
+ match url.split_once('?') {
+ Some((base, _)) => format!("{base}?"),
+ None => url.to_owned(),
+ }
+}
+
+/// Every combination of constants plus one parent record's value per
+/// `provider_params` entry — the cartesian product across however many
+/// ancestor collections `collection` is nested under. Empty
+/// `provider_params` yields exactly the constants unchanged (a root
+/// collection, walked once); a provider whose collection has no records
+/// yet read yields no combinations at all (nothing to nest under).
+fn binding_combinations(
+ constants: &BTreeMap,
+ provider_params: &[(&str, &ContextProvider)],
+ records_by_collection: &BTreeMap>>,
+) -> Vec> {
+ let mut combinations = vec![constants.clone()];
+ for (param, provider) in provider_params {
+ let empty = Vec::new();
+ let parent_records = records_by_collection
+ .get(&provider.collection)
+ .unwrap_or(&empty);
+ let mut next = Vec::new();
+ for combination in &combinations {
+ for record in parent_records {
+ let Some(value) = record.get(&provider.field) else {
+ continue;
+ };
+ let mut extended = combination.clone();
+ extended.insert((*param).to_string(), json_to_string(value));
+ next.push(extended);
+ }
+ }
+ combinations = next;
+ }
+ combinations
+}
+
+/// The array of item objects in a paginated response: the whole body when
+/// its schema is itself an array, otherwise the property
+/// [`locate_items_field`] finds.
+fn response_items(
+ schema: Option<&SchemaObject>,
+ scheme: Option<&PaginationSchemeObject>,
+ body: &Value,
+) -> std::result::Result>, String> {
+ let is_bare_array = schema.and_then(|s| s.schema_type.as_deref()) == Some("array");
+ let array = if is_bare_array {
+ body.as_array()
+ } else if let Some(field) = locate_items_field(schema, scheme) {
+ body.get(&field).and_then(Value::as_array)
+ } else {
+ body.as_array()
+ };
+ let array =
+ array.ok_or_else(|| "could not locate the items array in the response".to_string())?;
+ Ok(array
+ .iter()
+ .filter_map(|item| item.as_object().cloned())
+ .collect())
+}
+
+/// Renders a JSON scalar as a plain string, for use as a record id or a
+/// namespace segment.
+fn json_to_string(value: &Value) -> String {
+ match value {
+ Value::String(s) => s.clone(),
+ other => other.to_string(),
+ }
+}
+
+/// Characters percent-encoded in a query string component — the reserved
+/// characters that would otherwise be mistaken for delimiters (`&`, `=`,
+/// `#`, `+`), not every non-alphanumeric character: a query parameter name
+/// like `per_page` should round-trip as `per_page`, not `per%5Fpage`.
+const QUERY_COMPONENT: &AsciiSet = &CONTROLS
+ .add(b' ')
+ .add(b'"')
+ .add(b'#')
+ .add(b'%')
+ .add(b'&')
+ .add(b'\'')
+ .add(b'+')
+ .add(b'<')
+ .add(b'>')
+ .add(b'=')
+ .add(b'`');
+
+/// Builds the absolute URL for one page request.
+fn request_url(base: &str, path: &str, query: &IndexMap) -> String {
+ let mut url = format!("{base}{path}");
+ if !query.is_empty() {
+ let pairs: Vec = query
+ .iter()
+ .map(|(name, value)| {
+ format!(
+ "{}={}",
+ utf8_percent_encode(name, QUERY_COMPONENT),
+ utf8_percent_encode(value, QUERY_COMPONENT)
+ )
+ })
+ .collect();
+ url.push('?');
+ url.push_str(&pairs.join("&"));
+ }
+ url
+}
diff --git a/integrations/localthought/syncables/src/sync/constants.rs b/integrations/localthought/syncables/src/sync/constants.rs
new file mode 100644
index 0000000000..0c13c38994
--- /dev/null
+++ b/integrations/localthought/syncables/src/sync/constants.rs
@@ -0,0 +1,129 @@
+//! Binding configured constants into a resource model's path templates —
+//! what scopes a sync to one issue tracker (`owner`/`repo`) instead of
+//! every tracker the credential can reach, per
+//! [issue #6](https://github.com/localthought/syncables-rs/issues/6).
+//!
+//! A GitHub token can read every repository its owner can reach; narrowing
+//! that down has to be part of the sync's configuration, not a filter
+//! applied after the fact — fetching every tracker and discarding most of
+//! it is both slow and a data-handling problem.
+
+use std::collections::{BTreeMap, HashSet};
+
+use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
+
+use crate::error::{Error, Result};
+use crate::openapi::types::OpenApiDocument;
+
+use super::resource_model::{path_variables, ResourceModel};
+
+/// Characters percent-encoded when a value is substituted into a URL path
+/// segment — everything outside what's safe unescaped in a path segment,
+/// mirroring the `url` crate's `PATH_SEGMENT_ENCODE_SET` (not pulled in as
+/// a dependency for one constant).
+const PATH_SEGMENT: &AsciiSet = &CONTROLS
+ .add(b' ')
+ .add(b'"')
+ .add(b'#')
+ .add(b'<')
+ .add(b'>')
+ .add(b'?')
+ .add(b'`')
+ .add(b'{')
+ .add(b'}')
+ .add(b'/')
+ .add(b'%');
+
+/// Every parameter name declared by some operation in the document — path
+/// or query, on any method — the set a constant is allowed to name.
+fn declared_parameter_names(document: &OpenApiDocument) -> HashSet<&str> {
+ document
+ .paths
+ .values()
+ .flat_map(|item| {
+ [
+ item.get.as_ref(),
+ item.put.as_ref(),
+ item.post.as_ref(),
+ item.patch.as_ref(),
+ item.delete.as_ref(),
+ ]
+ })
+ .flatten()
+ .flat_map(|operation| operation.parameters.iter().flatten())
+ .map(|parameter| parameter.name.as_str())
+ .collect()
+}
+
+/// A path variable is resolvable if a constant supplies it, or a parent
+/// record's context provider does.
+fn is_resolvable(model: &ResourceModel, constants: &BTreeMap, param: &str) -> bool {
+ constants.contains_key(param) || model.provider_for(param).is_some()
+}
+
+/// Validates `constants` against `document` and `model`, before any request
+/// is made:
+///
+/// - every constant must name a parameter the document actually declares
+/// ([`Error::UnknownConstant`]) — a typo'd key would otherwise silently
+/// sync nothing, or scope the sync far wider than intended;
+/// - every path variable a managed collection needs — in its list URL, or
+/// in its own item URL beyond what its identity binding already supplies
+/// — must be resolvable by a constant or a parent record's provider
+/// ([`Error::UnboundContextParam`]).
+pub fn validate_constants(
+ document: &OpenApiDocument,
+ model: &ResourceModel,
+ constants: &BTreeMap