@rigxyz/tapd 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/bin.d.ts +2 -0
- package/dist/bin.js +886 -0
- package/dist/bin.js.map +1 -0
- package/dist/chunk-RQC73B5Y.js +1452 -0
- package/dist/chunk-RQC73B5Y.js.map +1 -0
- package/dist/index.d.ts +542 -0
- package/dist/index.js +64 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import { Ignore } from 'ignore';
|
|
2
|
+
import { GetBindingResponse, PrepareUploadRequest, PrepareUploadResponse, CompleteUploadRequest, CompleteUploadResponse, DownloadUrlResponse, SubmitChangeEvent, SubmitChangesResponse, ChangesSinceResponse, CreateInviteRequest, CreateInviteResponse, BindingInvite, ManifestSnapshotResponse, CreateBindingRequest, CreateBindingResponse, BindingDevice, CapabilityOp, Role, ChangeEvent } from '@tap/core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* .tapignore loader.
|
|
6
|
+
*
|
|
7
|
+
* Layers:
|
|
8
|
+
* 1. Built-in defaults — common secrets, dependencies, OS noise, and
|
|
9
|
+
* bulky generated artifacts (per design doc § "Large File Policy").
|
|
10
|
+
* A user CAN override these via `!pattern` in their .tapignore.
|
|
11
|
+
* 2. Project .tapignore.
|
|
12
|
+
* 3. Always-excluded paths — Tap's own local metadata. These cannot be
|
|
13
|
+
* re-enabled by `!pattern`; the relay would reject the upload anyway
|
|
14
|
+
* (validateRigPath rejects reserved paths).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
declare const BUILTIN_IGNORE_LINES: ReadonlyArray<string>;
|
|
18
|
+
declare function loadIgnore(rootDir: string): Ignore;
|
|
19
|
+
/**
|
|
20
|
+
* Hard-exclude check that runs in addition to .tapignore. Tap's own
|
|
21
|
+
* local metadata is invisible to sync no matter what the user puts in
|
|
22
|
+
* their .tapignore — including `!pattern` overrides.
|
|
23
|
+
*/
|
|
24
|
+
declare function isAlwaysExcluded(posixPath: string): boolean;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Recursive directory walker for a rig. Yields:
|
|
28
|
+
* - `files`: candidates for upload (size + posix path relative to root)
|
|
29
|
+
* - `emptyDirs`: paths to mkdir markers (dirs with no tracked children)
|
|
30
|
+
* - `warnings`: paths that won't sync (oversized, symlink, invalid path)
|
|
31
|
+
*
|
|
32
|
+
* Rules enforced:
|
|
33
|
+
* - `.tapignore` (loadIgnore)
|
|
34
|
+
* - Tap's own reserved metadata (isAlwaysExcluded)
|
|
35
|
+
* - MAX_FILE_BYTES (100 MB) — daemon refuses upload above this
|
|
36
|
+
* - Symlinks (lstat-detected) — rejected; warning emitted
|
|
37
|
+
* - validateRigPath — defends against control chars / weird segments
|
|
38
|
+
* before the relay sees them
|
|
39
|
+
*
|
|
40
|
+
* Path strings are POSIX (forward slashes) even on Windows so they
|
|
41
|
+
* match what the relay stores and what the manifest returns.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
type WalkFile = {
|
|
45
|
+
path: string;
|
|
46
|
+
size: number;
|
|
47
|
+
};
|
|
48
|
+
type WalkWarning = {
|
|
49
|
+
path: string;
|
|
50
|
+
reason: "too_large" | "symlink" | "invalid_path" | "unreadable";
|
|
51
|
+
};
|
|
52
|
+
type WalkResult = {
|
|
53
|
+
files: WalkFile[];
|
|
54
|
+
emptyDirs: string[];
|
|
55
|
+
/**
|
|
56
|
+
* Every directory visited during the walk, including non-empty ones.
|
|
57
|
+
* Used by `scanAndPush` to detect "previously-tracked dir no longer
|
|
58
|
+
* on disk" cases that emptyDirs alone wouldn't catch — e.g. a
|
|
59
|
+
* directory that was empty-and-tracked, then gained files (now
|
|
60
|
+
* non-empty and on disk: should NOT be deleted) vs one that was
|
|
61
|
+
* `rm -rf`d (gone from disk: should be rmdir'd).
|
|
62
|
+
*/
|
|
63
|
+
directoriesScanned: string[];
|
|
64
|
+
warnings: WalkWarning[];
|
|
65
|
+
};
|
|
66
|
+
declare function walk(rootDir: string, opts: {
|
|
67
|
+
ignore: Ignore;
|
|
68
|
+
}): WalkResult;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Thin fetch wrapper around the Phase 1 relay routes.
|
|
72
|
+
*
|
|
73
|
+
* Module-level `createBinding` is unauthenticated (Phase 3 will Clerk-gate
|
|
74
|
+
* it). Everything else binds to a (baseUrl, bindingId, token) triple via
|
|
75
|
+
* the `RelayClient` class, since every binding-scoped route needs the
|
|
76
|
+
* same three values on every call.
|
|
77
|
+
*
|
|
78
|
+
* `RelayError` carries the route + status + parsed body so caller logic
|
|
79
|
+
* can map specific 400s ("object_not_uploaded", "case_collision_with",
|
|
80
|
+
* etc.) to user-facing warnings instead of "something went wrong".
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
type FetchLike$1 = typeof fetch;
|
|
84
|
+
declare class RelayError extends Error {
|
|
85
|
+
readonly route: string;
|
|
86
|
+
readonly status: number;
|
|
87
|
+
readonly body: unknown;
|
|
88
|
+
constructor(route: string, status: number, body: unknown);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Create a new binding. User-authenticated via the X-Tap-User-Id header
|
|
92
|
+
* (Phase 3 placeholder; Clerk JWT later). The response carries the
|
|
93
|
+
* owner's first capability token, returned exactly once.
|
|
94
|
+
*/
|
|
95
|
+
declare function createBinding(opts: {
|
|
96
|
+
baseUrl: string;
|
|
97
|
+
request: CreateBindingRequest;
|
|
98
|
+
userId: string;
|
|
99
|
+
email?: string;
|
|
100
|
+
fetch?: FetchLike$1;
|
|
101
|
+
}): Promise<CreateBindingResponse>;
|
|
102
|
+
type RelayClientOptions = {
|
|
103
|
+
baseUrl: string;
|
|
104
|
+
bindingId: string;
|
|
105
|
+
token: string;
|
|
106
|
+
fetch?: FetchLike$1;
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Binding-scoped relay client. One instance per active binding; cheap to
|
|
110
|
+
* construct, holds no resources beyond the closed-over fetch impl.
|
|
111
|
+
*/
|
|
112
|
+
declare class RelayClient {
|
|
113
|
+
private readonly baseUrl;
|
|
114
|
+
private readonly bindingId;
|
|
115
|
+
private readonly token;
|
|
116
|
+
private readonly fetch;
|
|
117
|
+
constructor(opts: RelayClientOptions);
|
|
118
|
+
private authHeaders;
|
|
119
|
+
private bindingPath;
|
|
120
|
+
getBinding(): Promise<GetBindingResponse>;
|
|
121
|
+
prepareUpload(body: PrepareUploadRequest): Promise<PrepareUploadResponse>;
|
|
122
|
+
completeUpload(body: CompleteUploadRequest): Promise<CompleteUploadResponse>;
|
|
123
|
+
downloadUrl(hash: string): Promise<DownloadUrlResponse>;
|
|
124
|
+
/** PUT bytes to a presigned URL. Throws on non-2xx. */
|
|
125
|
+
putObjectBytes(uploadUrl: string, bytes: Uint8Array | Buffer): Promise<void>;
|
|
126
|
+
/** GET bytes from a presigned URL. Throws on non-2xx. */
|
|
127
|
+
getObjectBytes(downloadUrl: string): Promise<Buffer>;
|
|
128
|
+
submitChanges(events: ReadonlyArray<SubmitChangeEvent>): Promise<SubmitChangesResponse>;
|
|
129
|
+
listChanges(opts?: {
|
|
130
|
+
after?: string;
|
|
131
|
+
limit?: number;
|
|
132
|
+
}): Promise<ChangesSinceResponse>;
|
|
133
|
+
/**
|
|
134
|
+
* Mint a new invite (owner-only). Returns the secret + a ready-to-share
|
|
135
|
+
* accept URL, exactly once. Caller must surface to the user immediately
|
|
136
|
+
* and rely on `listInvites()` later if they want to refer back without
|
|
137
|
+
* the secret.
|
|
138
|
+
*/
|
|
139
|
+
mintInvite(body: CreateInviteRequest): Promise<CreateInviteResponse>;
|
|
140
|
+
listInvites(): Promise<{
|
|
141
|
+
invites: BindingInvite[];
|
|
142
|
+
}>;
|
|
143
|
+
revokeInvite(inviteId: string): Promise<{
|
|
144
|
+
revoked: boolean;
|
|
145
|
+
alreadyRevoked: boolean;
|
|
146
|
+
}>;
|
|
147
|
+
getManifest(opts?: {
|
|
148
|
+
after?: string;
|
|
149
|
+
limit?: number;
|
|
150
|
+
}): Promise<ManifestSnapshotResponse>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Local sync metadata — one SQLite file per binding, at
|
|
155
|
+
* `<rig>/.rig/tap/state.local.db`. Machine-local; never synced.
|
|
156
|
+
*
|
|
157
|
+
* Tracks:
|
|
158
|
+
* - per-path: last_applied_change_id, last_seen_hash, local_dirty,
|
|
159
|
+
* last_scan_at
|
|
160
|
+
* - cursor: the change_event id we've consumed up to (so a daemon
|
|
161
|
+
* restart resumes from the right place)
|
|
162
|
+
*
|
|
163
|
+
* Schema is small and applied inline on open — no migrations file.
|
|
164
|
+
*/
|
|
165
|
+
type LocalPathState = {
|
|
166
|
+
path: string;
|
|
167
|
+
lastAppliedChangeId: string | null;
|
|
168
|
+
lastSeenHash: string | null;
|
|
169
|
+
localDirty: boolean;
|
|
170
|
+
lastScanAt: string | null;
|
|
171
|
+
};
|
|
172
|
+
type StateDb = {
|
|
173
|
+
/** Cursor we've applied up to, or "chg_0" if nothing yet. */
|
|
174
|
+
getCursor(): string;
|
|
175
|
+
setCursor(cursor: string): void;
|
|
176
|
+
upsertPath(state: LocalPathState): void;
|
|
177
|
+
getPath(path: string): LocalPathState | null;
|
|
178
|
+
deletePath(path: string): void;
|
|
179
|
+
/** Enumerate every tracked path. Cheap — state DBs are small. */
|
|
180
|
+
listPaths(): string[];
|
|
181
|
+
setMeta(key: string, value: string): void;
|
|
182
|
+
getMeta(key: string): string | null;
|
|
183
|
+
close(): void;
|
|
184
|
+
};
|
|
185
|
+
declare function openStateDb(dbPath: string): StateDb;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* .rig/tap-binding.local.json — machine-local per-binding pointer.
|
|
189
|
+
*
|
|
190
|
+
* Holds the bindingId, the device's capability token, and where to find
|
|
191
|
+
* the relay. NEVER synced (validated by isAlwaysExcluded in tapignore.ts;
|
|
192
|
+
* the relay also rejects the path via validateRigPath).
|
|
193
|
+
*
|
|
194
|
+
* Tokens live here in plaintext for now. A future revision can move
|
|
195
|
+
* them to the OS keychain; the file structure is designed to make that
|
|
196
|
+
* swap localized.
|
|
197
|
+
*/
|
|
198
|
+
declare const BINDING_CONFIG_PATH = ".rig/tap-binding.local.json";
|
|
199
|
+
type BindingConfig = {
|
|
200
|
+
bindingId: string;
|
|
201
|
+
relayUrl: string;
|
|
202
|
+
deviceId: string;
|
|
203
|
+
/** Capability-token secret. Returned once by the relay, never re-derivable. */
|
|
204
|
+
token: string;
|
|
205
|
+
};
|
|
206
|
+
declare function bindingConfigFile(rootDir: string): string;
|
|
207
|
+
declare function readBindingConfig(rootDir: string): BindingConfig | null;
|
|
208
|
+
declare function writeBindingConfig(rootDir: string, config: BindingConfig): void;
|
|
209
|
+
/**
|
|
210
|
+
* Thrown when an operation requires a bound workspace but
|
|
211
|
+
* `.rig/tap-binding.local.json` is missing. All three callers (`invite`,
|
|
212
|
+
* `status`, `uninit`) raise the same class so bin.ts maps it to a
|
|
213
|
+
* single `not_initialized` JSON error code for rig.
|
|
214
|
+
*/
|
|
215
|
+
declare class NotInitializedError extends Error {
|
|
216
|
+
readonly rootDir: string;
|
|
217
|
+
constructor(rootDir: string);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Streaming sha256 of a file path, returned in the relay's wire format
|
|
222
|
+
* ("sha256:" + 64 hex). Used by walker → uploader and by watcher → uploader.
|
|
223
|
+
*/
|
|
224
|
+
declare function hashFile(absPath: string): Promise<string>;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* `tapd init` — bootstrap a fresh binding from the current working
|
|
228
|
+
* directory.
|
|
229
|
+
*
|
|
230
|
+
* Steps (mapped to design doc § "Init Flow"):
|
|
231
|
+
* 1. Refuse if .rig/tap-binding.local.json already exists.
|
|
232
|
+
* 2. Walk the rig, applying .tapignore + reserved-path rules.
|
|
233
|
+
* 3. Hash every tracked file (de-duplicated by hash so identical
|
|
234
|
+
* content uploads once).
|
|
235
|
+
* 4. For each unique hash: prepare-upload → PUT bytes → complete-upload.
|
|
236
|
+
* 5. Submit one batched POST /changes with one event per file +
|
|
237
|
+
* one mkdir per empty-dir marker.
|
|
238
|
+
* 6. Write .rig/tap-binding.local.json (mode 0600).
|
|
239
|
+
* 7. Populate .rig/tap/state.local.db with last_applied_change_id
|
|
240
|
+
* per path, and the resulting cursor.
|
|
241
|
+
*
|
|
242
|
+
* Returns a summary with the binding, the owner token (so the caller
|
|
243
|
+
* CLI can print it once), upload + change counts, and any walker
|
|
244
|
+
* warnings (oversized files / symlinks / invalid paths). Caller is
|
|
245
|
+
* responsible for surfacing the warnings to the user.
|
|
246
|
+
*/
|
|
247
|
+
|
|
248
|
+
type InitOptions = {
|
|
249
|
+
rootDir: string;
|
|
250
|
+
relayUrl: string;
|
|
251
|
+
ownerUserId: string;
|
|
252
|
+
bindingName: string;
|
|
253
|
+
deviceLabel?: string;
|
|
254
|
+
/** Test seam: override fetch (used to drive the in-process relay). */
|
|
255
|
+
fetch?: typeof fetch;
|
|
256
|
+
};
|
|
257
|
+
type InitResult = {
|
|
258
|
+
binding: CreateBindingResponse["binding"];
|
|
259
|
+
device: CreateBindingResponse["device"];
|
|
260
|
+
/** Owner secret. Returned exactly once. The caller should display it. */
|
|
261
|
+
ownerSecret: string;
|
|
262
|
+
uploadedHashes: number;
|
|
263
|
+
reusedHashes: number;
|
|
264
|
+
submittedEvents: number;
|
|
265
|
+
warnings: WalkResult["warnings"];
|
|
266
|
+
/** Cursor written to state.local.db after the initial submit. */
|
|
267
|
+
cursor: string;
|
|
268
|
+
};
|
|
269
|
+
declare class AlreadyInitializedError extends Error {
|
|
270
|
+
readonly path: string;
|
|
271
|
+
constructor(path: string);
|
|
272
|
+
}
|
|
273
|
+
declare function init(opts: InitOptions): Promise<InitResult>;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* `tapd join` — accept an invite URL and set up a fresh local binding.
|
|
277
|
+
*
|
|
278
|
+
* Unlike `tapd init`, join does NOT touch the rig contents:
|
|
279
|
+
* 1. Refuse if .rig/tap-binding.local.json already exists.
|
|
280
|
+
* 2. Parse the invite URL → (baseUrl, secret).
|
|
281
|
+
* 3. POST /v1/invites/:secret/accept with the user's identity header,
|
|
282
|
+
* receive {bindingId, device, token}.
|
|
283
|
+
* 4. Write .rig/tap-binding.local.json (mode 0600) IMMEDIATELY — same
|
|
284
|
+
* "secret-returned-once survives downstream failure" discipline as
|
|
285
|
+
* `tapd init`.
|
|
286
|
+
* 5. Open .rig/tap/state.local.db with cursor `chg_0`.
|
|
287
|
+
*
|
|
288
|
+
* After join, `tapd start` will:
|
|
289
|
+
* - applyOnce: pull the remote manifest + change log
|
|
290
|
+
* - scanAndPush: push any pre-existing local divergence
|
|
291
|
+
*
|
|
292
|
+
* That covers both the bootstrap-from-empty-dir case and the
|
|
293
|
+
* "user already has some files locally" merge case.
|
|
294
|
+
*/
|
|
295
|
+
|
|
296
|
+
type JoinOptions = {
|
|
297
|
+
rootDir: string;
|
|
298
|
+
inviteUrl: string;
|
|
299
|
+
userId: string;
|
|
300
|
+
email?: string;
|
|
301
|
+
deviceLabel?: string;
|
|
302
|
+
/** Test seam: override fetch. */
|
|
303
|
+
fetch?: typeof fetch;
|
|
304
|
+
};
|
|
305
|
+
type JoinResult = {
|
|
306
|
+
bindingId: string;
|
|
307
|
+
device: BindingDevice;
|
|
308
|
+
/** Capability secret. Returned exactly once. */
|
|
309
|
+
tokenSecret: string;
|
|
310
|
+
/** Whether the invite granted a member role (vs pure-capability). */
|
|
311
|
+
becameMember: boolean;
|
|
312
|
+
};
|
|
313
|
+
declare class AlreadyJoinedError extends Error {
|
|
314
|
+
readonly path: string;
|
|
315
|
+
constructor(path: string);
|
|
316
|
+
}
|
|
317
|
+
declare class InvalidInviteUrlError extends Error {
|
|
318
|
+
readonly url: string;
|
|
319
|
+
constructor(url: string);
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Parse `https://relay.example/v1/invites/<secret>/accept` into
|
|
323
|
+
* (baseUrl, secret). Trailing slash + missing /accept both accepted.
|
|
324
|
+
*/
|
|
325
|
+
declare function parseInviteUrl(url: string): {
|
|
326
|
+
baseUrl: string;
|
|
327
|
+
secret: string;
|
|
328
|
+
};
|
|
329
|
+
declare function join(opts: JoinOptions): Promise<JoinResult>;
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* `tapd invite` — owner-side helpers for the invite lifecycle.
|
|
333
|
+
*
|
|
334
|
+
* Reads .rig/tap-binding.local.json for the relay URL + token, builds
|
|
335
|
+
* a RelayClient, and calls the matching endpoint. Designed to be a
|
|
336
|
+
* thin wrapper that the CLI dispatcher (bin.ts) can call without
|
|
337
|
+
* thinking about plumbing.
|
|
338
|
+
*/
|
|
339
|
+
|
|
340
|
+
type MintInviteOptions = {
|
|
341
|
+
rootDir: string;
|
|
342
|
+
ops: ReadonlyArray<CapabilityOp>;
|
|
343
|
+
role?: Role | null;
|
|
344
|
+
pathGlobs?: ReadonlyArray<string>;
|
|
345
|
+
ttlSeconds?: number;
|
|
346
|
+
maxUses?: number;
|
|
347
|
+
emailConstraint?: string;
|
|
348
|
+
label?: string;
|
|
349
|
+
fetch?: typeof fetch;
|
|
350
|
+
};
|
|
351
|
+
declare function mint(opts: MintInviteOptions): Promise<CreateInviteResponse>;
|
|
352
|
+
declare function list(opts: {
|
|
353
|
+
rootDir: string;
|
|
354
|
+
fetch?: typeof fetch;
|
|
355
|
+
}): Promise<BindingInvite[]>;
|
|
356
|
+
declare function revoke(opts: {
|
|
357
|
+
rootDir: string;
|
|
358
|
+
inviteId: string;
|
|
359
|
+
fetch?: typeof fetch;
|
|
360
|
+
}): Promise<{
|
|
361
|
+
revoked: boolean;
|
|
362
|
+
alreadyRevoked: boolean;
|
|
363
|
+
}>;
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Apply remote change events to the local filesystem.
|
|
367
|
+
*
|
|
368
|
+
* Phase 2 keeps this simple: last-write-wins, no conflict materialization
|
|
369
|
+
* (Phase 4 adds it). Echo suppression is handled via the state DB:
|
|
370
|
+
* if the remote event's hash matches our last_seen_hash for the path,
|
|
371
|
+
* we know it's our own write coming back through the poll loop and skip
|
|
372
|
+
* the download.
|
|
373
|
+
*
|
|
374
|
+
* Materialization is atomic: download to `<file>.tap-tmp.<rand>` then
|
|
375
|
+
* `rename` into place. Atomic on POSIX; on Windows, `fs.rename` falls
|
|
376
|
+
* back to MoveFileEx-style replace.
|
|
377
|
+
*/
|
|
378
|
+
|
|
379
|
+
/** State-DB meta key — running count of conflicts surfaced by `tapd status`. */
|
|
380
|
+
declare const STATE_KEY_CONFLICT_COUNT = "conflict_count";
|
|
381
|
+
/**
|
|
382
|
+
* Sidecar path for a conflict materialization. Original extension is
|
|
383
|
+
* preserved at the end so editor highlighting still works.
|
|
384
|
+
*
|
|
385
|
+
* decisions/pricing.md → decisions/pricing.conflict-from.dev_xyz.chg_456.md
|
|
386
|
+
*
|
|
387
|
+
* Device label resolution lands when GET /v1/bindings/:id/devices does
|
|
388
|
+
* (Phase 5); until then we use the raw deviceId, which is at least an
|
|
389
|
+
* audit-stable reference.
|
|
390
|
+
*/
|
|
391
|
+
declare function conflictSidecarPath(path: string, ev: {
|
|
392
|
+
id: string;
|
|
393
|
+
actorDeviceId: string | null;
|
|
394
|
+
}): string;
|
|
395
|
+
type ApplyEventError = {
|
|
396
|
+
eventId: string;
|
|
397
|
+
path: string;
|
|
398
|
+
op: string;
|
|
399
|
+
reason: string;
|
|
400
|
+
};
|
|
401
|
+
type ApplyResult = {
|
|
402
|
+
applied: number;
|
|
403
|
+
echoSkipped: number;
|
|
404
|
+
errored: number;
|
|
405
|
+
events: ReadonlyArray<ChangeEvent>;
|
|
406
|
+
errors: ReadonlyArray<ApplyEventError>;
|
|
407
|
+
};
|
|
408
|
+
/**
|
|
409
|
+
* One iteration of the apply loop: poll `GET /changes?after=<cursor>`,
|
|
410
|
+
* materialize each event, update the cursor.
|
|
411
|
+
*
|
|
412
|
+
* Per-event errors (hash mismatch, file/directory collision, disk full,
|
|
413
|
+
* etc.) are caught, logged, and recorded to the state DB — the cursor
|
|
414
|
+
* still advances past the failed event so the daemon doesn't loop
|
|
415
|
+
* forever on the same broken row. Phase 4 conflict materialization
|
|
416
|
+
* will replace the "skip + log" behavior for the file/dir collision
|
|
417
|
+
* case; for now it's the safe default.
|
|
418
|
+
*/
|
|
419
|
+
declare function applyOnce(opts: {
|
|
420
|
+
rootDir: string;
|
|
421
|
+
client: RelayClient;
|
|
422
|
+
stateDb: StateDb;
|
|
423
|
+
limit?: number;
|
|
424
|
+
}): Promise<ApplyResult>;
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Local → remote sync direction: compute the diff between the file
|
|
428
|
+
* system and the state DB, upload any new bytes, submit one batched
|
|
429
|
+
* `POST /changes`.
|
|
430
|
+
*
|
|
431
|
+
* Phase 2 calls this on startup (initial reconcile) and on each
|
|
432
|
+
* watcher event (debounced).
|
|
433
|
+
*/
|
|
434
|
+
|
|
435
|
+
type ScanAndPushResult = {
|
|
436
|
+
/** Bytes uploaded this cycle. */
|
|
437
|
+
uploaded: number;
|
|
438
|
+
/** Hashes that were already on the relay (no PUT needed). */
|
|
439
|
+
reused: number;
|
|
440
|
+
/** Change events submitted (writes + deletes + mkdir + rmdir). */
|
|
441
|
+
submitted: ReadonlyArray<ChangeEvent>;
|
|
442
|
+
/** Warnings from the walker (symlinks / oversize / invalid) +
|
|
443
|
+
* mid-scan mutations detected before upload. */
|
|
444
|
+
warnings: ReadonlyArray<{
|
|
445
|
+
path: string;
|
|
446
|
+
reason: string;
|
|
447
|
+
}>;
|
|
448
|
+
};
|
|
449
|
+
declare function scanAndPush(opts: {
|
|
450
|
+
rootDir: string;
|
|
451
|
+
client: RelayClient;
|
|
452
|
+
stateDb: StateDb;
|
|
453
|
+
ignore: Ignore;
|
|
454
|
+
}): Promise<ScanAndPushResult>;
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* SSE consumer for the relay's /stream endpoint.
|
|
458
|
+
*
|
|
459
|
+
* Phase 4c — push delivery so applyOnce fires within milliseconds of a
|
|
460
|
+
* remote change instead of waiting on the poll interval. The poll loop
|
|
461
|
+
* remains the source of truth and keeps running underneath as a
|
|
462
|
+
* fallback (slower cadence is fine when SSE is healthy).
|
|
463
|
+
*
|
|
464
|
+
* Reconnect semantics:
|
|
465
|
+
* - Server-side disconnect (stream end) or client-side fetch error
|
|
466
|
+
* → backoff 1s, 2s, 4s, 8s, 16s, capped at 30s; reset on success.
|
|
467
|
+
* - Heartbeat watchdog: if no event/heartbeat lands within
|
|
468
|
+
* `heartbeatTimeoutMs` (default 45s), abort + reconnect.
|
|
469
|
+
* - `stop()` aborts the in-flight fetch + cancels pending reconnects.
|
|
470
|
+
*
|
|
471
|
+
* The subscriber NEVER advances the cursor — it just nudges applyOnce.
|
|
472
|
+
* Cursor management stays in the apply loop, which reads /changes as
|
|
473
|
+
* the durable source of truth.
|
|
474
|
+
*/
|
|
475
|
+
|
|
476
|
+
type FetchLike = typeof fetch;
|
|
477
|
+
type SseSubscriberOptions = {
|
|
478
|
+
baseUrl: string;
|
|
479
|
+
bindingId: string;
|
|
480
|
+
token: string;
|
|
481
|
+
onChange: (change: ChangeEvent) => void | Promise<void>;
|
|
482
|
+
onSubscribed?: (info: {
|
|
483
|
+
bindingId: string;
|
|
484
|
+
pathGlobs: string[];
|
|
485
|
+
}) => void;
|
|
486
|
+
onError?: (err: Error) => void;
|
|
487
|
+
fetch?: FetchLike;
|
|
488
|
+
/** Watchdog window. Default 45s — matches a server heartbeat of 15s × 3. */
|
|
489
|
+
heartbeatTimeoutMs?: number;
|
|
490
|
+
/** Backoff for the Nth reconnect attempt (0-indexed). */
|
|
491
|
+
reconnectDelayMs?: (attempt: number) => number;
|
|
492
|
+
};
|
|
493
|
+
type SseSubscriber = {
|
|
494
|
+
stop(): Promise<void>;
|
|
495
|
+
};
|
|
496
|
+
declare function subscribe(opts: SseSubscriberOptions): SseSubscriber;
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* `tapd start` — the daemon main loop.
|
|
500
|
+
*
|
|
501
|
+
* Three concurrent jobs (Phase 4):
|
|
502
|
+
* - SSE subscriber: opens /v1/bindings/:id/stream and triggers
|
|
503
|
+
* applyOnce on every push. Fast path — latency is network +
|
|
504
|
+
* download time, not pollSeconds.
|
|
505
|
+
* - poll loop: every `pollSeconds`, runs applyOnce as a fallback
|
|
506
|
+
* for SSE outages (and for multi-relay deployments where the SSE
|
|
507
|
+
* bus is single-process; see relay's event-bus.ts).
|
|
508
|
+
* - watcher: chokidar emits add / change / unlink / addDir / unlinkDir.
|
|
509
|
+
* Debounced rescan calls `scanAndPush` so file diffs flow upstream.
|
|
510
|
+
*
|
|
511
|
+
* All three share the state DB and the same RelayClient. Echo
|
|
512
|
+
* suppression in `applyEvent` prevents a self-write from being applied
|
|
513
|
+
* back over the same bytes.
|
|
514
|
+
*/
|
|
515
|
+
|
|
516
|
+
type StartOptions = {
|
|
517
|
+
rootDir: string;
|
|
518
|
+
pollSeconds?: number;
|
|
519
|
+
/** Quiescence window before the watcher triggers a rescan. */
|
|
520
|
+
debounceMs?: number;
|
|
521
|
+
/** Test seam — override fetch (passed to RelayClient + SSE subscriber). */
|
|
522
|
+
fetch?: typeof fetch;
|
|
523
|
+
/** Test seam — disable the chokidar watcher (poll loop only). */
|
|
524
|
+
noWatch?: boolean;
|
|
525
|
+
/** Test seam — disable SSE (poll-only mode). */
|
|
526
|
+
noSse?: boolean;
|
|
527
|
+
/** SSE watchdog window in ms. Default 45s. */
|
|
528
|
+
sseHeartbeatTimeoutMs?: number;
|
|
529
|
+
};
|
|
530
|
+
type DaemonHandle = {
|
|
531
|
+
/** Cleanly shut down the watcher + poll loop + close the state DB. */
|
|
532
|
+
stop(): Promise<void>;
|
|
533
|
+
/** Force one apply cycle now. Useful for tests + `tapd status`. */
|
|
534
|
+
applyNow(): Promise<ApplyResult>;
|
|
535
|
+
/** Force one upstream scan+push now. Useful for tests + `tapd status`. */
|
|
536
|
+
scanNow(): Promise<ScanAndPushResult>;
|
|
537
|
+
/** Underlying state DB. Caller MUST NOT close it — stop() does that. */
|
|
538
|
+
stateDb: StateDb;
|
|
539
|
+
};
|
|
540
|
+
declare function start(opts: StartOptions): Promise<DaemonHandle>;
|
|
541
|
+
|
|
542
|
+
export { AlreadyInitializedError, AlreadyJoinedError, type ApplyResult, BINDING_CONFIG_PATH, BUILTIN_IGNORE_LINES, type BindingConfig, type DaemonHandle, type InitOptions, type InitResult, InvalidInviteUrlError, type JoinOptions, type JoinResult, type LocalPathState, type MintInviteOptions, NotInitializedError, RelayClient, type RelayClientOptions, RelayError, STATE_KEY_CONFLICT_COUNT, type ScanAndPushResult, type SseSubscriber, type SseSubscriberOptions, type StartOptions, type StateDb, type WalkFile, type WalkResult, type WalkWarning, applyOnce, bindingConfigFile, conflictSidecarPath, createBinding, hashFile, init, isAlwaysExcluded, join, list as listInvites, loadIgnore, mint as mintInvite, openStateDb, parseInviteUrl, readBindingConfig, revoke as revokeInvite, scanAndPush, subscribe as sseSubscribe, start, walk, writeBindingConfig };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
AlreadyInitializedError,
|
|
4
|
+
AlreadyJoinedError,
|
|
5
|
+
BINDING_CONFIG_PATH,
|
|
6
|
+
BUILTIN_IGNORE_LINES,
|
|
7
|
+
InvalidInviteUrlError,
|
|
8
|
+
NotInitializedError,
|
|
9
|
+
RelayClient,
|
|
10
|
+
RelayError,
|
|
11
|
+
STATE_KEY_CONFLICT_COUNT,
|
|
12
|
+
applyOnce,
|
|
13
|
+
bindingConfigFile,
|
|
14
|
+
conflictSidecarPath,
|
|
15
|
+
createBinding,
|
|
16
|
+
hashFile,
|
|
17
|
+
init,
|
|
18
|
+
isAlwaysExcluded,
|
|
19
|
+
join,
|
|
20
|
+
list,
|
|
21
|
+
loadIgnore,
|
|
22
|
+
mint,
|
|
23
|
+
openStateDb,
|
|
24
|
+
parseInviteUrl,
|
|
25
|
+
readBindingConfig,
|
|
26
|
+
revoke,
|
|
27
|
+
scanAndPush,
|
|
28
|
+
start,
|
|
29
|
+
subscribe,
|
|
30
|
+
walk,
|
|
31
|
+
writeBindingConfig
|
|
32
|
+
} from "./chunk-RQC73B5Y.js";
|
|
33
|
+
export {
|
|
34
|
+
AlreadyInitializedError,
|
|
35
|
+
AlreadyJoinedError,
|
|
36
|
+
BINDING_CONFIG_PATH,
|
|
37
|
+
BUILTIN_IGNORE_LINES,
|
|
38
|
+
InvalidInviteUrlError,
|
|
39
|
+
NotInitializedError,
|
|
40
|
+
RelayClient,
|
|
41
|
+
RelayError,
|
|
42
|
+
STATE_KEY_CONFLICT_COUNT,
|
|
43
|
+
applyOnce,
|
|
44
|
+
bindingConfigFile,
|
|
45
|
+
conflictSidecarPath,
|
|
46
|
+
createBinding,
|
|
47
|
+
hashFile,
|
|
48
|
+
init,
|
|
49
|
+
isAlwaysExcluded,
|
|
50
|
+
join,
|
|
51
|
+
list as listInvites,
|
|
52
|
+
loadIgnore,
|
|
53
|
+
mint as mintInvite,
|
|
54
|
+
openStateDb,
|
|
55
|
+
parseInviteUrl,
|
|
56
|
+
readBindingConfig,
|
|
57
|
+
revoke as revokeInvite,
|
|
58
|
+
scanAndPush,
|
|
59
|
+
subscribe as sseSubscribe,
|
|
60
|
+
start,
|
|
61
|
+
walk,
|
|
62
|
+
writeBindingConfig
|
|
63
|
+
};
|
|
64
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rigxyz/tapd",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local daemon for the hosted Tap relay — watches files, hashes content, uploads/downloads objects, applies remote changes. Pairs with @rigxyz/cli for shared-rig sync.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Dylan Bourgeois <dtsbourg@gmail.com>",
|
|
7
|
+
"homepage": "https://github.com/dtsbourg/tap#readme",
|
|
8
|
+
"bugs": "https://github.com/dtsbourg/tap/issues",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/dtsbourg/tap.git",
|
|
12
|
+
"directory": "packages/tapd"
|
|
13
|
+
},
|
|
14
|
+
"keywords": ["tap", "tapd", "rig", "sync", "daemon"],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"bin": {
|
|
17
|
+
"tapd": "./dist/bin.js"
|
|
18
|
+
},
|
|
19
|
+
"files": ["dist", "README.md"],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsup",
|
|
25
|
+
"dev": "tsup --watch",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"test:watch": "vitest"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"better-sqlite3": "^11.5.0",
|
|
32
|
+
"chokidar": "^4.0.1",
|
|
33
|
+
"ignore": "^5.3.0",
|
|
34
|
+
"kleur": "^4.1.5"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@tap/core": "workspace:*",
|
|
38
|
+
"@tap/relay": "workspace:*",
|
|
39
|
+
"@types/better-sqlite3": "^7.6.12",
|
|
40
|
+
"@types/pg": "^8.20.0",
|
|
41
|
+
"pg": "^8.21.0",
|
|
42
|
+
"tsup": "^8.3.5",
|
|
43
|
+
"typescript": "^5.7.2",
|
|
44
|
+
"vitest": "^2.1.9"
|
|
45
|
+
}
|
|
46
|
+
}
|