@relayfile/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.
- package/dist/acl.d.ts +34 -0
- package/dist/acl.js +163 -0
- package/dist/events.d.ts +23 -0
- package/dist/events.js +39 -0
- package/dist/export.d.ts +16 -0
- package/dist/export.js +137 -0
- package/dist/files.d.ts +66 -0
- package/dist/files.js +240 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +17 -0
- package/dist/operations.d.ts +20 -0
- package/dist/operations.js +94 -0
- package/dist/query.d.ts +30 -0
- package/dist/query.js +138 -0
- package/dist/semantics.d.ts +15 -0
- package/dist/semantics.js +90 -0
- package/dist/storage.d.ts +113 -0
- package/dist/storage.js +9 -0
- package/dist/tree.d.ts +35 -0
- package/dist/tree.js +106 -0
- package/dist/utils.d.ts +11 -0
- package/dist/utils.js +32 -0
- package/dist/webhooks.d.ts +82 -0
- package/dist/webhooks.js +493 -0
- package/dist/writeback.d.ts +24 -0
- package/dist/writeback.js +148 -0
- package/package.json +39 -0
package/dist/webhooks.js
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook envelope normalization and processing.
|
|
3
|
+
*
|
|
4
|
+
* Extract from workspace.ts:
|
|
5
|
+
* - generic webhook ingestion
|
|
6
|
+
* - envelope normalization
|
|
7
|
+
* - deduplication by delivery_id
|
|
8
|
+
* - coalescing within time window
|
|
9
|
+
* - suppression for loop prevention
|
|
10
|
+
* - stale event filtering
|
|
11
|
+
* - file materialization from webhook data
|
|
12
|
+
*
|
|
13
|
+
* This module stays pure. It only operates over plain storage records and
|
|
14
|
+
* optional callbacks for suppression/staleness policy.
|
|
15
|
+
*/
|
|
16
|
+
import { normalizePath, DEFAULT_CONTENT_TYPE, MAX_FILE_BYTES, encodedSize } from "./files.js";
|
|
17
|
+
export function ingestWebhook(storage, input, options = {}) {
|
|
18
|
+
const envelopeStorage = getWebhookStorage(storage);
|
|
19
|
+
const correlationId = input.correlationId?.trim() ?? "";
|
|
20
|
+
if (options.signatureVerifier && !options.signatureVerifier(input)) {
|
|
21
|
+
return {
|
|
22
|
+
status: "signature_invalid",
|
|
23
|
+
envelopeId: "",
|
|
24
|
+
correlationId,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
if (!envelopeStorage) {
|
|
28
|
+
return {
|
|
29
|
+
status: "invalid_storage",
|
|
30
|
+
envelopeId: "",
|
|
31
|
+
correlationId,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const now = options.now ?? nowIso;
|
|
35
|
+
const normalized = normalizeEnvelope(input, now);
|
|
36
|
+
const provider = asOptionalString(normalized.provider) ?? "";
|
|
37
|
+
const payload = asRecord(normalized.payload);
|
|
38
|
+
const path = normalizeEnvelopePath({ payload });
|
|
39
|
+
const receivedAt = asOptionalString(normalized.receivedAt) ?? now();
|
|
40
|
+
const workspaceId = storage.getWorkspaceId();
|
|
41
|
+
const deliveryId = asOptionalString(normalized.deliveryId) ??
|
|
42
|
+
defaultDeliveryId(provider, receivedAt);
|
|
43
|
+
if (!provider || !path || !normalizeEnvelopeEventType(payload)) {
|
|
44
|
+
return {
|
|
45
|
+
status: "invalid_input",
|
|
46
|
+
envelopeId: "",
|
|
47
|
+
correlationId,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const existing = findEnvelopeByDelivery(envelopeStorage, workspaceId, provider, deliveryId);
|
|
51
|
+
if (existing) {
|
|
52
|
+
return {
|
|
53
|
+
status: "duplicate",
|
|
54
|
+
envelopeId: existing.envelopeId,
|
|
55
|
+
correlationId: existing.correlationId || correlationId,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const queued = envelopeStorage.listEnvelopes({
|
|
59
|
+
workspaceId,
|
|
60
|
+
provider,
|
|
61
|
+
status: "queued",
|
|
62
|
+
limit: 100,
|
|
63
|
+
}).items;
|
|
64
|
+
const coalesced = findCoalescedEnvelope(queued, payload, receivedAt, options.coalesceWindowMs ?? DEFAULT_COALESCE_WINDOW_MS);
|
|
65
|
+
if (coalesced) {
|
|
66
|
+
const updated = {
|
|
67
|
+
...coalesced,
|
|
68
|
+
deliveryId: coalesced.deliveryId,
|
|
69
|
+
deliveryIds: mergeDeliveryIds(coalesced, deliveryId),
|
|
70
|
+
receivedAt,
|
|
71
|
+
headers: normalized.headers ?? coalesced.headers ?? {},
|
|
72
|
+
payload,
|
|
73
|
+
correlationId: correlationId || coalesced.correlationId,
|
|
74
|
+
status: "queued",
|
|
75
|
+
lastError: null,
|
|
76
|
+
};
|
|
77
|
+
envelopeStorage.putEnvelope(updated);
|
|
78
|
+
envelopeStorage.putEnvelopeDeliveryAlias?.(workspaceId, provider, deliveryId, updated.envelopeId);
|
|
79
|
+
return {
|
|
80
|
+
status: "queued",
|
|
81
|
+
envelopeId: updated.envelopeId,
|
|
82
|
+
correlationId: updated.correlationId,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const envelopeId = (options.generateEnvelopeId ?? defaultEnvelopeId)();
|
|
86
|
+
envelopeStorage.putEnvelope({
|
|
87
|
+
envelopeId,
|
|
88
|
+
workspaceId,
|
|
89
|
+
provider,
|
|
90
|
+
deliveryId,
|
|
91
|
+
deliveryIds: [deliveryId],
|
|
92
|
+
receivedAt,
|
|
93
|
+
headers: normalized.headers ?? {},
|
|
94
|
+
payload,
|
|
95
|
+
correlationId,
|
|
96
|
+
status: "queued",
|
|
97
|
+
attemptCount: 0,
|
|
98
|
+
lastError: null,
|
|
99
|
+
});
|
|
100
|
+
envelopeStorage.putEnvelopeDeliveryAlias?.(workspaceId, provider, deliveryId, envelopeId);
|
|
101
|
+
return {
|
|
102
|
+
status: "queued",
|
|
103
|
+
envelopeId,
|
|
104
|
+
correlationId,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export function normalizeEnvelope(input, now = nowIso) {
|
|
108
|
+
const provider = normalizeProvider(input.provider);
|
|
109
|
+
const eventType = normalizeEventType(input.eventType);
|
|
110
|
+
const path = normalizePath(input.path ?? "");
|
|
111
|
+
const correlationId = input.correlationId?.trim() ?? "";
|
|
112
|
+
const receivedAt = normalizeIsoDate(input.timestamp) ?? now();
|
|
113
|
+
const deliveryId = input.deliveryId?.trim();
|
|
114
|
+
if (!provider || !eventType || !input.path?.trim()) {
|
|
115
|
+
return {
|
|
116
|
+
provider,
|
|
117
|
+
deliveryId,
|
|
118
|
+
receivedAt,
|
|
119
|
+
headers: normalizeHeaderMap(input.headers),
|
|
120
|
+
payload: {},
|
|
121
|
+
correlationId,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
provider,
|
|
126
|
+
deliveryId,
|
|
127
|
+
receivedAt,
|
|
128
|
+
headers: normalizeHeaderMap(input.headers),
|
|
129
|
+
payload: {
|
|
130
|
+
provider,
|
|
131
|
+
event_type: eventType,
|
|
132
|
+
path,
|
|
133
|
+
timestamp: receivedAt,
|
|
134
|
+
data: asRecord(input.data),
|
|
135
|
+
delivery_id: deliveryId,
|
|
136
|
+
},
|
|
137
|
+
correlationId,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
export function normalizeEnvelopeEvent(envelope) {
|
|
141
|
+
const payload = envelope.payload;
|
|
142
|
+
const eventType = normalizeEnvelopeEventType(payload);
|
|
143
|
+
if (!eventType) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const path = normalizePath(asOptionalString(payload.path) ?? "/");
|
|
147
|
+
const data = asRecord(payload.data);
|
|
148
|
+
const body = Object.keys(data).length > 0 ? data : payload;
|
|
149
|
+
const timestamp = normalizeIsoDate(asOptionalString(payload.timestamp) ?? envelope.receivedAt) ??
|
|
150
|
+
nowIso();
|
|
151
|
+
const content = typeof body.content === "string" ? body.content : undefined;
|
|
152
|
+
const contentType = typeof body.contentType === "string"
|
|
153
|
+
? body.contentType
|
|
154
|
+
: typeof body.content_type === "string"
|
|
155
|
+
? body.content_type
|
|
156
|
+
: undefined;
|
|
157
|
+
const encoding = typeof body.encoding === "string" ? body.encoding : undefined;
|
|
158
|
+
const semantics = body.semantics && typeof body.semantics === "object" && !Array.isArray(body.semantics)
|
|
159
|
+
? normalizeSemantics(body.semantics)
|
|
160
|
+
: undefined;
|
|
161
|
+
return {
|
|
162
|
+
type: eventType,
|
|
163
|
+
path,
|
|
164
|
+
timestamp,
|
|
165
|
+
content,
|
|
166
|
+
contentType,
|
|
167
|
+
encoding,
|
|
168
|
+
semantics,
|
|
169
|
+
data: body,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
export function normalizeEnvelopePath(envelope) {
|
|
173
|
+
const path = asOptionalString(envelope.payload.path);
|
|
174
|
+
return path ? normalizePath(path) : null;
|
|
175
|
+
}
|
|
176
|
+
export function applyWebhookEnvelope(storage, envelope, options = {}) {
|
|
177
|
+
const event = normalizeEnvelopeEvent(envelope);
|
|
178
|
+
if (!event) {
|
|
179
|
+
return {
|
|
180
|
+
status: "ignored",
|
|
181
|
+
eventType: null,
|
|
182
|
+
path: normalizeEnvelopePath(envelope),
|
|
183
|
+
revision: null,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (options.shouldSuppress?.(envelope, event)) {
|
|
187
|
+
const revision = appendSyncEvent(storage, "sync.suppressed", event.path, envelope.provider, envelope.correlationId, options.now?.() ?? event.timestamp);
|
|
188
|
+
return {
|
|
189
|
+
status: "suppressed",
|
|
190
|
+
eventType: "sync.suppressed",
|
|
191
|
+
path: event.path,
|
|
192
|
+
revision,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (options.isStale?.(envelope, event)) {
|
|
196
|
+
const revision = appendSyncEvent(storage, "sync.stale", event.path, envelope.provider, envelope.correlationId, options.now?.() ?? event.timestamp);
|
|
197
|
+
return {
|
|
198
|
+
status: "stale",
|
|
199
|
+
eventType: "sync.stale",
|
|
200
|
+
path: event.path,
|
|
201
|
+
revision,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
if (event.type === "file.created" || event.type === "file.updated") {
|
|
205
|
+
if (options.isPathWriteAllowed && !options.isPathWriteAllowed(event.path)) {
|
|
206
|
+
return {
|
|
207
|
+
status: "ignored",
|
|
208
|
+
eventType: event.type,
|
|
209
|
+
path: event.path,
|
|
210
|
+
revision: null,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const revision = storage.nextRevision();
|
|
214
|
+
const content = typeof event.content === "string"
|
|
215
|
+
? event.content
|
|
216
|
+
: JSON.stringify(event.data ?? {});
|
|
217
|
+
const encoding = normalizeEncoding(event.encoding) ?? "utf-8";
|
|
218
|
+
if (encodedSize(content, encoding) > MAX_FILE_BYTES) {
|
|
219
|
+
return {
|
|
220
|
+
status: "rejected",
|
|
221
|
+
eventType: event.type,
|
|
222
|
+
path: event.path,
|
|
223
|
+
revision: null,
|
|
224
|
+
reason: "file_too_large",
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
// Strip permissions from webhook-provided semantics to prevent
|
|
228
|
+
// external webhooks from injecting or overwriting ACL rules.
|
|
229
|
+
const semantics = normalizeSemantics(event.semantics);
|
|
230
|
+
delete semantics.permissions;
|
|
231
|
+
storage.putFile({
|
|
232
|
+
path: event.path,
|
|
233
|
+
revision,
|
|
234
|
+
contentType: event.contentType?.trim() || DEFAULT_CONTENT_TYPE,
|
|
235
|
+
content,
|
|
236
|
+
encoding,
|
|
237
|
+
provider: envelope.provider,
|
|
238
|
+
lastEditedAt: event.timestamp,
|
|
239
|
+
semantics,
|
|
240
|
+
});
|
|
241
|
+
storage.appendEvent({
|
|
242
|
+
eventId: storage.nextEventId(),
|
|
243
|
+
type: event.type,
|
|
244
|
+
path: event.path,
|
|
245
|
+
revision,
|
|
246
|
+
origin: "provider_sync",
|
|
247
|
+
provider: envelope.provider,
|
|
248
|
+
correlationId: envelope.correlationId,
|
|
249
|
+
timestamp: event.timestamp,
|
|
250
|
+
});
|
|
251
|
+
return {
|
|
252
|
+
status: "processed",
|
|
253
|
+
eventType: event.type,
|
|
254
|
+
path: event.path,
|
|
255
|
+
revision,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
if (event.type === "file.deleted") {
|
|
259
|
+
if (options.isPathWriteAllowed && !options.isPathWriteAllowed(event.path)) {
|
|
260
|
+
return {
|
|
261
|
+
status: "ignored",
|
|
262
|
+
eventType: event.type,
|
|
263
|
+
path: event.path,
|
|
264
|
+
revision: null,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
storage.deleteFile(event.path);
|
|
268
|
+
const revision = appendSyncEvent(storage, "file.deleted", event.path, envelope.provider, envelope.correlationId, event.timestamp);
|
|
269
|
+
return {
|
|
270
|
+
status: "processed",
|
|
271
|
+
eventType: "file.deleted",
|
|
272
|
+
path: event.path,
|
|
273
|
+
revision,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
const revision = appendSyncEvent(storage, event.type, event.path, envelope.provider, envelope.correlationId, event.timestamp);
|
|
277
|
+
return {
|
|
278
|
+
status: "processed",
|
|
279
|
+
eventType: event.type,
|
|
280
|
+
path: event.path,
|
|
281
|
+
revision,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const DEFAULT_COALESCE_WINDOW_MS = 3_000;
|
|
285
|
+
const ENVELOPE_SCAN_PAGE_SIZE = 100;
|
|
286
|
+
const MAX_ENVELOPE_SCAN_PAGES = 1000;
|
|
287
|
+
const VALID_EVENT_TYPES = new Set([
|
|
288
|
+
"file.created",
|
|
289
|
+
"file.updated",
|
|
290
|
+
"file.deleted",
|
|
291
|
+
"dir.created",
|
|
292
|
+
"dir.deleted",
|
|
293
|
+
"sync.error",
|
|
294
|
+
"sync.ignored",
|
|
295
|
+
"sync.suppressed",
|
|
296
|
+
"sync.stale",
|
|
297
|
+
"writeback.failed",
|
|
298
|
+
"writeback.succeeded",
|
|
299
|
+
]);
|
|
300
|
+
function getWebhookStorage(storage) {
|
|
301
|
+
if (typeof storage.getEnvelopeByDelivery !== "function" ||
|
|
302
|
+
typeof storage.putEnvelope !== "function" ||
|
|
303
|
+
typeof storage.listEnvelopes !== "function") {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
return storage;
|
|
307
|
+
}
|
|
308
|
+
function findEnvelopeByDelivery(storage, workspaceId, provider, deliveryId) {
|
|
309
|
+
const direct = storage.getEnvelopeByDelivery(workspaceId, provider, deliveryId);
|
|
310
|
+
if (direct) {
|
|
311
|
+
return direct;
|
|
312
|
+
}
|
|
313
|
+
let cursor;
|
|
314
|
+
let pagesScanned = 0;
|
|
315
|
+
for (;;) {
|
|
316
|
+
if (pagesScanned >= MAX_ENVELOPE_SCAN_PAGES) {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
const page = storage.listEnvelopes({
|
|
320
|
+
workspaceId,
|
|
321
|
+
provider,
|
|
322
|
+
cursor,
|
|
323
|
+
limit: ENVELOPE_SCAN_PAGE_SIZE,
|
|
324
|
+
});
|
|
325
|
+
pagesScanned += 1;
|
|
326
|
+
for (const envelope of page.items) {
|
|
327
|
+
if (deliveryMatches(envelope, deliveryId)) {
|
|
328
|
+
return envelope;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (!page.nextCursor || page.nextCursor === cursor) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
cursor = page.nextCursor;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
function appendSyncEvent(storage, type, path, provider, correlationId, timestamp) {
|
|
338
|
+
const revision = storage.nextRevision();
|
|
339
|
+
storage.appendEvent({
|
|
340
|
+
eventId: storage.nextEventId(),
|
|
341
|
+
type,
|
|
342
|
+
path,
|
|
343
|
+
revision,
|
|
344
|
+
origin: "provider_sync",
|
|
345
|
+
provider,
|
|
346
|
+
correlationId,
|
|
347
|
+
timestamp,
|
|
348
|
+
});
|
|
349
|
+
return revision;
|
|
350
|
+
}
|
|
351
|
+
function findCoalescedEnvelope(envelopes, payload, receivedAt, windowMs) {
|
|
352
|
+
const key = coalesceObjectKey(payload);
|
|
353
|
+
if (!key) {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
let match = null;
|
|
357
|
+
for (const envelope of envelopes) {
|
|
358
|
+
if (coalesceObjectKey(envelope.payload) !== key) {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (!withinCoalesceWindow(envelope.receivedAt, receivedAt, windowMs)) {
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (!match || envelope.receivedAt > match.receivedAt) {
|
|
365
|
+
match = envelope;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return match;
|
|
369
|
+
}
|
|
370
|
+
function mergeDeliveryIds(envelope, deliveryId) {
|
|
371
|
+
return Array.from(new Set([envelope.deliveryId, ...(envelope.deliveryIds ?? []), deliveryId]));
|
|
372
|
+
}
|
|
373
|
+
function deliveryMatches(envelope, deliveryId) {
|
|
374
|
+
return envelope.deliveryId === deliveryId || envelope.deliveryIds?.includes(deliveryId) === true;
|
|
375
|
+
}
|
|
376
|
+
function coalesceObjectKey(payload) {
|
|
377
|
+
const data = asRecord(payload.data);
|
|
378
|
+
const providerObjectId = asOptionalString(data.providerObjectId) ??
|
|
379
|
+
asOptionalString(data.provider_object_id) ??
|
|
380
|
+
asOptionalString(data.objectId) ??
|
|
381
|
+
asOptionalString(payload.providerObjectId) ??
|
|
382
|
+
asOptionalString(payload.provider_object_id) ??
|
|
383
|
+
asOptionalString(payload.objectId);
|
|
384
|
+
if (providerObjectId) {
|
|
385
|
+
return `object:${providerObjectId}`;
|
|
386
|
+
}
|
|
387
|
+
const path = normalizeEnvelopePath({ payload });
|
|
388
|
+
return path && path !== "/" ? `path:${path}` : "";
|
|
389
|
+
}
|
|
390
|
+
function withinCoalesceWindow(existingReceivedAt, incomingReceivedAt, windowMs) {
|
|
391
|
+
if (windowMs <= 0) {
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
const existingTs = Date.parse(existingReceivedAt);
|
|
395
|
+
const incomingTs = Date.parse(incomingReceivedAt);
|
|
396
|
+
if (Number.isNaN(existingTs) || Number.isNaN(incomingTs)) {
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
if (incomingTs < existingTs) {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
return incomingTs - existingTs <= windowMs;
|
|
403
|
+
}
|
|
404
|
+
function normalizeProvider(provider) {
|
|
405
|
+
return provider?.trim().toLowerCase() ?? "";
|
|
406
|
+
}
|
|
407
|
+
function normalizeEncoding(encoding) {
|
|
408
|
+
const value = encoding?.trim().toLowerCase() ?? "";
|
|
409
|
+
if (!value || value === "utf-8" || value === "utf8") {
|
|
410
|
+
return "utf-8";
|
|
411
|
+
}
|
|
412
|
+
if (value === "base64") {
|
|
413
|
+
return "base64";
|
|
414
|
+
}
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
function normalizeEventType(value) {
|
|
418
|
+
const type = value?.trim() ?? "";
|
|
419
|
+
return VALID_EVENT_TYPES.has(type) ? type : null;
|
|
420
|
+
}
|
|
421
|
+
function normalizeEnvelopeEventType(payload) {
|
|
422
|
+
return normalizeEventType(asOptionalString(payload.event_type) ??
|
|
423
|
+
asOptionalString(payload.eventType) ??
|
|
424
|
+
asOptionalString(payload.type) ??
|
|
425
|
+
undefined);
|
|
426
|
+
}
|
|
427
|
+
function normalizeIsoDate(value) {
|
|
428
|
+
if (!value?.trim()) {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
const timestamp = Date.parse(value);
|
|
432
|
+
return Number.isNaN(timestamp) ? null : new Date(timestamp).toISOString();
|
|
433
|
+
}
|
|
434
|
+
function normalizeHeaderMap(headers) {
|
|
435
|
+
if (!headers) {
|
|
436
|
+
return {};
|
|
437
|
+
}
|
|
438
|
+
return Object.entries(headers).reduce((acc, [key, value]) => {
|
|
439
|
+
const normalizedKey = key.trim();
|
|
440
|
+
if (normalizedKey) {
|
|
441
|
+
acc[normalizedKey] = String(value);
|
|
442
|
+
}
|
|
443
|
+
return acc;
|
|
444
|
+
}, {});
|
|
445
|
+
}
|
|
446
|
+
function normalizeSemantics(input) {
|
|
447
|
+
return {
|
|
448
|
+
properties: normalizeProperties(input?.properties),
|
|
449
|
+
relations: normalizeStringArray(input?.relations),
|
|
450
|
+
permissions: normalizeStringArray(input?.permissions),
|
|
451
|
+
comments: normalizeStringArray(input?.comments),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function normalizeProperties(input) {
|
|
455
|
+
if (!input) {
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
const out = {};
|
|
459
|
+
for (const [key, value] of Object.entries(input)) {
|
|
460
|
+
const normalizedKey = key.trim();
|
|
461
|
+
if (!normalizedKey) {
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
out[normalizedKey] = String(value).trim();
|
|
465
|
+
}
|
|
466
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
467
|
+
}
|
|
468
|
+
function normalizeStringArray(values) {
|
|
469
|
+
if (!values || values.length === 0) {
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
const normalized = Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
|
|
473
|
+
normalized.sort((left, right) => left.localeCompare(right));
|
|
474
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
475
|
+
}
|
|
476
|
+
function asOptionalString(value) {
|
|
477
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
478
|
+
}
|
|
479
|
+
function asRecord(value) {
|
|
480
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
481
|
+
? value
|
|
482
|
+
: {};
|
|
483
|
+
}
|
|
484
|
+
function defaultEnvelopeId() {
|
|
485
|
+
return `env_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
486
|
+
}
|
|
487
|
+
function defaultDeliveryId(provider, receivedAt) {
|
|
488
|
+
const safeProvider = provider || "webhook";
|
|
489
|
+
return `dlv_${safeProvider}_${Date.parse(receivedAt) || Date.now()}`;
|
|
490
|
+
}
|
|
491
|
+
function nowIso() {
|
|
492
|
+
return new Date().toISOString();
|
|
493
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writeback lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Extract from workspace.ts:
|
|
5
|
+
* - pending writeback listing (filtered by op status)
|
|
6
|
+
* - writeback acknowledgment
|
|
7
|
+
* - dead-letter handling
|
|
8
|
+
* - retry logic
|
|
9
|
+
*/
|
|
10
|
+
import type { StorageAdapter, WritebackItem } from "./storage.js";
|
|
11
|
+
export declare const MAX_WRITEBACK_ATTEMPTS = 3;
|
|
12
|
+
export declare const WRITEBACK_RETRY_DELAY_MS = 30000;
|
|
13
|
+
export interface DispatchWritebackCallbacks {
|
|
14
|
+
send(item: WritebackItem): void;
|
|
15
|
+
onRetryScheduled?(item: WritebackItem, nextAttemptAt: string): void;
|
|
16
|
+
now?(): string;
|
|
17
|
+
}
|
|
18
|
+
export declare function getPendingWritebacks(storage: StorageAdapter): WritebackItem[];
|
|
19
|
+
export declare function acknowledgeWriteback(storage: StorageAdapter, itemId: string, success: boolean, errorMsg?: string, correlationId?: string, now?: () => string): {
|
|
20
|
+
status: string;
|
|
21
|
+
id: string;
|
|
22
|
+
success: boolean;
|
|
23
|
+
} | null;
|
|
24
|
+
export declare function dispatchWriteback(storage: StorageAdapter, opId: string, callbacks: DispatchWritebackCallbacks): boolean;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writeback lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Extract from workspace.ts:
|
|
5
|
+
* - pending writeback listing (filtered by op status)
|
|
6
|
+
* - writeback acknowledgment
|
|
7
|
+
* - dead-letter handling
|
|
8
|
+
* - retry logic
|
|
9
|
+
*/
|
|
10
|
+
import { createEvent } from "./events.js";
|
|
11
|
+
export const MAX_WRITEBACK_ATTEMPTS = 3;
|
|
12
|
+
export const WRITEBACK_RETRY_DELAY_MS = 30_000;
|
|
13
|
+
export function getPendingWritebacks(storage) {
|
|
14
|
+
const items = [];
|
|
15
|
+
let cursor;
|
|
16
|
+
do {
|
|
17
|
+
const page = storage.listOperations({
|
|
18
|
+
status: "pending",
|
|
19
|
+
cursor,
|
|
20
|
+
limit: 1000,
|
|
21
|
+
});
|
|
22
|
+
items.push(...page.items
|
|
23
|
+
.filter((op) => op.status === "pending")
|
|
24
|
+
.map((op) => ({
|
|
25
|
+
id: op.opId,
|
|
26
|
+
workspaceId: storage.getWorkspaceId(),
|
|
27
|
+
path: normalizePath(op.path),
|
|
28
|
+
revision: op.revision,
|
|
29
|
+
correlationId: op.correlationId || "",
|
|
30
|
+
})));
|
|
31
|
+
cursor = page.nextCursor ?? undefined;
|
|
32
|
+
} while (cursor);
|
|
33
|
+
return items;
|
|
34
|
+
}
|
|
35
|
+
export function acknowledgeWriteback(storage, itemId, success, errorMsg, correlationId, now = defaultNow) {
|
|
36
|
+
const op = storage.getOperation(itemId.trim());
|
|
37
|
+
if (!op) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const timestamp = now();
|
|
41
|
+
const effectiveCorrelationId = correlationId ?? op.correlationId;
|
|
42
|
+
storage.putOperation({
|
|
43
|
+
...op,
|
|
44
|
+
status: success ? "succeeded" : "failed",
|
|
45
|
+
nextAttemptAt: null,
|
|
46
|
+
lastError: success ? null : normalizeError(errorMsg),
|
|
47
|
+
correlationId: effectiveCorrelationId,
|
|
48
|
+
});
|
|
49
|
+
createEvent(storage, {
|
|
50
|
+
type: success ? "writeback.succeeded" : "writeback.failed",
|
|
51
|
+
path: op.path,
|
|
52
|
+
revision: op.revision,
|
|
53
|
+
origin: "system",
|
|
54
|
+
provider: op.provider,
|
|
55
|
+
correlationId: effectiveCorrelationId,
|
|
56
|
+
timestamp,
|
|
57
|
+
});
|
|
58
|
+
return { status: "acknowledged", id: op.opId, success };
|
|
59
|
+
}
|
|
60
|
+
export function dispatchWriteback(storage, opId, callbacks) {
|
|
61
|
+
const op = storage.getOperation(opId.trim());
|
|
62
|
+
if (!op) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
if (op.status !== "pending" && op.status !== "running") {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
const now = callbacks.now ?? defaultNow;
|
|
69
|
+
const runningOp = {
|
|
70
|
+
...op,
|
|
71
|
+
status: "running",
|
|
72
|
+
nextAttemptAt: null,
|
|
73
|
+
lastError: null,
|
|
74
|
+
};
|
|
75
|
+
storage.putOperation(runningOp);
|
|
76
|
+
const item = toWritebackItem(storage, runningOp);
|
|
77
|
+
try {
|
|
78
|
+
callbacks.send(item);
|
|
79
|
+
storage.putOperation({ ...runningOp, status: "dispatched" });
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
const lastError = error instanceof Error ? error.message : "queue send failed";
|
|
84
|
+
const attemptCount = (op.attemptCount ?? 0) + 1;
|
|
85
|
+
if (attemptCount >= MAX_WRITEBACK_ATTEMPTS) {
|
|
86
|
+
const timestamp = now();
|
|
87
|
+
const deadLetteredOp = {
|
|
88
|
+
...op,
|
|
89
|
+
status: "dead_lettered",
|
|
90
|
+
attemptCount,
|
|
91
|
+
nextAttemptAt: null,
|
|
92
|
+
lastError,
|
|
93
|
+
};
|
|
94
|
+
storage.putOperation(deadLetteredOp);
|
|
95
|
+
createEvent(storage, {
|
|
96
|
+
type: "writeback.failed",
|
|
97
|
+
path: op.path,
|
|
98
|
+
revision: op.revision,
|
|
99
|
+
origin: "system",
|
|
100
|
+
provider: op.provider,
|
|
101
|
+
correlationId: op.correlationId,
|
|
102
|
+
timestamp,
|
|
103
|
+
});
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
const nextAttemptAt = addDelay(now(), WRITEBACK_RETRY_DELAY_MS);
|
|
107
|
+
const pendingOp = {
|
|
108
|
+
...op,
|
|
109
|
+
status: "pending",
|
|
110
|
+
attemptCount,
|
|
111
|
+
nextAttemptAt,
|
|
112
|
+
lastError,
|
|
113
|
+
};
|
|
114
|
+
storage.putOperation(pendingOp);
|
|
115
|
+
callbacks.onRetryScheduled?.(item, nextAttemptAt);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function normalizePath(path) {
|
|
120
|
+
const trimmed = path.trim();
|
|
121
|
+
if (!trimmed) {
|
|
122
|
+
return "/";
|
|
123
|
+
}
|
|
124
|
+
const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
125
|
+
return prefixed.length > 1 ? prefixed.replace(/\/+$/, "") : "/";
|
|
126
|
+
}
|
|
127
|
+
function normalizeError(errorMsg) {
|
|
128
|
+
return errorMsg?.trim() || "provider reported failure";
|
|
129
|
+
}
|
|
130
|
+
function addDelay(isoTimestamp, delayMs) {
|
|
131
|
+
const baseMs = Date.parse(isoTimestamp);
|
|
132
|
+
if (Number.isNaN(baseMs)) {
|
|
133
|
+
throw new Error("invalid timestamp");
|
|
134
|
+
}
|
|
135
|
+
return new Date(baseMs + delayMs).toISOString();
|
|
136
|
+
}
|
|
137
|
+
function defaultNow() {
|
|
138
|
+
return new Date().toISOString();
|
|
139
|
+
}
|
|
140
|
+
function toWritebackItem(storage, item) {
|
|
141
|
+
return {
|
|
142
|
+
id: item.opId,
|
|
143
|
+
workspaceId: storage.getWorkspaceId(),
|
|
144
|
+
path: item.path,
|
|
145
|
+
revision: item.revision,
|
|
146
|
+
correlationId: item.correlationId,
|
|
147
|
+
};
|
|
148
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@relayfile/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared business logic for relayfile — file operations, ACL, queries, events, and writeback lifecycle",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"typescript": "^5.7.3",
|
|
18
|
+
"vitest": "^3.0.0"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/AgentWorkforce/relayfile",
|
|
26
|
+
"directory": "packages/core"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"keywords": [
|
|
30
|
+
"relayfile",
|
|
31
|
+
"filesystem",
|
|
32
|
+
"agent",
|
|
33
|
+
"collaboration",
|
|
34
|
+
"core"
|
|
35
|
+
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
}
|
|
39
|
+
}
|