@relayfile/core 0.7.12 → 0.7.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/storage.d.ts +5 -0
- package/dist/writeback.d.ts +37 -1
- package/dist/writeback.deadletter.test.d.ts +1 -0
- package/dist/writeback.deadletter.test.js +64 -0
- package/dist/writeback.js +189 -0
- package/dist/writeback.list.test.d.ts +1 -0
- package/dist/writeback.list.test.js +208 -0
- package/dist/writeback.schemaref.test.d.ts +1 -0
- package/dist/writeback.schemaref.test.js +51 -0
- package/package.json +1 -1
package/dist/storage.d.ts
CHANGED
|
@@ -105,6 +105,11 @@ export interface StorageAdapter {
|
|
|
105
105
|
nextEventId(): string;
|
|
106
106
|
enqueueWriteback(item: WritebackItem): void;
|
|
107
107
|
getPendingWritebacks(): WritebackItem[];
|
|
108
|
+
/**
|
|
109
|
+
* Optional: return the schema identifier the daemon validates this
|
|
110
|
+
* writeback resource against. Return null if no schema is registered.
|
|
111
|
+
*/
|
|
112
|
+
getWritebackSchemaId?(provider: string, resource: string): string | null;
|
|
108
113
|
getEnvelopeByDelivery?(workspaceId: string, provider: string, deliveryId: string): EnvelopeRow | null;
|
|
109
114
|
putEnvelope?(envelope: EnvelopeRow): void;
|
|
110
115
|
putEnvelopeDeliveryAlias?(workspaceId: string, provider: string, deliveryId: string, envelopeId: string): void;
|
package/dist/writeback.d.ts
CHANGED
|
@@ -7,14 +7,50 @@
|
|
|
7
7
|
* - dead-letter handling
|
|
8
8
|
* - retry logic
|
|
9
9
|
*/
|
|
10
|
-
import type { StorageAdapter, WritebackItem } from "./storage.js";
|
|
10
|
+
import type { Paginated, StorageAdapter, WritebackItem } from "./storage.js";
|
|
11
11
|
export declare const MAX_WRITEBACK_ATTEMPTS = 3;
|
|
12
12
|
export declare const WRITEBACK_RETRY_DELAY_MS = 30000;
|
|
13
|
+
export type WritebackListState = "pending" | "succeeded" | "failed" | "dead_lettered";
|
|
14
|
+
export interface WritebackListItem extends WritebackItem {
|
|
15
|
+
state: WritebackListState;
|
|
16
|
+
attemptCount: number;
|
|
17
|
+
lastError: string | null;
|
|
18
|
+
nextAttemptAt: string | null;
|
|
19
|
+
/** Provider slug, copied from OperationRow.provider. */
|
|
20
|
+
provider: string;
|
|
21
|
+
}
|
|
22
|
+
export interface ListWritebacksOptions {
|
|
23
|
+
state: WritebackListState;
|
|
24
|
+
cursor?: string;
|
|
25
|
+
limit?: number;
|
|
26
|
+
}
|
|
27
|
+
export type DeadLetterErrorCode = "schema_violation" | "provider_4xx" | "provider_5xx_exhausted" | "timeout";
|
|
28
|
+
export interface DeadLetterError {
|
|
29
|
+
code: DeadLetterErrorCode;
|
|
30
|
+
message: string;
|
|
31
|
+
providerStatus?: number;
|
|
32
|
+
providerResponse?: Record<string, unknown> | unknown[];
|
|
33
|
+
attempts: number;
|
|
34
|
+
firstAttemptAt: string;
|
|
35
|
+
lastAttemptAt: string;
|
|
36
|
+
opId: string;
|
|
37
|
+
}
|
|
38
|
+
export interface WritebackResourceSchemaRef {
|
|
39
|
+
/** Provider slug, e.g. "linear". */
|
|
40
|
+
provider: string;
|
|
41
|
+
/** Resource slug, e.g. "comments". */
|
|
42
|
+
resource: string;
|
|
43
|
+
/** Identifier the daemon uses to load the schema. */
|
|
44
|
+
schemaId: string;
|
|
45
|
+
}
|
|
13
46
|
export interface DispatchWritebackCallbacks {
|
|
14
47
|
send(item: WritebackItem): void;
|
|
15
48
|
onRetryScheduled?(item: WritebackItem, nextAttemptAt: string): void;
|
|
16
49
|
now?(): string;
|
|
17
50
|
}
|
|
51
|
+
export declare function listWritebacks(storage: StorageAdapter, options: ListWritebacksOptions): Paginated<WritebackListItem>;
|
|
52
|
+
export declare function normalizeDeadLetterError(input: unknown): DeadLetterError;
|
|
53
|
+
export declare function resolveWritebackSchemaRef(storage: StorageAdapter, writebackPath: string): WritebackResourceSchemaRef | null;
|
|
18
54
|
export declare function getPendingWritebacks(storage: StorageAdapter): WritebackItem[];
|
|
19
55
|
export declare function acknowledgeWriteback(storage: StorageAdapter, itemId: string, success: boolean, errorMsg?: string, correlationId?: string, now?: () => string): {
|
|
20
56
|
status: string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { normalizeDeadLetterError, } from "./writeback.js";
|
|
3
|
+
describe("normalizeDeadLetterError", () => {
|
|
4
|
+
it("round-trips a canonical dead-letter error", () => {
|
|
5
|
+
const input = {
|
|
6
|
+
code: "schema_violation",
|
|
7
|
+
message: "body must include event",
|
|
8
|
+
providerStatus: 422,
|
|
9
|
+
providerResponse: { error: "missing event" },
|
|
10
|
+
attempts: 4,
|
|
11
|
+
firstAttemptAt: "2026-05-13T10:00:00.000Z",
|
|
12
|
+
lastAttemptAt: "2026-05-13T10:05:00.000Z",
|
|
13
|
+
opId: "op_dead",
|
|
14
|
+
};
|
|
15
|
+
const normalized = normalizeDeadLetterError(input);
|
|
16
|
+
expect(normalized).toEqual(input);
|
|
17
|
+
expect(Object.isFrozen(normalized)).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
it("rejects unknown code values", () => {
|
|
20
|
+
expect(() => normalizeDeadLetterError({
|
|
21
|
+
...canonicalInput(),
|
|
22
|
+
code: "provider_unknown",
|
|
23
|
+
})).toThrow(TypeError);
|
|
24
|
+
});
|
|
25
|
+
it("trims message whitespace", () => {
|
|
26
|
+
expect(normalizeDeadLetterError({
|
|
27
|
+
...canonicalInput(),
|
|
28
|
+
message: " timed out ",
|
|
29
|
+
}).message).toBe("timed out");
|
|
30
|
+
});
|
|
31
|
+
it("rejects missing required fields", () => {
|
|
32
|
+
for (const field of ["firstAttemptAt", "lastAttemptAt", "opId"]) {
|
|
33
|
+
const input = canonicalInput();
|
|
34
|
+
delete input[field];
|
|
35
|
+
expect(() => normalizeDeadLetterError(input)).toThrow(TypeError);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
it("accepts optional provider status and object response", () => {
|
|
39
|
+
expect(normalizeDeadLetterError({
|
|
40
|
+
...canonicalInput(),
|
|
41
|
+
providerStatus: 504,
|
|
42
|
+
providerResponse: { retryable: true },
|
|
43
|
+
})).toMatchObject({
|
|
44
|
+
providerStatus: 504,
|
|
45
|
+
providerResponse: { retryable: true },
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
it("accepts array provider responses matching the canonical schema", () => {
|
|
49
|
+
expect(normalizeDeadLetterError({
|
|
50
|
+
...canonicalInput(),
|
|
51
|
+
providerResponse: [{ error: "bad request" }],
|
|
52
|
+
}).providerResponse).toEqual([{ error: "bad request" }]);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
function canonicalInput() {
|
|
56
|
+
return {
|
|
57
|
+
code: "provider_5xx_exhausted",
|
|
58
|
+
message: "upstream failed",
|
|
59
|
+
attempts: 3,
|
|
60
|
+
firstAttemptAt: "2026-05-13T10:00:00.000Z",
|
|
61
|
+
lastAttemptAt: "2026-05-13T10:05:00.000Z",
|
|
62
|
+
opId: "op_123",
|
|
63
|
+
};
|
|
64
|
+
}
|
package/dist/writeback.js
CHANGED
|
@@ -10,6 +10,78 @@
|
|
|
10
10
|
import { createEvent } from "./events.js";
|
|
11
11
|
export const MAX_WRITEBACK_ATTEMPTS = 3;
|
|
12
12
|
export const WRITEBACK_RETRY_DELAY_MS = 30_000;
|
|
13
|
+
export function listWritebacks(storage, options) {
|
|
14
|
+
const state = normalizeWritebackListState(options.state);
|
|
15
|
+
const limit = clampLimit(options.limit);
|
|
16
|
+
const sortedItems = listAllOperationsForState(storage, state)
|
|
17
|
+
.filter((op) => op.status === state)
|
|
18
|
+
.map((op) => toWritebackListItem(storage, op, state))
|
|
19
|
+
.sort(compareWritebackListItems);
|
|
20
|
+
let start = 0;
|
|
21
|
+
if (options.cursor) {
|
|
22
|
+
const cursorIndex = sortedItems.findIndex((item) => item.id === options.cursor);
|
|
23
|
+
if (cursorIndex < 0) {
|
|
24
|
+
return { items: [], nextCursor: null };
|
|
25
|
+
}
|
|
26
|
+
start = cursorIndex + 1;
|
|
27
|
+
}
|
|
28
|
+
const items = sortedItems.slice(start, start + limit);
|
|
29
|
+
return {
|
|
30
|
+
items,
|
|
31
|
+
nextCursor: start + limit < sortedItems.length
|
|
32
|
+
? (items[items.length - 1]?.id ?? null)
|
|
33
|
+
: null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function normalizeDeadLetterError(input) {
|
|
37
|
+
if (!isRecord(input)) {
|
|
38
|
+
throw new TypeError("dead-letter error must be an object");
|
|
39
|
+
}
|
|
40
|
+
const code = normalizeDeadLetterErrorCode(input.code);
|
|
41
|
+
const message = requiredString(input.message, "message").trim();
|
|
42
|
+
const attempts = requiredPositiveInteger(input.attempts, "attempts");
|
|
43
|
+
const firstAttemptAt = requiredIsoTimestamp(input.firstAttemptAt, "firstAttemptAt");
|
|
44
|
+
const lastAttemptAt = requiredIsoTimestamp(input.lastAttemptAt, "lastAttemptAt");
|
|
45
|
+
const opId = requiredString(input.opId, "opId").trim();
|
|
46
|
+
if (!message) {
|
|
47
|
+
throw new TypeError("dead-letter error message is required");
|
|
48
|
+
}
|
|
49
|
+
if (!opId) {
|
|
50
|
+
throw new TypeError("dead-letter error opId is required");
|
|
51
|
+
}
|
|
52
|
+
const normalized = {
|
|
53
|
+
code,
|
|
54
|
+
message,
|
|
55
|
+
attempts,
|
|
56
|
+
firstAttemptAt,
|
|
57
|
+
lastAttemptAt,
|
|
58
|
+
opId,
|
|
59
|
+
};
|
|
60
|
+
if (input.providerStatus !== undefined) {
|
|
61
|
+
normalized.providerStatus = optionalProviderStatus(input.providerStatus);
|
|
62
|
+
}
|
|
63
|
+
if (input.providerResponse !== undefined) {
|
|
64
|
+
normalized.providerResponse = optionalProviderResponse(input.providerResponse);
|
|
65
|
+
}
|
|
66
|
+
// Keep this shape aligned with relayfile/schemas/relay/dead-letter-error.schema.json.
|
|
67
|
+
return Object.freeze(normalized);
|
|
68
|
+
}
|
|
69
|
+
export function resolveWritebackSchemaRef(storage, writebackPath) {
|
|
70
|
+
const parsed = parseWritebackPath(writebackPath);
|
|
71
|
+
if (!parsed) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const registeredSchemaId = storage.getWritebackSchemaId?.(parsed.provider, parsed.resource);
|
|
75
|
+
if (registeredSchemaId === null) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
provider: parsed.provider,
|
|
80
|
+
resource: parsed.resource,
|
|
81
|
+
schemaId: registeredSchemaId?.trim() ||
|
|
82
|
+
`${parsed.provider}/${parsed.resource}.schema.json`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
13
85
|
export function getPendingWritebacks(storage) {
|
|
14
86
|
const items = [];
|
|
15
87
|
let cursor;
|
|
@@ -124,6 +196,36 @@ function normalizePath(path) {
|
|
|
124
196
|
const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
125
197
|
return prefixed.length > 1 ? prefixed.replace(/\/+$/, "") : "/";
|
|
126
198
|
}
|
|
199
|
+
function normalizeWritebackListState(state) {
|
|
200
|
+
if (state === "pending" ||
|
|
201
|
+
state === "succeeded" ||
|
|
202
|
+
state === "failed" ||
|
|
203
|
+
state === "dead_lettered") {
|
|
204
|
+
return state;
|
|
205
|
+
}
|
|
206
|
+
throw new TypeError("writeback state must be pending, succeeded, failed, or dead_lettered");
|
|
207
|
+
}
|
|
208
|
+
function clampLimit(limit) {
|
|
209
|
+
return Math.max(1, Math.min(limit ?? 100, 1000));
|
|
210
|
+
}
|
|
211
|
+
function listAllOperationsForState(storage, state) {
|
|
212
|
+
const operations = [];
|
|
213
|
+
let cursor;
|
|
214
|
+
do {
|
|
215
|
+
const page = storage.listOperations({
|
|
216
|
+
status: state,
|
|
217
|
+
cursor,
|
|
218
|
+
limit: 1000,
|
|
219
|
+
});
|
|
220
|
+
operations.push(...page.items);
|
|
221
|
+
const nextCursor = page.nextCursor ?? undefined;
|
|
222
|
+
if (!nextCursor || nextCursor === cursor) {
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
cursor = nextCursor;
|
|
226
|
+
} while (cursor);
|
|
227
|
+
return operations;
|
|
228
|
+
}
|
|
127
229
|
function normalizeError(errorMsg) {
|
|
128
230
|
return errorMsg?.trim() || "provider reported failure";
|
|
129
231
|
}
|
|
@@ -146,3 +248,90 @@ function toWritebackItem(storage, item) {
|
|
|
146
248
|
correlationId: item.correlationId,
|
|
147
249
|
};
|
|
148
250
|
}
|
|
251
|
+
function toWritebackListItem(storage, op, state) {
|
|
252
|
+
return {
|
|
253
|
+
id: op.opId,
|
|
254
|
+
workspaceId: storage.getWorkspaceId(),
|
|
255
|
+
path: normalizePath(op.path),
|
|
256
|
+
revision: op.revision,
|
|
257
|
+
correlationId: op.correlationId || "",
|
|
258
|
+
state,
|
|
259
|
+
attemptCount: op.attemptCount,
|
|
260
|
+
lastError: op.lastError,
|
|
261
|
+
nextAttemptAt: op.nextAttemptAt,
|
|
262
|
+
provider: op.provider,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
function compareWritebackListItems(left, right) {
|
|
266
|
+
if (left.nextAttemptAt && right.nextAttemptAt) {
|
|
267
|
+
const byNextAttempt = left.nextAttemptAt.localeCompare(right.nextAttemptAt);
|
|
268
|
+
if (byNextAttempt !== 0) {
|
|
269
|
+
return byNextAttempt;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
else if (left.nextAttemptAt) {
|
|
273
|
+
return -1;
|
|
274
|
+
}
|
|
275
|
+
else if (right.nextAttemptAt) {
|
|
276
|
+
return 1;
|
|
277
|
+
}
|
|
278
|
+
return left.id.localeCompare(right.id);
|
|
279
|
+
}
|
|
280
|
+
function isRecord(value) {
|
|
281
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
282
|
+
}
|
|
283
|
+
function normalizeDeadLetterErrorCode(value) {
|
|
284
|
+
if (value === "schema_violation" ||
|
|
285
|
+
value === "provider_4xx" ||
|
|
286
|
+
value === "provider_5xx_exhausted" ||
|
|
287
|
+
value === "timeout") {
|
|
288
|
+
return value;
|
|
289
|
+
}
|
|
290
|
+
throw new TypeError("dead-letter error code is invalid");
|
|
291
|
+
}
|
|
292
|
+
function requiredString(value, field) {
|
|
293
|
+
if (typeof value !== "string") {
|
|
294
|
+
throw new TypeError(`dead-letter error ${field} must be a string`);
|
|
295
|
+
}
|
|
296
|
+
return value;
|
|
297
|
+
}
|
|
298
|
+
function requiredPositiveInteger(value, field) {
|
|
299
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
300
|
+
throw new TypeError(`dead-letter error ${field} must be a positive integer`);
|
|
301
|
+
}
|
|
302
|
+
return value;
|
|
303
|
+
}
|
|
304
|
+
function requiredIsoTimestamp(value, field) {
|
|
305
|
+
const timestamp = requiredString(value, field).trim();
|
|
306
|
+
if (!timestamp || Number.isNaN(Date.parse(timestamp))) {
|
|
307
|
+
throw new TypeError(`dead-letter error ${field} must be an ISO timestamp`);
|
|
308
|
+
}
|
|
309
|
+
return timestamp;
|
|
310
|
+
}
|
|
311
|
+
function optionalProviderStatus(value) {
|
|
312
|
+
if (typeof value !== "number" ||
|
|
313
|
+
!Number.isInteger(value) ||
|
|
314
|
+
value < 100 ||
|
|
315
|
+
value > 599) {
|
|
316
|
+
throw new TypeError("dead-letter error providerStatus must be an HTTP status");
|
|
317
|
+
}
|
|
318
|
+
return value;
|
|
319
|
+
}
|
|
320
|
+
function optionalProviderResponse(value) {
|
|
321
|
+
if (!isRecord(value) && !Array.isArray(value)) {
|
|
322
|
+
throw new TypeError("dead-letter error providerResponse must be an object or array");
|
|
323
|
+
}
|
|
324
|
+
return value;
|
|
325
|
+
}
|
|
326
|
+
function parseWritebackPath(writebackPath) {
|
|
327
|
+
const parts = normalizePath(writebackPath).split("/").filter(Boolean);
|
|
328
|
+
if (parts.length < 3) {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
const provider = parts[0]?.trim().toLowerCase();
|
|
332
|
+
const resource = parts[parts.length - 2]?.trim().toLowerCase();
|
|
333
|
+
if (!provider || !resource || resource.startsWith(".")) {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
return { provider, resource };
|
|
337
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { MAX_WRITEBACK_ATTEMPTS, listWritebacks, } from "./writeback.js";
|
|
3
|
+
describe("listWritebacks", () => {
|
|
4
|
+
it("lists pending writebacks deterministically by nextAttemptAt with nulls last", () => {
|
|
5
|
+
const storage = makeStorage([
|
|
6
|
+
operation({
|
|
7
|
+
opId: "op_null",
|
|
8
|
+
path: "/linear/comments/null.json",
|
|
9
|
+
nextAttemptAt: null,
|
|
10
|
+
}),
|
|
11
|
+
operation({
|
|
12
|
+
opId: "op_late",
|
|
13
|
+
path: "/linear/comments/late.json",
|
|
14
|
+
nextAttemptAt: "2026-05-13T12:00:00.000Z",
|
|
15
|
+
}),
|
|
16
|
+
operation({
|
|
17
|
+
opId: "op_early",
|
|
18
|
+
path: "/linear/comments/early.json",
|
|
19
|
+
nextAttemptAt: "2026-05-13T11:00:00.000Z",
|
|
20
|
+
}),
|
|
21
|
+
]);
|
|
22
|
+
const first = listWritebacks(storage, { state: "pending" }).items;
|
|
23
|
+
const second = listWritebacks(storage, { state: "pending" }).items;
|
|
24
|
+
expect(first.map((item) => item.id)).toEqual([
|
|
25
|
+
"op_early",
|
|
26
|
+
"op_late",
|
|
27
|
+
"op_null",
|
|
28
|
+
]);
|
|
29
|
+
expect(second).toEqual(first);
|
|
30
|
+
expect(first[0]).toMatchObject({
|
|
31
|
+
workspaceId: "ws_core",
|
|
32
|
+
path: "/linear/comments/early.json",
|
|
33
|
+
state: "pending",
|
|
34
|
+
attemptCount: 0,
|
|
35
|
+
provider: "linear",
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
it("sorts before applying the requested page limit", () => {
|
|
39
|
+
const storage = makeStorage([
|
|
40
|
+
operation({
|
|
41
|
+
opId: "op_late",
|
|
42
|
+
nextAttemptAt: "2026-05-13T12:00:00.000Z",
|
|
43
|
+
}),
|
|
44
|
+
operation({
|
|
45
|
+
opId: "op_null",
|
|
46
|
+
nextAttemptAt: null,
|
|
47
|
+
}),
|
|
48
|
+
operation({
|
|
49
|
+
opId: "op_early",
|
|
50
|
+
nextAttemptAt: "2026-05-13T10:00:00.000Z",
|
|
51
|
+
}),
|
|
52
|
+
], { sort: false });
|
|
53
|
+
const first = listWritebacks(storage, { state: "pending", limit: 1 });
|
|
54
|
+
const second = listWritebacks(storage, {
|
|
55
|
+
state: "pending",
|
|
56
|
+
cursor: first.nextCursor ?? undefined,
|
|
57
|
+
limit: 1,
|
|
58
|
+
});
|
|
59
|
+
expect(first.items.map((item) => item.id)).toEqual(["op_early"]);
|
|
60
|
+
expect(first.nextCursor).toBe("op_early");
|
|
61
|
+
expect(second.items.map((item) => item.id)).toEqual(["op_late"]);
|
|
62
|
+
expect(second.nextCursor).toBe("op_late");
|
|
63
|
+
});
|
|
64
|
+
it("lists dead-lettered writebacks with retry metadata", () => {
|
|
65
|
+
const storage = makeStorage([
|
|
66
|
+
operation({
|
|
67
|
+
opId: "op_dead",
|
|
68
|
+
status: "dead_lettered",
|
|
69
|
+
attemptCount: MAX_WRITEBACK_ATTEMPTS,
|
|
70
|
+
lastError: "schema validation failed",
|
|
71
|
+
}),
|
|
72
|
+
operation({ opId: "op_pending", status: "pending" }),
|
|
73
|
+
]);
|
|
74
|
+
expect(listWritebacks(storage, { state: "dead_lettered" }).items).toEqual([
|
|
75
|
+
expect.objectContaining({
|
|
76
|
+
id: "op_dead",
|
|
77
|
+
state: "dead_lettered",
|
|
78
|
+
attemptCount: MAX_WRITEBACK_ATTEMPTS,
|
|
79
|
+
lastError: "schema validation failed",
|
|
80
|
+
}),
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
it("lists succeeded and failed writebacks separately", () => {
|
|
84
|
+
const storage = makeStorage([
|
|
85
|
+
operation({ opId: "op_succeeded", status: "succeeded" }),
|
|
86
|
+
operation({ opId: "op_failed", status: "failed", lastError: "timeout" }),
|
|
87
|
+
operation({ opId: "op_pending", status: "pending" }),
|
|
88
|
+
]);
|
|
89
|
+
expect(listWritebacks(storage, { state: "succeeded" }).items).toEqual([
|
|
90
|
+
expect.objectContaining({ id: "op_succeeded", state: "succeeded" }),
|
|
91
|
+
]);
|
|
92
|
+
expect(listWritebacks(storage, { state: "failed" }).items).toEqual([
|
|
93
|
+
expect.objectContaining({
|
|
94
|
+
id: "op_failed",
|
|
95
|
+
state: "failed",
|
|
96
|
+
lastError: "timeout",
|
|
97
|
+
}),
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
it("rejects unknown states", () => {
|
|
101
|
+
const storage = makeStorage([]);
|
|
102
|
+
expect(() => listWritebacks(storage, { state: "dead" })).toThrow(TypeError);
|
|
103
|
+
});
|
|
104
|
+
it("paginates using the returned operation cursor", () => {
|
|
105
|
+
const storage = makeStorage([
|
|
106
|
+
operation({ opId: "op_1", nextAttemptAt: "2026-05-13T10:00:00.000Z" }),
|
|
107
|
+
operation({ opId: "op_2", nextAttemptAt: "2026-05-13T11:00:00.000Z" }),
|
|
108
|
+
operation({ opId: "op_3", nextAttemptAt: "2026-05-13T12:00:00.000Z" }),
|
|
109
|
+
operation({ opId: "op_4", nextAttemptAt: "2026-05-13T13:00:00.000Z" }),
|
|
110
|
+
operation({ opId: "op_5", nextAttemptAt: "2026-05-13T14:00:00.000Z" }),
|
|
111
|
+
]);
|
|
112
|
+
const first = listWritebacks(storage, { state: "pending", limit: 2 });
|
|
113
|
+
const second = listWritebacks(storage, {
|
|
114
|
+
state: "pending",
|
|
115
|
+
cursor: first.nextCursor ?? undefined,
|
|
116
|
+
limit: 2,
|
|
117
|
+
});
|
|
118
|
+
expect(first.items.map((item) => item.id)).toEqual(["op_1", "op_2"]);
|
|
119
|
+
expect(first.nextCursor).toBe("op_2");
|
|
120
|
+
expect(second.items.map((item) => item.id)).toEqual(["op_3", "op_4"]);
|
|
121
|
+
expect(second.nextCursor).toBe("op_4");
|
|
122
|
+
});
|
|
123
|
+
it("returns an empty page for unknown cursors", () => {
|
|
124
|
+
const storage = makeStorage([operation({ opId: "op_1" })]);
|
|
125
|
+
expect(listWritebacks(storage, {
|
|
126
|
+
state: "pending",
|
|
127
|
+
cursor: "op_missing",
|
|
128
|
+
})).toEqual({ items: [], nextCursor: null });
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
function operation(overrides) {
|
|
132
|
+
return {
|
|
133
|
+
opId: "op_default",
|
|
134
|
+
path: "/linear/issues/AGE-16__abc/comments/wb.json",
|
|
135
|
+
revision: "rev_1",
|
|
136
|
+
action: "file_upsert",
|
|
137
|
+
provider: "linear",
|
|
138
|
+
status: "pending",
|
|
139
|
+
attemptCount: 0,
|
|
140
|
+
lastError: null,
|
|
141
|
+
nextAttemptAt: null,
|
|
142
|
+
correlationId: "corr_1",
|
|
143
|
+
...overrides,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function makeStorage(operations, options = {}) {
|
|
147
|
+
const sortedOperations = options.sort === false ? [...operations] : [...operations].sort(compareOperations);
|
|
148
|
+
return {
|
|
149
|
+
getFile: () => null,
|
|
150
|
+
listFiles: () => [],
|
|
151
|
+
putFile: () => undefined,
|
|
152
|
+
deleteFile: () => undefined,
|
|
153
|
+
appendEvent: () => undefined,
|
|
154
|
+
listEvents: () => ({ items: [], nextCursor: null }),
|
|
155
|
+
getRecentEvents: () => [],
|
|
156
|
+
getOperation: (opId) => sortedOperations.find((operationRow) => operationRow.opId === opId) ??
|
|
157
|
+
null,
|
|
158
|
+
putOperation: () => undefined,
|
|
159
|
+
listOperations: (options) => {
|
|
160
|
+
const matching = sortedOperations.filter((op) => {
|
|
161
|
+
return !options.status || op.status === options.status;
|
|
162
|
+
});
|
|
163
|
+
const start = options.cursor
|
|
164
|
+
? matching.findIndex((op) => op.opId === options.cursor) + 1
|
|
165
|
+
: 0;
|
|
166
|
+
const limit = options.limit ?? 100;
|
|
167
|
+
const items = matching.slice(Math.max(0, start), Math.max(0, start) + limit);
|
|
168
|
+
return {
|
|
169
|
+
items,
|
|
170
|
+
nextCursor: items.length >= limit ? (items[items.length - 1]?.opId ?? null) : null,
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
nextRevision: () => "rev_next",
|
|
174
|
+
nextOperationId: () => "op_next",
|
|
175
|
+
nextEventId: () => "evt_next",
|
|
176
|
+
enqueueWriteback: () => undefined,
|
|
177
|
+
getPendingWritebacks: () => [],
|
|
178
|
+
getWorkspaceId: () => "ws_core",
|
|
179
|
+
listEnvelopes: (_options) => ({
|
|
180
|
+
items: [],
|
|
181
|
+
nextCursor: null,
|
|
182
|
+
}),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function compareOperations(left, right) {
|
|
186
|
+
const leftItem = operationToListItem(left);
|
|
187
|
+
const rightItem = operationToListItem(right);
|
|
188
|
+
if (leftItem.nextAttemptAt && rightItem.nextAttemptAt) {
|
|
189
|
+
const byNextAttempt = leftItem.nextAttemptAt.localeCompare(rightItem.nextAttemptAt);
|
|
190
|
+
if (byNextAttempt !== 0) {
|
|
191
|
+
return byNextAttempt;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else if (leftItem.nextAttemptAt) {
|
|
195
|
+
return -1;
|
|
196
|
+
}
|
|
197
|
+
else if (rightItem.nextAttemptAt) {
|
|
198
|
+
return 1;
|
|
199
|
+
}
|
|
200
|
+
return leftItem.id.localeCompare(rightItem.id);
|
|
201
|
+
}
|
|
202
|
+
function operationToListItem(op) {
|
|
203
|
+
return {
|
|
204
|
+
id: op.opId,
|
|
205
|
+
nextAttemptAt: op.nextAttemptAt,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
void {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveWritebackSchemaRef } from "./writeback.js";
|
|
3
|
+
describe("resolveWritebackSchemaRef", () => {
|
|
4
|
+
it("derives the schema id from a writeback path", () => {
|
|
5
|
+
expect(resolveWritebackSchemaRef(makeStorage(), "/linear/issues/AGE-16__abc/comments/wb-1715000000.json")).toEqual({
|
|
6
|
+
provider: "linear",
|
|
7
|
+
resource: "comments",
|
|
8
|
+
schemaId: "linear/comments.schema.json",
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
it("returns null for paths without a resource segment", () => {
|
|
12
|
+
expect(resolveWritebackSchemaRef(makeStorage(), "/linear/wb.json")).toBeNull();
|
|
13
|
+
});
|
|
14
|
+
it("uses a registered schema id when storage provides one", () => {
|
|
15
|
+
const storage = makeStorage({
|
|
16
|
+
getWritebackSchemaId: (provider, resource) => `${provider}/${resource}.v2.schema.json`,
|
|
17
|
+
});
|
|
18
|
+
expect(resolveWritebackSchemaRef(storage, "/linear/issues/AGE-16__abc/comments/wb.json")?.schemaId).toBe("linear/comments.v2.schema.json");
|
|
19
|
+
});
|
|
20
|
+
it("returns null when storage reports no registered schema", () => {
|
|
21
|
+
const storage = makeStorage({
|
|
22
|
+
getWritebackSchemaId: () => null,
|
|
23
|
+
});
|
|
24
|
+
expect(resolveWritebackSchemaRef(storage, "/linear/issues/AGE-16__abc/comments/wb.json")).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
function makeStorage(overrides = {}) {
|
|
28
|
+
return {
|
|
29
|
+
getFile: () => null,
|
|
30
|
+
listFiles: () => [],
|
|
31
|
+
putFile: () => undefined,
|
|
32
|
+
deleteFile: () => undefined,
|
|
33
|
+
appendEvent: () => undefined,
|
|
34
|
+
listEvents: () => ({ items: [], nextCursor: null }),
|
|
35
|
+
getRecentEvents: () => [],
|
|
36
|
+
getOperation: () => null,
|
|
37
|
+
putOperation: () => undefined,
|
|
38
|
+
listOperations: () => ({
|
|
39
|
+
items: [],
|
|
40
|
+
nextCursor: null,
|
|
41
|
+
}),
|
|
42
|
+
nextRevision: () => "rev_next",
|
|
43
|
+
nextOperationId: () => "op_next",
|
|
44
|
+
nextEventId: () => "evt_next",
|
|
45
|
+
enqueueWriteback: () => undefined,
|
|
46
|
+
getPendingWritebacks: () => [],
|
|
47
|
+
getWorkspaceId: () => "ws_core",
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
void {};
|
package/package.json
CHANGED