@relayfile/sdk 0.6.3 → 0.6.4

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/index.d.ts CHANGED
@@ -4,12 +4,14 @@ export { type RelayfileCloudLoginOptions, type RelayfileCloudTokenSet, type Rela
4
4
  export { CloudAbortError, CloudApiError, CloudTimeoutError, IntegrationConnectionTimeoutError, MalformedCloudResponseError, MissingConnectionIdError, RelayfileSetupError, UnknownProviderError } from "./setup-errors.js";
5
5
  export { WORKSPACE_INTEGRATION_PROVIDERS, type AgentWorkspaceInvite, type AgentWorkspaceInviteOptions, type ConnectIntegrationOptions, type ConnectIntegrationResult, type CreateWorkspaceOptions, type JoinWorkspaceOptions, type RelayfileSetupOptions, type RelayfileSetupRetryOptions, type WaitForConnectionOptions, type WorkspaceInfo, type WorkspaceIntegrationProvider, type WorkspaceMountEnv, type WorkspaceMountEnvOptions, type WorkspacePermissions } from "./setup-types.js";
6
6
  export { RelayFileSync, type RelayFileSyncOptions, type RelayFileSyncPong, type RelayFileSyncReconnectOptions, type RelayFileSyncSocket, type RelayFileSyncState } from "./sync.js";
7
+ export { onWrite, pathMatches, type OnWriteClient, type OnWriteHandler, type OnWriteHandlerError, type OnWriteOptions } from "./onWrite.js";
7
8
  export { InvalidStateError, PayloadTooLargeError, QueueFullError, RelayFileApiError, RevisionConflictError } from "./errors.js";
8
9
  export { IntegrationProvider, computeCanonicalPath } from "./provider.js";
9
10
  export type { WebhookInput, ListProviderFilesOptions, WatchProviderEventsOptions } from "./provider.js";
10
11
  export type { ConnectionProvider, NormalizedWebhook, ProxyHeaders, ProxyMethod, ProxyQuery, ProxyRequest, ProxyResponse, } from "./connection.js";
11
12
  export type { AckResponse, AckWritebackInput, AckWritebackResponse, AdminIngressAlert, AdminIngressAlertProfile, AdminIngressEffectiveAlertProfile, AdminIngressAlertSeverity, AdminIngressAlertThresholds, AdminIngressAlertTotals, AdminIngressAlertType, AdminIngressStatusResponse, AdminSyncAlert, AdminSyncAlertSeverity, AdminSyncAlertThresholds, AdminSyncAlertTotals, AdminSyncAlertType, AdminSyncStatusResponse, BackendStatusResponse, BulkWriteFile, BulkWriteInput, BulkWriteResponse, CommitForkInput, CommitForkResponse, ConflictErrorResponse, CreateForkInput, ContentIdentity, DeleteFileInput, DeadLetterFeedResponse, DeadLetterItem, DiscardForkInput, ErrorResponse, EventFeedResponse, ExportFormat, ExportJsonResponse, ExportOptions, FileQueryItem, FileQueryResponse, FileReadResponse, FileSemantics, FileWriteRequest, FilesystemEvent, FilesystemEventType, EventOrigin, GetEventsOptions, GetAdminSyncStatusOptions, GetAdminIngressStatusOptions, GetOperationsOptions, GetSyncDeadLettersOptions, GetSyncIngressStatusOptions, GetSyncStatusOptions, IngestWebhookInput, ListTreeOptions, OperationFeedResponse, OperationStatus, OperationStatusResponse, QueuedResponse, QueryFilesOptions, ReadFileInput, RelayFileJwtClaims, SyncIngressStatusResponse, SyncProviderStatus, SyncProviderStatusState, SyncRefreshRequest, SyncStatusResponse, TreeEntry, TreeResponse, WritebackActionType, WritebackState, WritebackItem, WriteFileInput, WriteQueuedResponse } from "./types.js";
12
13
  export type { ForkHandle, ForkOptions } from "@relayfile/core";
14
+ export type { WriteEvent, WriteEventActor, WriteEventOperation, WriteEventSource } from "@relayfile/core";
13
15
  export { WritebackConsumer } from "./writeback-consumer.js";
14
16
  export type { WritebackHandler, WritebackConsumerOptions } from "./writeback-consumer.js";
15
17
  export * from "./integration-adapter.js";
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ export { RelayfileSetup, RELAYFILE_SDK_VERSION, WorkspaceHandle } from "./setup.
3
3
  export { CloudAbortError, CloudApiError, CloudTimeoutError, IntegrationConnectionTimeoutError, MalformedCloudResponseError, MissingConnectionIdError, RelayfileSetupError, UnknownProviderError } from "./setup-errors.js";
4
4
  export { WORKSPACE_INTEGRATION_PROVIDERS } from "./setup-types.js";
5
5
  export { RelayFileSync } from "./sync.js";
6
+ export { onWrite, pathMatches } from "./onWrite.js";
6
7
  export { InvalidStateError, PayloadTooLargeError, QueueFullError, RelayFileApiError, RevisionConflictError } from "./errors.js";
7
8
  // Integration providers
8
9
  export { IntegrationProvider, computeCanonicalPath } from "./provider.js";
@@ -0,0 +1,24 @@
1
+ import type { WriteEvent, WriteEventOperation } from "@relayfile/core";
2
+ import { RelayFileClient } from "./client.js";
3
+ import { type RelayFileSyncSocket } from "./sync.js";
4
+ export type OnWriteHandler = (event: WriteEvent) => void | Promise<void>;
5
+ export interface OnWriteHandlerError {
6
+ pattern: string;
7
+ path: string;
8
+ error: unknown;
9
+ retryable: false;
10
+ }
11
+ export type OnWriteClient = RelayFileClient & {
12
+ recordHandlerError?(error: OnWriteHandlerError): void | Promise<void>;
13
+ };
14
+ export interface OnWriteOptions {
15
+ client?: OnWriteClient;
16
+ workspaceId?: string;
17
+ operations?: WriteEventOperation[];
18
+ signal?: AbortSignal;
19
+ baseUrl?: string;
20
+ token?: string;
21
+ webSocketFactory?: (url: string) => RelayFileSyncSocket;
22
+ }
23
+ export declare function pathMatches(pattern: string, path: string): boolean;
24
+ export declare function onWrite(pattern: string, handler: OnWriteHandler, options?: OnWriteOptions): () => void;
@@ -0,0 +1,268 @@
1
+ import { RelayFileClient, DEFAULT_RELAYFILE_BASE_URL } from "./client.js";
2
+ import { RelayFileSync } from "./sync.js";
3
+ const DEFAULT_OPERATIONS = ["create", "update"];
4
+ const DEFAULT_RECONNECT_MIN_DELAY_MS = 1000;
5
+ const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000;
6
+ const dispatchers = new WeakMap();
7
+ let nextRegistrationId = 1;
8
+ let defaultClient;
9
+ export function pathMatches(pattern, path) {
10
+ const patternSegments = normalizePattern(pattern);
11
+ const pathSegments = normalizePath(path);
12
+ return matchSegments(patternSegments, pathSegments);
13
+ }
14
+ export function onWrite(pattern, handler, options = {}) {
15
+ const normalizedPattern = `/${normalizePattern(pattern).join("/")}`;
16
+ if (typeof handler !== "function") {
17
+ throw new Error("onWrite handler must be a function.");
18
+ }
19
+ const client = options.client ?? getDefaultClient();
20
+ const workspaceId = options.workspaceId ?? readEnv("RELAYFILE_WORKSPACE_ID");
21
+ if (!workspaceId) {
22
+ throw new Error("onWrite requires options.workspaceId or RELAYFILE_WORKSPACE_ID.");
23
+ }
24
+ const operations = new Set(options.operations ?? DEFAULT_OPERATIONS);
25
+ for (const operation of operations) {
26
+ if (operation !== "create" && operation !== "update" && operation !== "delete") {
27
+ throw new Error(`Invalid onWrite operation: ${operation}`);
28
+ }
29
+ }
30
+ // The dispatcher cache is keyed by client; a single shared WebSocket is
31
+ // bound to one workspace. v1 scopes a client to a single workspace per the
32
+ // design doc (Out-of-scope: "Cross-workspace subscriptions"). Reject
33
+ // mismatched workspaceId rather than silently attaching to the wrong feed.
34
+ let dispatcher = dispatchers.get(client);
35
+ if (dispatcher && dispatcher.workspaceId !== workspaceId) {
36
+ throw new Error(`onWrite registrations on the same client must use the same workspaceId. Existing="${dispatcher.workspaceId}", new="${workspaceId}". Construct a separate RelayFileClient per workspace.`);
37
+ }
38
+ if (!dispatcher) {
39
+ dispatcher = new OnWriteDispatcher(client, workspaceId);
40
+ dispatchers.set(client, dispatcher);
41
+ }
42
+ const registration = {
43
+ id: nextRegistrationId++,
44
+ pattern: normalizedPattern,
45
+ operations,
46
+ handler
47
+ };
48
+ dispatcher.register(registration, {
49
+ workspaceId,
50
+ signal: options.signal,
51
+ baseUrl: options.baseUrl,
52
+ token: options.token,
53
+ webSocketFactory: options.webSocketFactory
54
+ });
55
+ return () => {
56
+ dispatcher?.unregister(registration.id);
57
+ };
58
+ }
59
+ class OnWriteDispatcher {
60
+ client;
61
+ // Captured at construction. RelayFileSync normalizes the FilesystemEvent
62
+ // shape and does not surface workspaceId on emitted events, so we stamp the
63
+ // subscribed workspaceId onto every WriteEvent we hand to user handlers.
64
+ // It also gates registrations: see the cross-workspace check in onWrite().
65
+ workspaceId;
66
+ registrations = [];
67
+ patternChains = new Map();
68
+ sync;
69
+ constructor(client, workspaceId) {
70
+ this.client = client;
71
+ this.workspaceId = workspaceId;
72
+ }
73
+ register(registration, options) {
74
+ this.registrations.push(registration);
75
+ if (options.signal) {
76
+ if (options.signal.aborted) {
77
+ this.unregister(registration.id);
78
+ return;
79
+ }
80
+ options.signal.addEventListener("abort", () => this.unregister(registration.id), { once: true });
81
+ }
82
+ this.ensureSync(options);
83
+ }
84
+ unregister(id) {
85
+ const index = this.registrations.findIndex((registration) => registration.id === id);
86
+ if (index >= 0) {
87
+ this.registrations.splice(index, 1);
88
+ }
89
+ if (this.registrations.length === 0 && this.sync) {
90
+ void this.sync.stop();
91
+ this.sync = undefined;
92
+ }
93
+ }
94
+ ensureSync(options) {
95
+ if (this.sync) {
96
+ return;
97
+ }
98
+ this.sync = RelayFileSync.connect({
99
+ client: this.client,
100
+ workspaceId: options.workspaceId,
101
+ baseUrl: options.baseUrl ?? readEnv("RELAYFILE_BASE_URL") ?? DEFAULT_RELAYFILE_BASE_URL,
102
+ token: options.token ?? readEnv("RELAYFILE_TOKEN"),
103
+ reconnect: {
104
+ minDelayMs: DEFAULT_RECONNECT_MIN_DELAY_MS,
105
+ maxDelayMs: DEFAULT_RECONNECT_MAX_DELAY_MS
106
+ },
107
+ webSocketFactory: options.webSocketFactory,
108
+ onEvent: (event) => {
109
+ void this.dispatch(event);
110
+ }
111
+ });
112
+ }
113
+ async dispatch(event) {
114
+ const writeEvent = toWriteEvent(event, this.workspaceId);
115
+ if (!writeEvent) {
116
+ return;
117
+ }
118
+ for (const registration of [...this.registrations]) {
119
+ if (!registration.operations.has(writeEvent.operation) || !pathMatches(registration.pattern, writeEvent.path)) {
120
+ continue;
121
+ }
122
+ const previous = this.patternChains.get(registration.pattern) ?? Promise.resolve();
123
+ const next = previous
124
+ .catch(() => undefined)
125
+ .then(() => this.runHandler(registration, writeEvent));
126
+ this.patternChains.set(registration.pattern, next);
127
+ void next.finally(() => {
128
+ if (this.patternChains.get(registration.pattern) === next) {
129
+ this.patternChains.delete(registration.pattern);
130
+ }
131
+ });
132
+ }
133
+ }
134
+ async runHandler(registration, event) {
135
+ try {
136
+ await registration.handler(event);
137
+ }
138
+ catch (error) {
139
+ await this.recordHandlerError({
140
+ pattern: registration.pattern,
141
+ path: event.path,
142
+ error,
143
+ retryable: false
144
+ });
145
+ }
146
+ }
147
+ // The "handler errors do not propagate" guarantee covers the recorder too:
148
+ // if the customer's recordHandlerError implementation throws or rejects, fall
149
+ // back to console.error rather than letting the rejection bubble up the
150
+ // dispatch chain (which would skip subsequent handlers for the same pattern).
151
+ async recordHandlerError(error) {
152
+ if (typeof this.client.recordHandlerError === "function") {
153
+ try {
154
+ await this.client.recordHandlerError(error);
155
+ return;
156
+ }
157
+ catch (reportingError) {
158
+ if (typeof console !== "undefined" && typeof console.error === "function") {
159
+ console.error("Relayfile onWrite handler-error reporter failed", reportingError);
160
+ }
161
+ }
162
+ }
163
+ if (typeof console !== "undefined" && typeof console.error === "function") {
164
+ console.error("Relayfile onWrite handler error", error);
165
+ }
166
+ }
167
+ }
168
+ function normalizePattern(pattern) {
169
+ if (typeof pattern !== "string" || pattern.length === 0) {
170
+ throw new Error("onWrite pattern must be a non-empty string.");
171
+ }
172
+ if (!pattern.startsWith("/")) {
173
+ throw new Error("onWrite pattern must start with '/'.");
174
+ }
175
+ if (pattern.includes("//")) {
176
+ throw new Error("onWrite pattern cannot contain empty path segments.");
177
+ }
178
+ const segments = normalizePath(pattern);
179
+ const recursiveIndex = segments.indexOf("**");
180
+ if (recursiveIndex >= 0 && recursiveIndex !== segments.length - 1) {
181
+ throw new Error("onWrite pattern only supports '**' as the trailing segment.");
182
+ }
183
+ return segments;
184
+ }
185
+ function normalizePath(path) {
186
+ if (!path.startsWith("/")) {
187
+ return normalizePath(`/${path}`);
188
+ }
189
+ const trimmed = path.replace(/\/+$/, "");
190
+ if (trimmed === "") {
191
+ return [];
192
+ }
193
+ return trimmed.split("/").filter(Boolean);
194
+ }
195
+ // Trailing `**` matches **zero or more** trailing segments — same as gitignore
196
+ // and standard glob conventions, and what the design doc specifies ("any
197
+ // number of segments"). `/linear/issues/**` therefore matches both
198
+ // `/linear/issues` (the collection root) and `/linear/issues/PROJ-1/comments`.
199
+ // `*` matches exactly one segment; `**` is only valid as the last segment.
200
+ function matchSegments(pattern, path) {
201
+ if (pattern.length > 0 && pattern[pattern.length - 1] === "**") {
202
+ const prefix = pattern.slice(0, -1);
203
+ return path.length >= prefix.length && prefix.every((segment, index) => segment === "*" || segment === path[index]);
204
+ }
205
+ if (pattern.length !== path.length) {
206
+ return false;
207
+ }
208
+ return pattern.every((segment, index) => segment === "*" || segment === path[index]);
209
+ }
210
+ function toWriteEvent(event, workspaceId) {
211
+ const operation = operationFromEventType(event.type);
212
+ if (!operation) {
213
+ return null;
214
+ }
215
+ // RelayFileSync currently surfaces only:
216
+ // eventId, type, path, revision, origin, provider, correlationId, timestamp
217
+ // Fields the wider WriteEvent contract advertises but the wire format does
218
+ // not yet preserve — previousRevision, value, actor — are intentionally
219
+ // omitted/null here. Wiring them through the wire format and
220
+ // normalizeFilesystemEvent is a follow-up; v1 callers should treat
221
+ // previousRevision/value/actor as not-yet-populated.
222
+ return {
223
+ workspaceId: workspaceId ?? "",
224
+ path: event.path,
225
+ operation,
226
+ revision: event.revision,
227
+ previousRevision: null,
228
+ timestamp: event.timestamp,
229
+ source: sourceFromOrigin(event.origin)
230
+ };
231
+ }
232
+ function operationFromEventType(type) {
233
+ if (type === "file.created") {
234
+ return "create";
235
+ }
236
+ if (type === "file.updated") {
237
+ return "update";
238
+ }
239
+ if (type === "file.deleted") {
240
+ return "delete";
241
+ }
242
+ return null;
243
+ }
244
+ function sourceFromOrigin(origin) {
245
+ if (origin === "agent_write") {
246
+ return "agent";
247
+ }
248
+ if (origin === "provider_sync") {
249
+ return "sync";
250
+ }
251
+ return "api";
252
+ }
253
+ function getDefaultClient() {
254
+ if (!defaultClient) {
255
+ const token = readEnv("RELAYFILE_TOKEN");
256
+ if (!token) {
257
+ throw new Error("onWrite requires options.client or RELAYFILE_TOKEN.");
258
+ }
259
+ defaultClient = new RelayFileClient({
260
+ baseUrl: readEnv("RELAYFILE_BASE_URL") ?? DEFAULT_RELAYFILE_BASE_URL,
261
+ token
262
+ });
263
+ }
264
+ return defaultClient;
265
+ }
266
+ function readEnv(name) {
267
+ return globalThis.process?.env?.[name];
268
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayfile/sdk",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "TypeScript SDK for relayfile — real-time filesystem for humans and agents",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -20,7 +20,7 @@
20
20
  "prepublishOnly": "npm run build"
21
21
  },
22
22
  "dependencies": {
23
- "@relayfile/core": "0.6.3"
23
+ "@relayfile/core": "0.6.4"
24
24
  },
25
25
  "devDependencies": {
26
26
  "typescript": "^5.7.3",