@relayfile/core 0.8.14 → 0.8.17

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.
@@ -15,6 +15,7 @@ describe("dedup type contracts", () => {
15
15
  const identity = {
16
16
  kind: "push",
17
17
  key: "repo:abc:main",
18
+ ttlSeconds: 300,
18
19
  };
19
20
  const store = {
20
21
  async has(key) {
package/dist/dedup.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export interface ContentIdentity {
2
2
  kind: string;
3
3
  key: string;
4
+ ttlSeconds?: number;
4
5
  }
5
6
  export interface DedupEntry {
6
7
  contentIdentity?: ContentIdentity;
@@ -87,6 +87,6 @@ export interface WebhookStorageAdapter extends StorageAdapter {
87
87
  }
88
88
  export declare function ingestWebhook(storage: StorageAdapter, input: IngestWebhookInput, options?: IngestWebhookOptions): IngestResult;
89
89
  export declare function normalizeEnvelope(input: IngestWebhookInput, now?: () => string): Partial<EnvelopeRow>;
90
- export declare function normalizeEnvelopeEvent(envelope: Pick<EnvelopeRow, "payload" | "receivedAt">): EnvelopeEvent | null;
91
- export declare function normalizeEnvelopePath(envelope: Pick<EnvelopeRow, "payload">): string | null;
90
+ export declare function normalizeEnvelopeEvent(envelope: Pick<EnvelopeRow, "payload" | "receivedAt"> & Partial<Pick<EnvelopeRow, "provider">>): EnvelopeEvent | null;
91
+ export declare function normalizeEnvelopePath(envelope: Pick<EnvelopeRow, "payload"> & Partial<Pick<EnvelopeRow, "provider">>): string | null;
92
92
  export declare function applyWebhookEnvelope(storage: StorageAdapter, envelope: EnvelopeRow, options?: ApplyEnvelopeOptions): ApplyEnvelopeResult;
package/dist/webhooks.js CHANGED
@@ -43,7 +43,7 @@ export function ingestWebhook(storage, input, options = {}) {
43
43
  const normalized = normalizeEnvelope(input, now);
44
44
  const provider = asOptionalString(normalized.provider) ?? "";
45
45
  const payload = asRecord(normalized.payload);
46
- const path = normalizeEnvelopePath({ payload });
46
+ const path = normalizeEnvelopePath({ payload, provider });
47
47
  const receivedAt = asOptionalString(normalized.receivedAt) ?? now();
48
48
  const workspaceId = storage.getWorkspaceId();
49
49
  const deliveryId = asOptionalString(normalized.deliveryId) ??
@@ -69,7 +69,7 @@ export function ingestWebhook(storage, input, options = {}) {
69
69
  status: "queued",
70
70
  limit: 100,
71
71
  }).items;
72
- const coalesced = findCoalescedEnvelope(queued, payload, receivedAt, options.coalesceWindowMs ?? DEFAULT_COALESCE_WINDOW_MS);
72
+ const coalesced = findCoalescedEnvelope(queued, payload, provider, receivedAt, options.coalesceWindowMs ?? DEFAULT_COALESCE_WINDOW_MS);
73
73
  if (coalesced) {
74
74
  const updated = {
75
75
  ...coalesced,
@@ -115,11 +115,13 @@ export function ingestWebhook(storage, input, options = {}) {
115
115
  export function normalizeEnvelope(input, now = nowIso) {
116
116
  const provider = normalizeProvider(input.provider);
117
117
  const eventType = normalizeEventType(input.eventType);
118
- const path = normalizePath(input.path ?? "");
118
+ const canonicalPath = input.path?.trim()
119
+ ? canonicalProviderEnvelopePath(provider, input.path)
120
+ : null;
119
121
  const correlationId = input.correlationId?.trim() ?? "";
120
122
  const receivedAt = normalizeIsoDate(input.timestamp) ?? now();
121
123
  const deliveryId = input.deliveryId?.trim();
122
- if (!provider || !eventType || !input.path?.trim()) {
124
+ if (!provider || !eventType || !input.path?.trim() || !canonicalPath) {
123
125
  return {
124
126
  provider,
125
127
  deliveryId,
@@ -137,7 +139,7 @@ export function normalizeEnvelope(input, now = nowIso) {
137
139
  payload: {
138
140
  provider,
139
141
  event_type: eventType,
140
- path,
142
+ path: canonicalPath,
141
143
  timestamp: receivedAt,
142
144
  data: asRecord(input.data),
143
145
  delivery_id: deliveryId,
@@ -151,7 +153,10 @@ export function normalizeEnvelopeEvent(envelope) {
151
153
  if (!eventType) {
152
154
  return null;
153
155
  }
154
- const path = normalizePath(asOptionalString(payload.path) ?? "/");
156
+ const path = normalizeEnvelopePath(envelope);
157
+ if (!path) {
158
+ return null;
159
+ }
155
160
  const data = asRecord(payload.data);
156
161
  const body = Object.keys(data).length > 0 ? data : payload;
157
162
  const timestamp = normalizeIsoDate(asOptionalString(payload.timestamp) ?? envelope.receivedAt) ??
@@ -179,11 +184,13 @@ export function normalizeEnvelopeEvent(envelope) {
179
184
  }
180
185
  export function normalizeEnvelopePath(envelope) {
181
186
  const path = asOptionalString(envelope.payload.path);
182
- return path ? normalizePath(path) : null;
187
+ const provider = asOptionalString(envelope.provider) ??
188
+ asOptionalString(envelope.payload.provider);
189
+ return path ? canonicalProviderEnvelopePath(provider, path) : null;
183
190
  }
184
191
  export function applyWebhookEnvelope(storage, envelope, options = {}) {
185
- const event = normalizeEnvelopeEvent(envelope);
186
- if (!event) {
192
+ const normalizedEvent = normalizeEnvelopeEvent(envelope);
193
+ if (!normalizedEvent) {
187
194
  return {
188
195
  status: "ignored",
189
196
  eventType: null,
@@ -191,6 +198,7 @@ export function applyWebhookEnvelope(storage, envelope, options = {}) {
191
198
  revision: null,
192
199
  };
193
200
  }
201
+ const event = canonicalizeEnvelopeEventPath(storage, envelope.provider, normalizedEvent);
194
202
  if (options.shouldSuppress?.(envelope, event)) {
195
203
  const revision = appendSyncEvent(storage, "sync.suppressed", event.path, envelope.provider, envelope.correlationId, options.now?.() ?? event.timestamp);
196
204
  return {
@@ -356,14 +364,14 @@ function appendSyncEvent(storage, type, path, provider, correlationId, timestamp
356
364
  });
357
365
  return revision;
358
366
  }
359
- function findCoalescedEnvelope(envelopes, payload, receivedAt, windowMs) {
360
- const key = coalesceObjectKey(payload);
367
+ function findCoalescedEnvelope(envelopes, payload, provider, receivedAt, windowMs) {
368
+ const key = coalesceObjectKey(payload, provider);
361
369
  if (!key) {
362
370
  return null;
363
371
  }
364
372
  let match = null;
365
373
  for (const envelope of envelopes) {
366
- if (coalesceObjectKey(envelope.payload) !== key) {
374
+ if (coalesceObjectKey(envelope.payload, envelope.provider) !== key) {
367
375
  continue;
368
376
  }
369
377
  if (!withinCoalesceWindow(envelope.receivedAt, receivedAt, windowMs)) {
@@ -381,7 +389,7 @@ function mergeDeliveryIds(envelope, deliveryId) {
381
389
  function deliveryMatches(envelope, deliveryId) {
382
390
  return envelope.deliveryId === deliveryId || envelope.deliveryIds?.includes(deliveryId) === true;
383
391
  }
384
- function coalesceObjectKey(payload) {
392
+ function coalesceObjectKey(payload, provider) {
385
393
  const data = asRecord(payload.data);
386
394
  const providerObjectId = asOptionalString(data.providerObjectId) ??
387
395
  asOptionalString(data.provider_object_id) ??
@@ -392,9 +400,77 @@ function coalesceObjectKey(payload) {
392
400
  if (providerObjectId) {
393
401
  return `object:${providerObjectId}`;
394
402
  }
395
- const path = normalizeEnvelopePath({ payload });
403
+ const path = normalizeEnvelopePath({ payload, provider });
396
404
  return path && path !== "/" ? `path:${path}` : "";
397
405
  }
406
+ const PROVIDER_RELATIVE_PATH_ROOTS = {
407
+ slack: new Set(["channels", "dms", "teams", "users"]),
408
+ };
409
+ function canonicalProviderEnvelopePath(provider, rawPath) {
410
+ const path = normalizePath(rawPath);
411
+ const normalizedProvider = normalizeProvider(provider);
412
+ if (!normalizedProvider || path === "/") {
413
+ return path;
414
+ }
415
+ const relativeRoots = PROVIDER_RELATIVE_PATH_ROOTS[normalizedProvider];
416
+ if (!relativeRoots) {
417
+ return path;
418
+ }
419
+ const trimmed = path.slice(1);
420
+ if (trimmed === normalizedProvider ||
421
+ trimmed.startsWith(`${normalizedProvider}/`)) {
422
+ return path;
423
+ }
424
+ const [firstSegment] = trimmed.split("/", 1);
425
+ if (firstSegment && relativeRoots.has(firstSegment)) {
426
+ return normalizePath(`/${normalizedProvider}/${trimmed}`);
427
+ }
428
+ return null;
429
+ }
430
+ function canonicalizeEnvelopeEventPath(storage, provider, event) {
431
+ const path = canonicalizeExistingProviderAliasPath(storage, provider, event.path);
432
+ return path === event.path ? event : { ...event, path };
433
+ }
434
+ function canonicalizeExistingProviderAliasPath(storage, provider, rawPath) {
435
+ const path = normalizePath(rawPath);
436
+ if (normalizeProvider(provider) !== "slack") {
437
+ return path;
438
+ }
439
+ if (!parseRawSlackChannelAliasPath(path)) {
440
+ return path;
441
+ }
442
+ return canonicalizeSlackChannelAliasPath(storage.listFiles(), path);
443
+ }
444
+ function canonicalizeSlackChannelAliasPath(files, rawPath) {
445
+ const parsed = parseRawSlackChannelAliasPath(rawPath);
446
+ if (!parsed) {
447
+ return normalizePath(rawPath);
448
+ }
449
+ const { path, parts, channelSegment } = parsed;
450
+ const prefix = `/slack/channels/${channelSegment}__`;
451
+ const candidates = files
452
+ .map((file) => normalizePath(file.path))
453
+ .filter((filePath) => filePath.startsWith(prefix))
454
+ .map((filePath) => filePath.slice(1).split("/")[2])
455
+ .sort();
456
+ if (candidates.length === 0) {
457
+ return path;
458
+ }
459
+ parts[2] = candidates[0];
460
+ return normalizePath(`/${parts.join("/")}`);
461
+ }
462
+ function parseRawSlackChannelAliasPath(rawPath) {
463
+ const path = normalizePath(rawPath);
464
+ const parts = path.slice(1).split("/");
465
+ if (parts.length < 3 || parts[0] !== "slack" || parts[1] !== "channels") {
466
+ return null;
467
+ }
468
+ const channelSegment = parts[2];
469
+ if (!channelSegment || channelSegment.includes("__")) {
470
+ return null;
471
+ }
472
+ return { path, parts, channelSegment };
473
+ }
398
474
  function withinCoalesceWindow(existingReceivedAt, incomingReceivedAt, windowMs) {
399
475
  if (windowMs <= 0) {
400
476
  return false;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,265 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { applyWebhookEnvelope, ingestWebhook, normalizeEnvelopeEvent, normalizeEnvelopePath, } from "./webhooks.js";
3
+ describe("webhook Slack path canonicalization", () => {
4
+ it("canonicalizes Slack provider-relative envelope paths", () => {
5
+ const event = normalizeEnvelopeEvent({
6
+ provider: "slack",
7
+ receivedAt: "2026-06-07T20:00:00.000Z",
8
+ payload: {
9
+ provider: "slack",
10
+ event_type: "file.updated",
11
+ path: "/channels/C123/messages/1711111111_000100/meta.json",
12
+ },
13
+ });
14
+ expect(event?.path).toBe("/slack/channels/C123/messages/1711111111_000100/meta.json");
15
+ expect(normalizeEnvelopePath({
16
+ provider: "slack",
17
+ payload: {
18
+ provider: "slack",
19
+ path: "/channels/C123/messages/1711111111_000100/meta.json",
20
+ },
21
+ })).toBe("/slack/channels/C123/messages/1711111111_000100/meta.json");
22
+ });
23
+ it("resolves raw Slack channel IDs to existing channelId__name aliases before writes and events", () => {
24
+ const storage = new MemoryStorage([
25
+ fileRow("/slack/channels/C123__engineering/meta.json", {
26
+ content: '{"id":"C123","name":"engineering"}',
27
+ provider: "slack",
28
+ }),
29
+ ]);
30
+ const result = applyWebhookEnvelope(storage, {
31
+ envelopeId: "env_1",
32
+ workspaceId: "ws_core",
33
+ provider: "slack",
34
+ deliveryId: "delivery_1",
35
+ receivedAt: "2026-06-07T20:00:00.000Z",
36
+ payload: {
37
+ provider: "slack",
38
+ event_type: "file.updated",
39
+ path: "/slack/channels/C123/messages/1711111111_000100/meta.json",
40
+ content: '{"text":"hello"}',
41
+ contentType: "application/json",
42
+ },
43
+ correlationId: "corr_1",
44
+ status: "queued",
45
+ attemptCount: 0,
46
+ lastError: null,
47
+ });
48
+ const canonicalPath = "/slack/channels/C123__engineering/messages/1711111111_000100/meta.json";
49
+ expect(result).toMatchObject({
50
+ status: "processed",
51
+ path: canonicalPath,
52
+ });
53
+ expect(storage.getFile(canonicalPath)?.content).toBe('{"text":"hello"}');
54
+ expect(storage.getFile("/slack/channels/C123/messages/1711111111_000100/meta.json")).toBeNull();
55
+ expect(storage.events.at(-1)?.path).toBe(canonicalPath);
56
+ expect(storage.listFilesCalls).toBe(1);
57
+ });
58
+ it("does not scan storage when Slack channel alias resolution is unnecessary", () => {
59
+ const storage = new MemoryStorage([
60
+ fileRow("/slack/channels/C123__engineering/meta.json", {
61
+ content: '{"id":"C123","name":"engineering"}',
62
+ provider: "slack",
63
+ }),
64
+ ]);
65
+ const aliasedResult = applyWebhookEnvelope(storage, {
66
+ envelopeId: "env_aliased",
67
+ workspaceId: "ws_core",
68
+ provider: "slack",
69
+ deliveryId: "delivery_aliased",
70
+ receivedAt: "2026-06-07T20:00:00.000Z",
71
+ payload: {
72
+ provider: "slack",
73
+ event_type: "file.updated",
74
+ path: "/slack/channels/C123__engineering/messages/1711111111_000100/meta.json",
75
+ content: '{"text":"hello"}',
76
+ contentType: "application/json",
77
+ },
78
+ correlationId: "corr_aliased",
79
+ status: "queued",
80
+ attemptCount: 0,
81
+ lastError: null,
82
+ });
83
+ const dmResult = applyWebhookEnvelope(storage, {
84
+ envelopeId: "env_dm",
85
+ workspaceId: "ws_core",
86
+ provider: "slack",
87
+ deliveryId: "delivery_dm",
88
+ receivedAt: "2026-06-07T20:00:01.000Z",
89
+ payload: {
90
+ provider: "slack",
91
+ event_type: "file.updated",
92
+ path: "/slack/dms/D123/messages/1711111111_000100/meta.json",
93
+ content: '{"text":"dm"}',
94
+ contentType: "application/json",
95
+ },
96
+ correlationId: "corr_dm",
97
+ status: "queued",
98
+ attemptCount: 0,
99
+ lastError: null,
100
+ });
101
+ expect(aliasedResult).toMatchObject({
102
+ status: "processed",
103
+ path: "/slack/channels/C123__engineering/messages/1711111111_000100/meta.json",
104
+ });
105
+ expect(dmResult).toMatchObject({
106
+ status: "processed",
107
+ path: "/slack/dms/D123/messages/1711111111_000100/meta.json",
108
+ });
109
+ expect(storage.listFilesCalls).toBe(0);
110
+ });
111
+ it("ignores Slack envelopes targeting paths outside the Slack provider root", () => {
112
+ const storage = new MemoryStorage();
113
+ const result = applyWebhookEnvelope(storage, {
114
+ envelopeId: "env_1",
115
+ workspaceId: "ws_core",
116
+ provider: "slack",
117
+ deliveryId: "delivery_1",
118
+ receivedAt: "2026-06-07T20:00:00.000Z",
119
+ payload: {
120
+ provider: "slack",
121
+ event_type: "file.updated",
122
+ path: "/github/repos/acme/cloud/issues/1.json",
123
+ content: '{"title":"wrong provider"}',
124
+ },
125
+ correlationId: "corr_1",
126
+ status: "queued",
127
+ attemptCount: 0,
128
+ lastError: null,
129
+ });
130
+ expect(result).toEqual({
131
+ status: "ignored",
132
+ eventType: null,
133
+ path: null,
134
+ revision: null,
135
+ });
136
+ expect(storage.listFiles()).toEqual([]);
137
+ expect(storage.events).toEqual([]);
138
+ });
139
+ it("coalesces Slack provider-relative and canonical paths by canonical path", () => {
140
+ const storage = new MemoryStorage();
141
+ let nextEnvelopeId = 1;
142
+ const first = ingestWebhook(storage, {
143
+ provider: "slack",
144
+ eventType: "file.updated",
145
+ path: "/channels/C123/messages/1711111111_000100/meta.json",
146
+ deliveryId: "delivery_1",
147
+ timestamp: "2026-06-07T20:00:00.000Z",
148
+ correlationId: "corr_1",
149
+ }, {
150
+ generateEnvelopeId: () => `env_${nextEnvelopeId++}`,
151
+ coalesceWindowMs: 10_000,
152
+ });
153
+ const second = ingestWebhook(storage, {
154
+ provider: "slack",
155
+ eventType: "file.updated",
156
+ path: "/slack/channels/C123/messages/1711111111_000100/meta.json",
157
+ deliveryId: "delivery_2",
158
+ timestamp: "2026-06-07T20:00:01.000Z",
159
+ correlationId: "corr_2",
160
+ }, {
161
+ generateEnvelopeId: () => `env_${nextEnvelopeId++}`,
162
+ coalesceWindowMs: 10_000,
163
+ });
164
+ expect(first).toMatchObject({ status: "queued", envelopeId: "env_1" });
165
+ expect(second).toMatchObject({ status: "queued", envelopeId: "env_1" });
166
+ expect(Array.from(storage.envelopes)).toHaveLength(1);
167
+ const envelope = storage.envelopes.get("env_1");
168
+ expect(envelope?.deliveryIds).toEqual(["delivery_1", "delivery_2"]);
169
+ expect(envelope?.payload.path).toBe("/slack/channels/C123/messages/1711111111_000100/meta.json");
170
+ });
171
+ });
172
+ function fileRow(path, overrides = {}) {
173
+ return {
174
+ path,
175
+ revision: "rev_1",
176
+ contentType: "application/json",
177
+ content: "{}",
178
+ encoding: "utf-8",
179
+ provider: "",
180
+ lastEditedAt: "2026-06-07T20:00:00.000Z",
181
+ semantics: {},
182
+ ...overrides,
183
+ };
184
+ }
185
+ class MemoryStorage {
186
+ files = new Map();
187
+ events = [];
188
+ envelopes = new Map();
189
+ deliveryAliases = new Map();
190
+ revisionCounter = 1;
191
+ eventCounter = 1;
192
+ listFilesCalls = 0;
193
+ constructor(files = []) {
194
+ for (const file of files) {
195
+ this.files.set(file.path, file);
196
+ }
197
+ }
198
+ getFile(path) {
199
+ return this.files.get(path) ?? null;
200
+ }
201
+ listFiles() {
202
+ this.listFilesCalls += 1;
203
+ return Array.from(this.files.values());
204
+ }
205
+ putFile(file) {
206
+ this.files.set(file.path, file);
207
+ }
208
+ deleteFile(path) {
209
+ this.files.delete(path);
210
+ }
211
+ appendEvent(event) {
212
+ this.events.push(event);
213
+ }
214
+ listEvents(_options) {
215
+ return { items: this.events, nextCursor: null };
216
+ }
217
+ getRecentEvents(limit) {
218
+ return this.events.slice(-limit);
219
+ }
220
+ getOperation(_opId) {
221
+ return null;
222
+ }
223
+ putOperation(_op) {
224
+ return undefined;
225
+ }
226
+ listOperations(_options) {
227
+ return { items: [], nextCursor: null };
228
+ }
229
+ nextRevision() {
230
+ return `rev_${this.revisionCounter++}`;
231
+ }
232
+ nextOperationId() {
233
+ return "op_1";
234
+ }
235
+ nextEventId() {
236
+ return `evt_${this.eventCounter++}`;
237
+ }
238
+ enqueueWriteback(_item) {
239
+ return undefined;
240
+ }
241
+ getPendingWritebacks() {
242
+ return [];
243
+ }
244
+ getEnvelopeByDelivery(workspaceId, provider, deliveryId) {
245
+ const envelopeId = this.deliveryAliases.get(`${workspaceId}|${provider}|${deliveryId}`);
246
+ return envelopeId ? this.envelopes.get(envelopeId) ?? null : null;
247
+ }
248
+ putEnvelope(envelope) {
249
+ this.envelopes.set(envelope.envelopeId, envelope);
250
+ }
251
+ putEnvelopeDeliveryAlias(workspaceId, provider, deliveryId, envelopeId) {
252
+ this.deliveryAliases.set(`${workspaceId}|${provider}|${deliveryId}`, envelopeId);
253
+ }
254
+ listEnvelopes(options) {
255
+ return {
256
+ items: Array.from(this.envelopes.values()).filter((envelope) => (!options.workspaceId || envelope.workspaceId === options.workspaceId) &&
257
+ (!options.provider || envelope.provider === options.provider) &&
258
+ (!options.status || envelope.status === options.status)),
259
+ nextCursor: null,
260
+ };
261
+ }
262
+ getWorkspaceId() {
263
+ return "ws_core";
264
+ }
265
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayfile/core",
3
- "version": "0.8.14",
3
+ "version": "0.8.17",
4
4
  "description": "Shared business logic for relayfile — file operations, ACL, queries, events, and writeback lifecycle",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",