@relayfile/adapter-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/README.md +89 -0
  2. package/dist/src/cli.d.ts +3 -0
  3. package/dist/src/cli.d.ts.map +1 -0
  4. package/dist/src/cli.js +430 -0
  5. package/dist/src/cli.js.map +1 -0
  6. package/dist/src/docs/change-detector.d.ts +23 -0
  7. package/dist/src/docs/change-detector.d.ts.map +1 -0
  8. package/dist/src/docs/change-detector.js +126 -0
  9. package/dist/src/docs/change-detector.js.map +1 -0
  10. package/dist/src/docs/crawler.d.ts +22 -0
  11. package/dist/src/docs/crawler.d.ts.map +1 -0
  12. package/dist/src/docs/crawler.js +296 -0
  13. package/dist/src/docs/crawler.js.map +1 -0
  14. package/dist/src/docs/extractor.d.ts +12 -0
  15. package/dist/src/docs/extractor.d.ts.map +1 -0
  16. package/dist/src/docs/extractor.js +549 -0
  17. package/dist/src/docs/extractor.js.map +1 -0
  18. package/dist/src/docs/generator.d.ts +14 -0
  19. package/dist/src/docs/generator.d.ts.map +1 -0
  20. package/dist/src/docs/generator.js +368 -0
  21. package/dist/src/docs/generator.js.map +1 -0
  22. package/dist/src/docs/mapping-generator.d.ts +13 -0
  23. package/dist/src/docs/mapping-generator.d.ts.map +1 -0
  24. package/dist/src/docs/mapping-generator.js +104 -0
  25. package/dist/src/docs/mapping-generator.js.map +1 -0
  26. package/dist/src/docs/types.d.ts +110 -0
  27. package/dist/src/docs/types.d.ts.map +1 -0
  28. package/dist/src/docs/types.js +2 -0
  29. package/dist/src/docs/types.js.map +1 -0
  30. package/dist/src/docs/updater.d.ts +11 -0
  31. package/dist/src/docs/updater.d.ts.map +1 -0
  32. package/dist/src/docs/updater.js +133 -0
  33. package/dist/src/docs/updater.js.map +1 -0
  34. package/dist/src/drift/drift-checker.d.ts +13 -0
  35. package/dist/src/drift/drift-checker.d.ts.map +1 -0
  36. package/dist/src/drift/drift-checker.js +200 -0
  37. package/dist/src/drift/drift-checker.js.map +1 -0
  38. package/dist/src/generate/adapter-generator.d.ts +4 -0
  39. package/dist/src/generate/adapter-generator.d.ts.map +1 -0
  40. package/dist/src/generate/adapter-generator.js +115 -0
  41. package/dist/src/generate/adapter-generator.js.map +1 -0
  42. package/dist/src/generate/types-generator.d.ts +3 -0
  43. package/dist/src/generate/types-generator.d.ts.map +1 -0
  44. package/dist/src/generate/types-generator.js +105 -0
  45. package/dist/src/generate/types-generator.js.map +1 -0
  46. package/dist/src/index.d.ts +22 -0
  47. package/dist/src/index.d.ts.map +1 -0
  48. package/dist/src/index.js +21 -0
  49. package/dist/src/index.js.map +1 -0
  50. package/dist/src/ingest/index.d.ts +4 -0
  51. package/dist/src/ingest/index.d.ts.map +1 -0
  52. package/dist/src/ingest/index.js +34 -0
  53. package/dist/src/ingest/index.js.map +1 -0
  54. package/dist/src/ingest/openapi.d.ts +8 -0
  55. package/dist/src/ingest/openapi.d.ts.map +1 -0
  56. package/dist/src/ingest/openapi.js +187 -0
  57. package/dist/src/ingest/openapi.js.map +1 -0
  58. package/dist/src/ingest/postman.d.ts +3 -0
  59. package/dist/src/ingest/postman.d.ts.map +1 -0
  60. package/dist/src/ingest/postman.js +14 -0
  61. package/dist/src/ingest/postman.js.map +1 -0
  62. package/dist/src/ingest/sample.d.ts +4 -0
  63. package/dist/src/ingest/sample.d.ts.map +1 -0
  64. package/dist/src/ingest/sample.js +72 -0
  65. package/dist/src/ingest/sample.js.map +1 -0
  66. package/dist/src/ingest/shared.d.ts +6 -0
  67. package/dist/src/ingest/shared.d.ts.map +1 -0
  68. package/dist/src/ingest/shared.js +52 -0
  69. package/dist/src/ingest/shared.js.map +1 -0
  70. package/dist/src/ingest/types.d.ts +44 -0
  71. package/dist/src/ingest/types.d.ts.map +1 -0
  72. package/dist/src/ingest/types.js +2 -0
  73. package/dist/src/ingest/types.js.map +1 -0
  74. package/dist/src/runtime/schema-adapter.d.ts +71 -0
  75. package/dist/src/runtime/schema-adapter.d.ts.map +1 -0
  76. package/dist/src/runtime/schema-adapter.js +887 -0
  77. package/dist/src/runtime/schema-adapter.js.map +1 -0
  78. package/dist/src/spec/parser.d.ts +9 -0
  79. package/dist/src/spec/parser.d.ts.map +1 -0
  80. package/dist/src/spec/parser.js +371 -0
  81. package/dist/src/spec/parser.js.map +1 -0
  82. package/dist/src/spec/template.d.ts +8 -0
  83. package/dist/src/spec/template.d.ts.map +1 -0
  84. package/dist/src/spec/template.js +75 -0
  85. package/dist/src/spec/template.js.map +1 -0
  86. package/dist/src/spec/types.d.ts +88 -0
  87. package/dist/src/spec/types.d.ts.map +1 -0
  88. package/dist/src/spec/types.js +2 -0
  89. package/dist/src/spec/types.js.map +1 -0
  90. package/dist/tests/docs/change-detector.test.d.ts +2 -0
  91. package/dist/tests/docs/change-detector.test.d.ts.map +1 -0
  92. package/dist/tests/docs/change-detector.test.js +24 -0
  93. package/dist/tests/docs/change-detector.test.js.map +1 -0
  94. package/dist/tests/docs/crawler.test.d.ts +2 -0
  95. package/dist/tests/docs/crawler.test.d.ts.map +1 -0
  96. package/dist/tests/docs/crawler.test.js +55 -0
  97. package/dist/tests/docs/crawler.test.js.map +1 -0
  98. package/dist/tests/docs/extractor.test.d.ts +2 -0
  99. package/dist/tests/docs/extractor.test.d.ts.map +1 -0
  100. package/dist/tests/docs/extractor.test.js +63 -0
  101. package/dist/tests/docs/extractor.test.js.map +1 -0
  102. package/dist/tests/docs/generator.test.d.ts +2 -0
  103. package/dist/tests/docs/generator.test.d.ts.map +1 -0
  104. package/dist/tests/docs/generator.test.js +46 -0
  105. package/dist/tests/docs/generator.test.js.map +1 -0
  106. package/dist/tests/drift/drift-checker.test.d.ts +2 -0
  107. package/dist/tests/drift/drift-checker.test.d.ts.map +1 -0
  108. package/dist/tests/drift/drift-checker.test.js +59 -0
  109. package/dist/tests/drift/drift-checker.test.js.map +1 -0
  110. package/dist/tests/round-trip/fake-connection.d.ts +29 -0
  111. package/dist/tests/round-trip/fake-connection.d.ts.map +1 -0
  112. package/dist/tests/round-trip/fake-connection.js +174 -0
  113. package/dist/tests/round-trip/fake-connection.js.map +1 -0
  114. package/dist/tests/round-trip/github-pulls.test.d.ts +2 -0
  115. package/dist/tests/round-trip/github-pulls.test.d.ts.map +1 -0
  116. package/dist/tests/round-trip/github-pulls.test.js +12 -0
  117. package/dist/tests/round-trip/github-pulls.test.js.map +1 -0
  118. package/dist/tests/round-trip/harness.d.ts +74 -0
  119. package/dist/tests/round-trip/harness.d.ts.map +1 -0
  120. package/dist/tests/round-trip/harness.js +323 -0
  121. package/dist/tests/round-trip/harness.js.map +1 -0
  122. package/dist/tests/round-trip/vfs-snapshot.d.ts +45 -0
  123. package/dist/tests/round-trip/vfs-snapshot.d.ts.map +1 -0
  124. package/dist/tests/round-trip/vfs-snapshot.js +218 -0
  125. package/dist/tests/round-trip/vfs-snapshot.js.map +1 -0
  126. package/dist/tests/runtime/schema-adapter.sync.test.d.ts +2 -0
  127. package/dist/tests/runtime/schema-adapter.sync.test.d.ts.map +1 -0
  128. package/dist/tests/runtime/schema-adapter.sync.test.js +962 -0
  129. package/dist/tests/runtime/schema-adapter.sync.test.js.map +1 -0
  130. package/dist/tests/runtime/schema-adapter.test.d.ts +2 -0
  131. package/dist/tests/runtime/schema-adapter.test.d.ts.map +1 -0
  132. package/dist/tests/runtime/schema-adapter.test.js +100 -0
  133. package/dist/tests/runtime/schema-adapter.test.js.map +1 -0
  134. package/dist/tests/spec/parser.test.d.ts +2 -0
  135. package/dist/tests/spec/parser.test.d.ts.map +1 -0
  136. package/dist/tests/spec/parser.test.js +248 -0
  137. package/dist/tests/spec/parser.test.js.map +1 -0
  138. package/mappings/github.mapping.yaml +35 -0
  139. package/mappings/slack.mapping.yaml +18 -0
  140. package/package.json +52 -0
@@ -0,0 +1,962 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { SchemaAdapter } from "../../src/runtime/schema-adapter.js";
4
+ const issueCheckpointPath = ".sync-state/github/issues/issues-by-repo-buk3ld.json";
5
+ function createCursorSpec() {
6
+ return {
7
+ adapter: {
8
+ name: "github",
9
+ version: "1.0.0",
10
+ baseUrl: "https://api.github.com",
11
+ source: { openapi: "./openapi.yaml" },
12
+ },
13
+ webhooks: {},
14
+ resources: {
15
+ issues: {
16
+ endpoint: "GET /repos/{owner}/{repo}/issues",
17
+ path: "/github/repos/{{owner}}/{{repo}}/issues/{{id}}.json",
18
+ iterate: true,
19
+ extract: ["id", "title", "status", "updated_at"],
20
+ pagination: {
21
+ strategy: "cursor",
22
+ cursorPath: "paging.next",
23
+ paramName: "after",
24
+ },
25
+ sync: {
26
+ modelName: "issue",
27
+ cursorField: "updated_at",
28
+ checkpointKey: "issues-by-repo",
29
+ },
30
+ },
31
+ },
32
+ };
33
+ }
34
+ function createMemoryClient(initialFiles = {}, onWrite) {
35
+ const files = new Map(Object.entries(initialFiles));
36
+ const writes = [];
37
+ let nextRevision = 1;
38
+ return {
39
+ files,
40
+ writes,
41
+ client: {
42
+ async ingestWebhook() {
43
+ return { status: "queued", id: "q_123" };
44
+ },
45
+ async readFile(_workspaceId, path, _correlationId, signal) {
46
+ throwIfAborted(signal);
47
+ const file = files.get(path);
48
+ if (!file) {
49
+ throw new Error(`No stored file for ${path}`);
50
+ }
51
+ return file;
52
+ },
53
+ async writeFile(input) {
54
+ throwIfAborted(input.signal);
55
+ writes.push(input);
56
+ onWrite?.(input);
57
+ throwIfAborted(input.signal);
58
+ const revision = `rev-${nextRevision}`;
59
+ nextRevision += 1;
60
+ files.set(input.path, {
61
+ content: input.content,
62
+ revision,
63
+ });
64
+ return { revision };
65
+ },
66
+ },
67
+ };
68
+ }
69
+ function createProvider(responses) {
70
+ const calls = [];
71
+ return {
72
+ calls,
73
+ provider: {
74
+ name: "provider",
75
+ async proxy(input) {
76
+ calls.push({
77
+ ...input,
78
+ query: input.query ? { ...input.query } : undefined,
79
+ });
80
+ const response = responses[calls.length - 1];
81
+ if (!response) {
82
+ throw new Error(`Unexpected proxy call ${calls.length}`);
83
+ }
84
+ return response;
85
+ },
86
+ async healthCheck() {
87
+ return true;
88
+ },
89
+ },
90
+ };
91
+ }
92
+ function recordWrites(writes) {
93
+ return writes.filter((write) => !write.path.startsWith(".sync-state/"));
94
+ }
95
+ function checkpointWrites(writes) {
96
+ return writes.filter((write) => write.path.startsWith(".sync-state/"));
97
+ }
98
+ function parseJson(content) {
99
+ return JSON.parse(content);
100
+ }
101
+ function throwIfAborted(signal) {
102
+ if (!signal?.aborted) {
103
+ return;
104
+ }
105
+ const error = new Error("The operation was aborted.");
106
+ error.name = "AbortError";
107
+ throw error;
108
+ }
109
+ test("SchemaAdapter sync paginates provider.proxy responses into workspace files and checkpoints", async () => {
110
+ const { client, writes } = createMemoryClient();
111
+ const { provider, calls } = createProvider([
112
+ {
113
+ status: 200,
114
+ headers: {},
115
+ data: {
116
+ data: [
117
+ {
118
+ id: "1",
119
+ title: "First",
120
+ status: "open",
121
+ updated_at: "2026-01-01T00:00:01.000Z",
122
+ ignored: true,
123
+ },
124
+ {
125
+ id: "2",
126
+ title: "Second",
127
+ status: "triaged",
128
+ updated_at: "2026-01-01T00:00:02.000Z",
129
+ },
130
+ ],
131
+ paging: { next: "cursor-2" },
132
+ },
133
+ },
134
+ {
135
+ status: 200,
136
+ headers: {},
137
+ data: {
138
+ data: [
139
+ {
140
+ id: "3",
141
+ title: "Third",
142
+ status: "closed",
143
+ updated_at: "2026-01-01T00:00:03.000Z",
144
+ },
145
+ ],
146
+ paging: { next: null },
147
+ },
148
+ },
149
+ ]);
150
+ const adapter = new SchemaAdapter({
151
+ client,
152
+ provider,
153
+ spec: createCursorSpec(),
154
+ defaultConnectionId: "conn_default",
155
+ });
156
+ const result = await adapter.sync("ws_123", "issues", {
157
+ input: { owner: "acme", repo: "demo" },
158
+ query: { state: "all" },
159
+ watermark: "2026-01-01T00:00:00.000Z",
160
+ watermarkParamName: "updated_since",
161
+ });
162
+ assert.equal(calls.length, 2);
163
+ assert.deepEqual(calls.map((call) => call.query), [
164
+ {
165
+ state: "all",
166
+ updated_since: "2026-01-01T00:00:00.000Z",
167
+ },
168
+ {
169
+ state: "all",
170
+ updated_since: "2026-01-01T00:00:00.000Z",
171
+ after: "cursor-2",
172
+ },
173
+ ]);
174
+ assert.deepEqual(calls.map((call) => ({
175
+ method: call.method,
176
+ baseUrl: call.baseUrl,
177
+ endpoint: call.endpoint,
178
+ connectionId: call.connectionId,
179
+ })), [
180
+ {
181
+ method: "GET",
182
+ baseUrl: "https://api.github.com",
183
+ endpoint: "/repos/acme/demo/issues",
184
+ connectionId: "conn_default",
185
+ },
186
+ {
187
+ method: "GET",
188
+ baseUrl: "https://api.github.com",
189
+ endpoint: "/repos/acme/demo/issues",
190
+ connectionId: "conn_default",
191
+ },
192
+ ]);
193
+ const syncedFiles = recordWrites(writes);
194
+ assert.deepEqual(syncedFiles.map((write) => ({
195
+ workspaceId: write.workspaceId,
196
+ path: write.path,
197
+ baseRevision: write.baseRevision,
198
+ contentType: write.contentType,
199
+ encoding: write.encoding,
200
+ })), [
201
+ {
202
+ workspaceId: "ws_123",
203
+ path: "/github/repos/acme/demo/issues/1.json",
204
+ baseRevision: "0",
205
+ contentType: "application/json",
206
+ encoding: "utf-8",
207
+ },
208
+ {
209
+ workspaceId: "ws_123",
210
+ path: "/github/repos/acme/demo/issues/2.json",
211
+ baseRevision: "0",
212
+ contentType: "application/json",
213
+ encoding: "utf-8",
214
+ },
215
+ {
216
+ workspaceId: "ws_123",
217
+ path: "/github/repos/acme/demo/issues/3.json",
218
+ baseRevision: "0",
219
+ contentType: "application/json",
220
+ encoding: "utf-8",
221
+ },
222
+ ]);
223
+ assert.equal(syncedFiles[0]?.content, `${JSON.stringify({
224
+ id: "1",
225
+ title: "First",
226
+ status: "open",
227
+ updated_at: "2026-01-01T00:00:01.000Z",
228
+ }, null, 2)}\n`);
229
+ assert.deepEqual(syncedFiles[0]?.semantics, {
230
+ properties: {
231
+ provider: "github",
232
+ "provider.object_type": "issue",
233
+ "provider.object_id": "1",
234
+ "provider.status": "open",
235
+ },
236
+ });
237
+ const checkpoints = checkpointWrites(writes).map((write) => parseJson(write.content));
238
+ assert.equal(checkpoints.length, 2);
239
+ assert.deepEqual({
240
+ adapter: checkpoints[0]?.adapter,
241
+ resourceName: checkpoints[0]?.resourceName,
242
+ checkpointKey: checkpoints[0]?.checkpointKey,
243
+ cursor: checkpoints[0]?.cursor,
244
+ nextCursor: checkpoints[0]?.nextCursor,
245
+ watermark: checkpoints[0]?.watermark,
246
+ pagesSynced: checkpoints[0]?.pagesSynced,
247
+ recordsSynced: checkpoints[0]?.recordsSynced,
248
+ }, {
249
+ adapter: "github",
250
+ resourceName: "issues",
251
+ checkpointKey: "issues-by-repo",
252
+ cursor: "cursor-2",
253
+ nextCursor: "cursor-2",
254
+ watermark: "2026-01-01T00:00:02.000Z",
255
+ pagesSynced: 1,
256
+ recordsSynced: 2,
257
+ });
258
+ assert.deepEqual({
259
+ adapter: checkpoints[1]?.adapter,
260
+ resourceName: checkpoints[1]?.resourceName,
261
+ checkpointKey: checkpoints[1]?.checkpointKey,
262
+ nextCursor: checkpoints[1]?.nextCursor,
263
+ watermark: checkpoints[1]?.watermark,
264
+ pagesSynced: checkpoints[1]?.pagesSynced,
265
+ recordsSynced: checkpoints[1]?.recordsSynced,
266
+ }, {
267
+ adapter: "github",
268
+ resourceName: "issues",
269
+ checkpointKey: "issues-by-repo",
270
+ nextCursor: null,
271
+ watermark: "2026-01-01T00:00:03.000Z",
272
+ pagesSynced: 2,
273
+ recordsSynced: 3,
274
+ });
275
+ assert.equal(Object.hasOwn(checkpoints[1] ?? {}, "cursor"), false);
276
+ assert.deepEqual(result, {
277
+ filesWritten: 3,
278
+ filesUpdated: 0,
279
+ filesDeleted: 0,
280
+ paths: [
281
+ "/github/repos/acme/demo/issues/1.json",
282
+ "/github/repos/acme/demo/issues/2.json",
283
+ "/github/repos/acme/demo/issues/3.json",
284
+ ],
285
+ cursor: undefined,
286
+ nextCursor: null,
287
+ syncedObjectTypes: ["issue"],
288
+ errors: [],
289
+ });
290
+ });
291
+ test("SchemaAdapter sync resumes from a stored checkpoint and rewrites it with the stored revision", async () => {
292
+ const { client, writes } = createMemoryClient({
293
+ [issueCheckpointPath]: {
294
+ revision: "rev-checkpoint-existing",
295
+ content: `${JSON.stringify({
296
+ adapter: "github",
297
+ resourceName: "issues",
298
+ checkpointKey: "issues-by-repo",
299
+ cursor: "cursor-resume",
300
+ nextCursor: "cursor-resume",
301
+ watermark: "2026-02-01T00:00:00.000Z",
302
+ pagesSynced: 7,
303
+ recordsSynced: 14,
304
+ }, null, 2)}\n`,
305
+ },
306
+ });
307
+ const { provider, calls } = createProvider([
308
+ {
309
+ status: 200,
310
+ headers: {},
311
+ data: {
312
+ data: [
313
+ {
314
+ id: "9",
315
+ title: "Resumed",
316
+ status: "closed",
317
+ updated_at: "2026-02-01T00:00:05.000Z",
318
+ },
319
+ ],
320
+ paging: { next: null },
321
+ },
322
+ },
323
+ ]);
324
+ const adapter = new SchemaAdapter({
325
+ client,
326
+ provider,
327
+ spec: createCursorSpec(),
328
+ defaultConnectionId: "conn_default",
329
+ });
330
+ const result = await adapter.sync("issues", {
331
+ workspaceId: "ws_123",
332
+ input: { owner: "acme", repo: "demo" },
333
+ connectionId: "conn_resume",
334
+ });
335
+ assert.equal(calls.length, 1);
336
+ assert.deepEqual(calls[0]?.query, {
337
+ since: "2026-02-01T00:00:00.000Z",
338
+ after: "cursor-resume",
339
+ });
340
+ assert.equal(calls[0]?.connectionId, "conn_resume");
341
+ const checkpoints = checkpointWrites(writes);
342
+ assert.equal(checkpoints.length, 1);
343
+ assert.equal(checkpoints[0]?.path, issueCheckpointPath);
344
+ assert.equal(checkpoints[0]?.baseRevision, "rev-checkpoint-existing");
345
+ assert.deepEqual({
346
+ nextCursor: parseJson(checkpoints[0]?.content ?? "{}").nextCursor,
347
+ watermark: parseJson(checkpoints[0]?.content ?? "{}").watermark,
348
+ pagesSynced: parseJson(checkpoints[0]?.content ?? "{}").pagesSynced,
349
+ recordsSynced: parseJson(checkpoints[0]?.content ?? "{}").recordsSynced,
350
+ }, {
351
+ nextCursor: null,
352
+ watermark: "2026-02-01T00:00:05.000Z",
353
+ pagesSynced: 8,
354
+ recordsSynced: 15,
355
+ });
356
+ assert.deepEqual(result, {
357
+ filesWritten: 1,
358
+ filesUpdated: 0,
359
+ filesDeleted: 0,
360
+ paths: ["/github/repos/acme/demo/issues/9.json"],
361
+ cursor: "cursor-resume",
362
+ nextCursor: null,
363
+ syncedObjectTypes: ["issue"],
364
+ errors: [],
365
+ });
366
+ });
367
+ test("SchemaAdapter sync stops deterministically at maxPages while preserving the next cursor", async () => {
368
+ const { client, writes } = createMemoryClient();
369
+ const { provider, calls } = createProvider([
370
+ {
371
+ status: 200,
372
+ headers: {},
373
+ data: {
374
+ items: [
375
+ { id: "lin_1", title: "One", updatedAt: 10 },
376
+ { id: "lin_2", title: "Two", updatedAt: 20 },
377
+ ],
378
+ },
379
+ },
380
+ {
381
+ status: 200,
382
+ headers: {},
383
+ data: {
384
+ items: [{ id: "lin_3", title: "Three", updatedAt: 30 }],
385
+ },
386
+ },
387
+ ]);
388
+ const adapter = new SchemaAdapter({
389
+ client,
390
+ provider,
391
+ spec: {
392
+ adapter: {
393
+ name: "linear",
394
+ version: "1.0.0",
395
+ baseUrl: "https://api.linear.app",
396
+ source: { openapi: "./openapi.yaml" },
397
+ },
398
+ webhooks: {},
399
+ resources: {
400
+ tickets: {
401
+ endpoint: "GET /teams/{teamId}/issues",
402
+ path: "/linear/teams/{{teamId}}/issues/{{id}}.json",
403
+ iterate: true,
404
+ extract: ["id", "title", "updatedAt"],
405
+ pagination: {
406
+ strategy: "page",
407
+ paramName: "page",
408
+ limitParamName: "per_page",
409
+ pageSize: 2,
410
+ },
411
+ sync: {
412
+ modelName: "ticket",
413
+ cursorField: "updatedAt",
414
+ },
415
+ },
416
+ },
417
+ },
418
+ defaultConnectionId: "conn_linear",
419
+ });
420
+ const result = await adapter.sync("ws_123", "tickets", {
421
+ input: { teamId: "eng" },
422
+ maxPages: 1,
423
+ });
424
+ assert.equal(calls.length, 1);
425
+ assert.deepEqual(calls[0]?.query, {
426
+ page: "1",
427
+ per_page: "2",
428
+ });
429
+ assert.deepEqual(recordWrites(writes).map((write) => write.path), [
430
+ "/linear/teams/eng/issues/lin_1.json",
431
+ "/linear/teams/eng/issues/lin_2.json",
432
+ ]);
433
+ const checkpoint = parseJson(checkpointWrites(writes)[0]?.content ?? "{}");
434
+ assert.deepEqual({
435
+ cursor: checkpoint.cursor,
436
+ nextCursor: checkpoint.nextCursor,
437
+ watermark: checkpoint.watermark,
438
+ pagesSynced: checkpoint.pagesSynced,
439
+ recordsSynced: checkpoint.recordsSynced,
440
+ }, {
441
+ cursor: "2",
442
+ nextCursor: "2",
443
+ watermark: "20",
444
+ pagesSynced: 1,
445
+ recordsSynced: 2,
446
+ });
447
+ assert.deepEqual(result, {
448
+ filesWritten: 2,
449
+ filesUpdated: 0,
450
+ filesDeleted: 0,
451
+ paths: [
452
+ "/linear/teams/eng/issues/lin_1.json",
453
+ "/linear/teams/eng/issues/lin_2.json",
454
+ ],
455
+ cursor: undefined,
456
+ nextCursor: "2",
457
+ syncedObjectTypes: ["ticket"],
458
+ errors: [],
459
+ });
460
+ });
461
+ test("SchemaAdapter sync scopes checkpoints by resource input", async () => {
462
+ const { client, writes } = createMemoryClient();
463
+ const { provider } = createProvider([
464
+ {
465
+ status: 200,
466
+ headers: {},
467
+ data: { data: [{ id: "1", title: "First", updated_at: "2026-01-01" }] },
468
+ },
469
+ {
470
+ status: 200,
471
+ headers: {},
472
+ data: { data: [{ id: "2", title: "Second", updated_at: "2026-01-02" }] },
473
+ },
474
+ ]);
475
+ const adapter = new SchemaAdapter({
476
+ client,
477
+ provider,
478
+ spec: createCursorSpec(),
479
+ defaultConnectionId: "conn_default",
480
+ });
481
+ await adapter.sync("ws_123", "issues", {
482
+ input: { owner: "acme", repo: "demo" },
483
+ });
484
+ await adapter.sync("ws_123", "issues", {
485
+ input: { owner: "octo", repo: "demo" },
486
+ });
487
+ const paths = checkpointWrites(writes).map((write) => write.path);
488
+ assert.equal(paths.length, 2);
489
+ assert.equal(new Set(paths).size, 2);
490
+ assert.ok(paths.every((path) => path.startsWith(".sync-state/github/issues/issues-by-repo-")));
491
+ });
492
+ test("SchemaAdapter sync withholds checkpoint advancement when a record write fails", async () => {
493
+ const { client, writes } = createMemoryClient({}, (input) => {
494
+ if (input.path === "/github/repos/acme/demo/issues/2.json") {
495
+ throw new Error("write failed");
496
+ }
497
+ });
498
+ const { provider } = createProvider([
499
+ {
500
+ status: 200,
501
+ headers: {},
502
+ data: {
503
+ data: [
504
+ {
505
+ id: "1",
506
+ title: "First",
507
+ status: "open",
508
+ updated_at: "2026-01-01T00:00:01.000Z",
509
+ },
510
+ {
511
+ id: "2",
512
+ title: "Second",
513
+ status: "open",
514
+ updated_at: "2026-01-01T00:00:02.000Z",
515
+ },
516
+ ],
517
+ paging: { next: "cursor-2" },
518
+ },
519
+ },
520
+ ]);
521
+ const adapter = new SchemaAdapter({
522
+ client,
523
+ provider,
524
+ spec: createCursorSpec(),
525
+ defaultConnectionId: "conn_default",
526
+ });
527
+ const result = await adapter.sync("ws_123", "issues", {
528
+ input: { owner: "acme", repo: "demo" },
529
+ });
530
+ assert.equal(result.filesWritten, 1);
531
+ assert.equal(result.errors.length, 1);
532
+ assert.equal(result.errors[0]?.path, "/github/repos/acme/demo/issues/2.json");
533
+ assert.equal(checkpointWrites(writes).length, 0);
534
+ });
535
+ test("SchemaAdapter sync stops link-header pagination on repeated next links", async () => {
536
+ const repeatedTarget = "https://api.example.com/items?page=2";
537
+ const repeatedNext = `<${repeatedTarget}>; rel="next"`;
538
+ const { client, writes } = createMemoryClient();
539
+ const { provider, calls } = createProvider([
540
+ {
541
+ status: 200,
542
+ headers: { link: repeatedNext },
543
+ data: { items: [{ id: "1", updatedAt: 1 }] },
544
+ },
545
+ {
546
+ status: 200,
547
+ headers: { link: repeatedNext },
548
+ data: { items: [{ id: "2", updatedAt: 2 }] },
549
+ },
550
+ ]);
551
+ const adapter = new SchemaAdapter({
552
+ client,
553
+ provider,
554
+ spec: {
555
+ adapter: {
556
+ name: "example",
557
+ version: "1.0.0",
558
+ baseUrl: "https://api.example.com",
559
+ source: { openapi: "./openapi.yaml" },
560
+ },
561
+ webhooks: {},
562
+ resources: {
563
+ items: {
564
+ endpoint: "GET /items",
565
+ path: "/example/items/{{id}}.json",
566
+ iterate: true,
567
+ pagination: { strategy: "link-header" },
568
+ sync: { modelName: "item", cursorField: "updatedAt" },
569
+ },
570
+ },
571
+ },
572
+ defaultConnectionId: "conn_default",
573
+ });
574
+ const result = await adapter.sync("ws_123", "items");
575
+ assert.equal(calls.length, 2);
576
+ assert.deepEqual(calls.map((call) => ({ endpoint: call.endpoint, query: call.query })), [
577
+ { endpoint: "/items", query: undefined },
578
+ { endpoint: "/items", query: { page: "2" } },
579
+ ]);
580
+ assert.equal(result.filesWritten, 1);
581
+ assert.equal(result.nextCursor, repeatedTarget);
582
+ const syncedFiles = recordWrites(writes);
583
+ assert.deepEqual(syncedFiles.map((write) => write.path), ["/example/items/1.json"]);
584
+ assert.equal(syncedFiles.some((write) => write.path === "/example/items/2.json"), false);
585
+ const checkpoints = checkpointWrites(writes);
586
+ assert.equal(checkpoints.length, 1);
587
+ assert.deepEqual({
588
+ nextCursor: parseJson(checkpoints[0]?.content ?? "{}").nextCursor,
589
+ watermark: parseJson(checkpoints[0]?.content ?? "{}").watermark,
590
+ pagesSynced: parseJson(checkpoints[0]?.content ?? "{}").pagesSynced,
591
+ recordsSynced: parseJson(checkpoints[0]?.content ?? "{}").recordsSynced,
592
+ }, {
593
+ nextCursor: repeatedTarget,
594
+ watermark: "1",
595
+ pagesSynced: 1,
596
+ recordsSynced: 1,
597
+ });
598
+ assert.deepEqual(result.errors, [
599
+ {
600
+ objectType: "item",
601
+ error: `Pagination stalled for items: repeated link-header target ${repeatedTarget}.`,
602
+ },
603
+ ]);
604
+ });
605
+ test("SchemaAdapter sync stops page pagination without an effective page size", async () => {
606
+ const { client } = createMemoryClient();
607
+ const { provider, calls } = createProvider([
608
+ {
609
+ status: 200,
610
+ headers: {},
611
+ data: { items: [{ id: "1", updatedAt: 1 }, { id: "2", updatedAt: 2 }] },
612
+ },
613
+ {
614
+ status: 200,
615
+ headers: {},
616
+ data: { items: [{ id: "3", updatedAt: 3 }, { id: "4", updatedAt: 4 }] },
617
+ },
618
+ ]);
619
+ const adapter = new SchemaAdapter({
620
+ client,
621
+ provider,
622
+ spec: {
623
+ adapter: {
624
+ name: "linear",
625
+ version: "1.0.0",
626
+ baseUrl: "https://api.linear.app",
627
+ source: { openapi: "./openapi.yaml" },
628
+ },
629
+ webhooks: {},
630
+ resources: {
631
+ tickets: {
632
+ endpoint: "GET /teams/{teamId}/issues",
633
+ path: "/linear/teams/{{teamId}}/issues/{{id}}.json",
634
+ iterate: true,
635
+ pagination: {
636
+ strategy: "page",
637
+ paramName: "page",
638
+ },
639
+ sync: { modelName: "ticket", cursorField: "updatedAt" },
640
+ },
641
+ },
642
+ },
643
+ defaultConnectionId: "conn_linear",
644
+ });
645
+ const result = await adapter.sync("ws_123", "tickets", {
646
+ input: { teamId: "eng" },
647
+ });
648
+ assert.equal(calls.length, 1);
649
+ assert.deepEqual(calls[0]?.query, { page: "1" });
650
+ assert.equal(result.filesWritten, 2);
651
+ assert.equal(result.nextCursor, null);
652
+ });
653
+ test("SchemaAdapter sync rejects before provider calls when already aborted", async () => {
654
+ const controller = new AbortController();
655
+ controller.abort();
656
+ const { client } = createMemoryClient();
657
+ const { provider, calls } = createProvider([
658
+ { status: 200, headers: {}, data: { data: [] } },
659
+ ]);
660
+ const adapter = new SchemaAdapter({
661
+ client,
662
+ provider,
663
+ spec: createCursorSpec(),
664
+ defaultConnectionId: "conn_default",
665
+ });
666
+ await assert.rejects(() => adapter.sync("ws_123", "issues", {
667
+ input: { owner: "acme", repo: "demo" },
668
+ signal: controller.signal,
669
+ }), (error) => error instanceof Error && error.name === "AbortError");
670
+ assert.equal(calls.length, 0);
671
+ });
672
+ test("SchemaAdapter sync passes AbortSignal to provider.proxy and propagates provider aborts", async () => {
673
+ const controller = new AbortController();
674
+ const { client } = createMemoryClient();
675
+ const calls = [];
676
+ const abortError = new Error("provider aborted");
677
+ abortError.name = "AbortError";
678
+ const adapter = new SchemaAdapter({
679
+ client,
680
+ provider: {
681
+ name: "provider",
682
+ async proxy(input) {
683
+ calls.push(input);
684
+ throw abortError;
685
+ },
686
+ async healthCheck() {
687
+ return true;
688
+ },
689
+ },
690
+ spec: createCursorSpec(),
691
+ defaultConnectionId: "conn_default",
692
+ });
693
+ await assert.rejects(() => adapter.sync("ws_123", "issues", {
694
+ input: { owner: "acme", repo: "demo" },
695
+ signal: controller.signal,
696
+ }), (error) => error === abortError);
697
+ assert.equal(calls.length, 1);
698
+ assert.equal(calls[0]?.signal, controller.signal);
699
+ });
700
+ test("SchemaAdapter sync propagates AbortSignal during checkpoint writes", async () => {
701
+ const controller = new AbortController();
702
+ const { client, writes } = createMemoryClient({}, (input) => {
703
+ if (input.path.startsWith(".sync-state/")) {
704
+ controller.abort();
705
+ }
706
+ });
707
+ const { provider } = createProvider([
708
+ {
709
+ status: 200,
710
+ headers: {},
711
+ data: {
712
+ data: [
713
+ {
714
+ id: "1",
715
+ title: "Before checkpoint abort",
716
+ updated_at: "2026-04-01T00:00:01.000Z",
717
+ },
718
+ ],
719
+ paging: { next: null },
720
+ },
721
+ },
722
+ ]);
723
+ const adapter = new SchemaAdapter({
724
+ client,
725
+ provider,
726
+ spec: createCursorSpec(),
727
+ defaultConnectionId: "conn_default",
728
+ });
729
+ await assert.rejects(() => adapter.sync("ws_123", "issues", {
730
+ input: { owner: "acme", repo: "demo" },
731
+ signal: controller.signal,
732
+ }), (error) => error instanceof Error && error.name === "AbortError");
733
+ assert.equal(recordWrites(writes).length, 1);
734
+ assert.equal(checkpointWrites(writes).length, 1);
735
+ });
736
+ test("SchemaAdapter sync honors AbortSignal before writing a later checkpoint", async () => {
737
+ const controller = new AbortController();
738
+ const { client, writes } = createMemoryClient({}, (input) => {
739
+ if (input.path === "/github/repos/acme/demo/issues/2.json") {
740
+ controller.abort();
741
+ }
742
+ });
743
+ const { provider, calls } = createProvider([
744
+ {
745
+ status: 200,
746
+ headers: {},
747
+ data: {
748
+ data: [
749
+ {
750
+ id: "1",
751
+ title: "Before abort",
752
+ status: "open",
753
+ updated_at: "2026-03-01T00:00:01.000Z",
754
+ },
755
+ ],
756
+ paging: { next: "cursor-2" },
757
+ },
758
+ },
759
+ {
760
+ status: 200,
761
+ headers: {},
762
+ data: {
763
+ data: [
764
+ {
765
+ id: "2",
766
+ title: "Abort after write",
767
+ status: "open",
768
+ updated_at: "2026-03-01T00:00:02.000Z",
769
+ },
770
+ ],
771
+ paging: { next: null },
772
+ },
773
+ },
774
+ ]);
775
+ const adapter = new SchemaAdapter({
776
+ client,
777
+ provider,
778
+ spec: createCursorSpec(),
779
+ defaultConnectionId: "conn_default",
780
+ });
781
+ await assert.rejects(() => adapter.sync("ws_123", "issues", {
782
+ input: { owner: "acme", repo: "demo" },
783
+ signal: controller.signal,
784
+ }), (error) => error instanceof Error && error.name === "AbortError");
785
+ assert.equal(calls.length, 2);
786
+ assert.deepEqual(recordWrites(writes).map((write) => write.path), [
787
+ "/github/repos/acme/demo/issues/1.json",
788
+ "/github/repos/acme/demo/issues/2.json",
789
+ ]);
790
+ const checkpoints = checkpointWrites(writes);
791
+ assert.equal(checkpoints.length, 1);
792
+ assert.deepEqual({
793
+ path: checkpoints[0]?.path,
794
+ nextCursor: parseJson(checkpoints[0]?.content ?? "{}").nextCursor,
795
+ watermark: parseJson(checkpoints[0]?.content ?? "{}").watermark,
796
+ }, {
797
+ path: issueCheckpointPath,
798
+ nextCursor: "cursor-2",
799
+ watermark: "2026-03-01T00:00:01.000Z",
800
+ });
801
+ });
802
+ test("SchemaAdapter sync stops offset pagination when provider repeats full page data", async () => {
803
+ const { client, writes } = createMemoryClient();
804
+ const { provider, calls } = createProvider([
805
+ {
806
+ status: 200,
807
+ headers: {},
808
+ data: {
809
+ items: [
810
+ { id: "offset_1", title: "One", updatedAt: 10 },
811
+ { id: "offset_2", title: "Two", updatedAt: 20 },
812
+ ],
813
+ },
814
+ },
815
+ {
816
+ status: 200,
817
+ headers: {},
818
+ data: {
819
+ items: [
820
+ { id: "offset_1", title: "One", updatedAt: 10 },
821
+ { id: "offset_2", title: "Two", updatedAt: 20 },
822
+ ],
823
+ },
824
+ },
825
+ ]);
826
+ const adapter = new SchemaAdapter({
827
+ client,
828
+ provider,
829
+ spec: {
830
+ adapter: {
831
+ name: "linear",
832
+ version: "1.0.0",
833
+ baseUrl: "https://api.linear.app",
834
+ source: { openapi: "./openapi.yaml" },
835
+ },
836
+ webhooks: {},
837
+ resources: {
838
+ tickets: {
839
+ endpoint: "GET /teams/{teamId}/issues",
840
+ path: "/linear/teams/{{teamId}}/issues/{{id}}.json",
841
+ iterate: true,
842
+ pagination: {
843
+ strategy: "offset",
844
+ paramName: "offset",
845
+ limitParamName: "limit",
846
+ pageSize: 2,
847
+ },
848
+ sync: { modelName: "ticket", cursorField: "updatedAt" },
849
+ },
850
+ },
851
+ },
852
+ defaultConnectionId: "conn_linear",
853
+ });
854
+ const result = await adapter.sync("ws_123", "tickets", {
855
+ input: { teamId: "eng" },
856
+ });
857
+ assert.equal(calls.length, 2);
858
+ assert.deepEqual(calls.map((call) => call.query), [
859
+ { offset: "0", limit: "2" },
860
+ { offset: "2", limit: "2" },
861
+ ]);
862
+ assert.deepEqual(recordWrites(writes).map((write) => write.path), [
863
+ "/linear/teams/eng/issues/offset_1.json",
864
+ "/linear/teams/eng/issues/offset_2.json",
865
+ ]);
866
+ assert.equal(checkpointWrites(writes).length, 1);
867
+ assert.deepEqual({
868
+ filesWritten: result.filesWritten,
869
+ nextCursor: result.nextCursor,
870
+ errors: result.errors,
871
+ }, {
872
+ filesWritten: 2,
873
+ nextCursor: "2",
874
+ errors: [
875
+ {
876
+ objectType: "ticket",
877
+ error: "Pagination stalled for tickets: repeated page data.",
878
+ },
879
+ ],
880
+ });
881
+ });
882
+ test("SchemaAdapter sync stops page pagination when provider repeats full page data", async () => {
883
+ const { client, writes } = createMemoryClient();
884
+ const { provider, calls } = createProvider([
885
+ {
886
+ status: 200,
887
+ headers: {},
888
+ data: {
889
+ items: [
890
+ { id: "page_1", title: "One", updatedAt: 10 },
891
+ { id: "page_2", title: "Two", updatedAt: 20 },
892
+ ],
893
+ },
894
+ },
895
+ {
896
+ status: 200,
897
+ headers: {},
898
+ data: {
899
+ items: [
900
+ { id: "page_1", title: "One", updatedAt: 10 },
901
+ { id: "page_2", title: "Two", updatedAt: 20 },
902
+ ],
903
+ },
904
+ },
905
+ ]);
906
+ const adapter = new SchemaAdapter({
907
+ client,
908
+ provider,
909
+ spec: {
910
+ adapter: {
911
+ name: "linear",
912
+ version: "1.0.0",
913
+ baseUrl: "https://api.linear.app",
914
+ source: { openapi: "./openapi.yaml" },
915
+ },
916
+ webhooks: {},
917
+ resources: {
918
+ tickets: {
919
+ endpoint: "GET /teams/{teamId}/issues",
920
+ path: "/linear/teams/{{teamId}}/issues/{{id}}.json",
921
+ iterate: true,
922
+ pagination: {
923
+ strategy: "page",
924
+ paramName: "page",
925
+ limitParamName: "per_page",
926
+ pageSize: 2,
927
+ },
928
+ sync: { modelName: "ticket", cursorField: "updatedAt" },
929
+ },
930
+ },
931
+ },
932
+ defaultConnectionId: "conn_linear",
933
+ });
934
+ const result = await adapter.sync("ws_123", "tickets", {
935
+ input: { teamId: "eng" },
936
+ });
937
+ assert.equal(calls.length, 2);
938
+ assert.deepEqual(calls.map((call) => call.query), [
939
+ { page: "1", per_page: "2" },
940
+ { page: "2", per_page: "2" },
941
+ ]);
942
+ assert.deepEqual(recordWrites(writes).map((write) => write.path), [
943
+ "/linear/teams/eng/issues/page_1.json",
944
+ "/linear/teams/eng/issues/page_2.json",
945
+ ]);
946
+ assert.equal(checkpointWrites(writes).length, 1);
947
+ assert.deepEqual({
948
+ filesWritten: result.filesWritten,
949
+ nextCursor: result.nextCursor,
950
+ errors: result.errors,
951
+ }, {
952
+ filesWritten: 2,
953
+ nextCursor: "2",
954
+ errors: [
955
+ {
956
+ objectType: "ticket",
957
+ error: "Pagination stalled for tickets: repeated page data.",
958
+ },
959
+ ],
960
+ });
961
+ });
962
+ //# sourceMappingURL=schema-adapter.sync.test.js.map