@dabble/patches 0.8.20 → 0.9.1

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.
@@ -84,6 +84,19 @@ declare class PatchesSync extends ReadonlyStoreClass<PatchesSyncState> {
84
84
  * Provides the pending changes that were discarded so the application can handle them.
85
85
  */
86
86
  readonly onRemoteDocDeleted: easy_signal.Signal<(docId: string, pendingChanges: Change[]) => void>;
87
+ /**
88
+ * Signal emitted when the server reports the caller is no longer authorised
89
+ * to read/write a tracked document (e.g. a co-author was revoked, removed
90
+ * from a shared collection, or never had access in the first place).
91
+ *
92
+ * Local cleanup mirrors `onRemoteDocDeleted` — untrack, drop the local
93
+ * cache, return any pending changes that were lost — but the doc itself
94
+ * still exists server-side. Consumers that maintain a workspace listing
95
+ * (e.g. a "Shared with Me" dashboard) should remove the doc from their
96
+ * own state when this fires; otherwise it would resurface on next start
97
+ * and immediately re-fail with the same 403.
98
+ */
99
+ readonly onRemoteDocAccessRevoked: easy_signal.Signal<(docId: string, pendingChanges: Change[]) => void>;
87
100
  /**
88
101
  * Signal emitted after pending branch metas have been synced to the server.
89
102
  * Consumers should use this to refresh in-memory branch state (e.g. call `loadCached()`
@@ -183,6 +196,21 @@ declare class PatchesSync extends ReadonlyStoreClass<PatchesSyncState> {
183
196
  * Cleans up local state and notifies the application with any pending changes that were lost.
184
197
  */
185
198
  protected _handleRemoteDocDeleted(docId: string): Promise<void>;
199
+ /**
200
+ * Sibling of `_handleRemoteDocDeleted` for the access-revoked path.
201
+ * Same local cleanup (close, untrack, drop cache, clear sync state) but
202
+ * the doc is not tombstoned server-side — it just isn't ours anymore.
203
+ * Emitted as a distinct signal so consumers can show different UX
204
+ * ("Your access was revoked" vs. "Project was deleted") without having
205
+ * to inspect error codes themselves.
206
+ */
207
+ protected _handleRemoteDocAccessRevoked(docId: string): Promise<void>;
208
+ /**
209
+ * Local cleanup shared by `_handleRemoteDocDeleted` and
210
+ * `_handleRemoteDocAccessRevoked`. Returns pending changes that were
211
+ * lost so the caller can include them in the application-facing signal.
212
+ */
213
+ protected _cleanupAfterAccessLoss(docId: string): Promise<Change[]>;
186
214
  /**
187
215
  * Adds, updates, or removes a doc state entry immutably and notifies via store.
188
216
  * - Pass a full DocSyncState to add a new entry or overwrite an existing one.
@@ -211,6 +239,14 @@ declare class PatchesSync extends ReadonlyStoreClass<PatchesSyncState> {
211
239
  * Helper to detect DOC_DELETED (410) errors from the server.
212
240
  */
213
241
  protected _isDocDeletedError(err: unknown): boolean;
242
+ /**
243
+ * Helper to detect ACCESS_REVOKED (403) errors from the server. Used by
244
+ * the sync/flush catch blocks to short-circuit straight into the
245
+ * `_handleRemoteDocAccessRevoked` cleanup path rather than latching the
246
+ * error onto `docStates[docId].syncError` (which would surface as a
247
+ * permanent "Unable to Sync" pill in the UI).
248
+ */
249
+ protected _isAccessRevokedError(err: unknown): boolean;
214
250
  }
215
251
 
216
252
  export { PatchesSync, type PatchesSyncOptions, type PatchesSyncState };
@@ -52,6 +52,19 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [serialGate],
52
52
  * Provides the pending changes that were discarded so the application can handle them.
53
53
  */
54
54
  __publicField(this, "onRemoteDocDeleted", signal());
55
+ /**
56
+ * Signal emitted when the server reports the caller is no longer authorised
57
+ * to read/write a tracked document (e.g. a co-author was revoked, removed
58
+ * from a shared collection, or never had access in the first place).
59
+ *
60
+ * Local cleanup mirrors `onRemoteDocDeleted` — untrack, drop the local
61
+ * cache, return any pending changes that were lost — but the doc itself
62
+ * still exists server-side. Consumers that maintain a workspace listing
63
+ * (e.g. a "Shared with Me" dashboard) should remove the doc from their
64
+ * own state when this fires; otherwise it would resurface on next start
65
+ * and immediately re-fail with the same 403.
66
+ */
67
+ __publicField(this, "onRemoteDocAccessRevoked", signal());
55
68
  /**
56
69
  * Signal emitted after pending branch metas have been synced to the server.
57
70
  * Consumers should use this to refresh in-memory branch state (e.g. call `loadCached()`
@@ -360,6 +373,10 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [serialGate],
360
373
  await this._handleRemoteDocDeleted(docId);
361
374
  return;
362
375
  }
376
+ if (this._isAccessRevokedError(err)) {
377
+ await this._handleRemoteDocAccessRevoked(docId);
378
+ return;
379
+ }
363
380
  const syncError = err instanceof Error ? err : new Error(String(err));
364
381
  this._updateDocSyncState(docId, { syncStatus: "error", syncError });
365
382
  console.error(`Error syncing doc ${docId}:`, err);
@@ -421,6 +438,10 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [serialGate],
421
438
  await this._handleRemoteDocDeleted(docId);
422
439
  return;
423
440
  }
441
+ if (this._isAccessRevokedError(err)) {
442
+ await this._handleRemoteDocAccessRevoked(docId);
443
+ return;
444
+ }
424
445
  const flushError = err instanceof Error ? err : new Error(String(err));
425
446
  this._updateDocSyncState(docId, { syncStatus: "error", syncError: flushError });
426
447
  console.error(`Flush failed for doc ${docId}:`, err);
@@ -562,6 +583,27 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [serialGate],
562
583
  * Cleans up local state and notifies the application with any pending changes that were lost.
563
584
  */
564
585
  async _handleRemoteDocDeleted(docId) {
586
+ const pendingChanges = await this._cleanupAfterAccessLoss(docId);
587
+ await this.onRemoteDocDeleted.emit(docId, pendingChanges);
588
+ }
589
+ /**
590
+ * Sibling of `_handleRemoteDocDeleted` for the access-revoked path.
591
+ * Same local cleanup (close, untrack, drop cache, clear sync state) but
592
+ * the doc is not tombstoned server-side — it just isn't ours anymore.
593
+ * Emitted as a distinct signal so consumers can show different UX
594
+ * ("Your access was revoked" vs. "Project was deleted") without having
595
+ * to inspect error codes themselves.
596
+ */
597
+ async _handleRemoteDocAccessRevoked(docId) {
598
+ const pendingChanges = await this._cleanupAfterAccessLoss(docId);
599
+ await this.onRemoteDocAccessRevoked.emit(docId, pendingChanges);
600
+ }
601
+ /**
602
+ * Local cleanup shared by `_handleRemoteDocDeleted` and
603
+ * `_handleRemoteDocAccessRevoked`. Returns pending changes that were
604
+ * lost so the caller can include them in the application-facing signal.
605
+ */
606
+ async _cleanupAfterAccessLoss(docId) {
565
607
  const algorithm = this._getAlgorithm(docId);
566
608
  const pendingChanges = await algorithm.getPendingToSend(docId) ?? [];
567
609
  const doc = this.patches.getOpenDoc(docId);
@@ -571,7 +613,7 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [serialGate],
571
613
  this.trackedDocs.delete(docId);
572
614
  this._updateDocSyncState(docId, void 0);
573
615
  await algorithm.confirmDeleteDoc(docId);
574
- await this.onRemoteDocDeleted.emit(docId, pendingChanges);
616
+ return pendingChanges;
575
617
  }
576
618
  /**
577
619
  * Adds, updates, or removes a doc state entry immutably and notifies via store.
@@ -634,6 +676,16 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [serialGate],
634
676
  _isDocDeletedError(err) {
635
677
  return err instanceof StatusError && err.code === ErrorCodes.DOC_DELETED;
636
678
  }
679
+ /**
680
+ * Helper to detect ACCESS_REVOKED (403) errors from the server. Used by
681
+ * the sync/flush catch blocks to short-circuit straight into the
682
+ * `_handleRemoteDocAccessRevoked` cleanup path rather than latching the
683
+ * error onto `docStates[docId].syncError` (which would surface as a
684
+ * permanent "Unable to Sync" pill in the UI).
685
+ */
686
+ _isAccessRevokedError(err) {
687
+ return err instanceof StatusError && err.code === ErrorCodes.ACCESS_REVOKED;
688
+ }
637
689
  }
638
690
  _init = __decoratorStart(_a);
639
691
  __decorateElement(_init, 1, "syncDoc", _syncDoc_dec, PatchesSync);
@@ -11,6 +11,15 @@ declare const ErrorCodes: {
11
11
  readonly DOC_DELETED: 410;
12
12
  /** Document not found (never existed). */
13
13
  readonly DOC_NOT_FOUND: 404;
14
+ /**
15
+ * Caller is no longer authorized to read/write the document.
16
+ * Distinct from DOC_DELETED — the doc still exists, the caller just lost
17
+ * membership (revoked, or removed from a shared collection). The sync
18
+ * loop treats it like a soft delete: untrack, drop local cache, emit
19
+ * `onRemoteDocAccessRevoked` so the application can remove the doc from
20
+ * its own workspace state.
21
+ */
22
+ readonly ACCESS_REVOKED: 403;
14
23
  };
15
24
  /**
16
25
  * Error thrown when the JSON-RPC client receives a message that cannot be parsed as JSON.
package/dist/net/error.js CHANGED
@@ -10,7 +10,16 @@ const ErrorCodes = {
10
10
  /** Document was deleted (tombstone exists). */
11
11
  DOC_DELETED: 410,
12
12
  /** Document not found (never existed). */
13
- DOC_NOT_FOUND: 404
13
+ DOC_NOT_FOUND: 404,
14
+ /**
15
+ * Caller is no longer authorized to read/write the document.
16
+ * Distinct from DOC_DELETED — the doc still exists, the caller just lost
17
+ * membership (revoked, or removed from a shared collection). The sync
18
+ * loop treats it like a soft delete: untrack, drop local cache, emit
19
+ * `onRemoteDocAccessRevoked` so the application can remove the doc from
20
+ * its own workspace state.
21
+ */
22
+ ACCESS_REVOKED: 403
14
23
  };
15
24
  class JSONRPCParseError extends Error {
16
25
  rawMessage;
@@ -1,3 +1,4 @@
1
+ export { Invite, InviteProjectBookMeta, InviteProjectMetaSnapshot, InviteRole, InviteTo } from './invite.js';
1
2
  export { FetchTransport } from './http/FetchTransport.js';
2
3
  export { PatchesClient } from './PatchesClient.js';
3
4
  export { PatchesConnection } from './PatchesConnection.js';
package/dist/net/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import "../chunk-IZ2YBCUP.js";
2
+ export * from "./invite.js";
2
3
  export * from "./http/FetchTransport.js";
3
4
  export * from "./PatchesClient.js";
4
5
  export * from "./PatchesConnection.js";
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Shapes returned by pup's invite endpoints (`GET /docs/:docId/_invites/:inviteId`)
3
+ * and embedded in roles documents. Kept in sync with pup's `Invite` / `UserAccess`
4
+ * surface so clients deserialize with correct field names.
5
+ */
6
+ type InviteRole = 'owner' | 'write' | 'edit' | 'comment' | 'view';
7
+ /** Invite addressee payload (`invite.to`). */
8
+ interface InviteTo {
9
+ role: InviteRole;
10
+ private?: boolean;
11
+ doc?: string;
12
+ /** Optional client-side id echoed from legacy creation paths; stripped on accept server-side. */
13
+ id?: string;
14
+ name?: string;
15
+ email: string;
16
+ }
17
+ /**
18
+ * Minimal book-row shape stored on invites for dashboard-style previews.
19
+ * Mirrors writer `NovelBookMeta` / pup-embedded JSON (extra keys are ignored).
20
+ */
21
+ interface InviteProjectBookMeta {
22
+ id?: string;
23
+ title?: string;
24
+ subtitle?: string;
25
+ author?: string;
26
+ coverArt?: string;
27
+ coverArtRatio?: unknown;
28
+ pattern?: number;
29
+ backgroundColor?: unknown;
30
+ }
31
+ /** Novel project meta snapshot on an invite (writer `InviteProjectMetaSnapshot`). */
32
+ interface InviteProjectMetaSnapshot {
33
+ title?: string;
34
+ modifiedAt?: string;
35
+ books: InviteProjectBookMeta[];
36
+ isTemplate?: boolean;
37
+ templateCount?: number;
38
+ type?: string;
39
+ }
40
+ interface Invite {
41
+ from: string;
42
+ to: InviteTo;
43
+ createdAt: number;
44
+ expiresAt: number;
45
+ /** Cached project title at invite creation (writer/pup roles doc). */
46
+ projectName?: string;
47
+ /** @deprecated Prefer `projectMetaSnapshot.books`. */
48
+ projectBooks?: InviteProjectBookMeta[];
49
+ /** @deprecated Prefer `projectMetaSnapshot.modifiedAt`. */
50
+ projectModifiedAt?: string;
51
+ /** Full project meta snapshot for accept-modal preview. */
52
+ projectMetaSnapshot?: InviteProjectMetaSnapshot;
53
+ }
54
+
55
+ export type { Invite, InviteProjectBookMeta, InviteProjectMetaSnapshot, InviteRole, InviteTo };
File without changes
@@ -2,6 +2,7 @@ import * as easy_signal from 'easy-signal';
2
2
  import { Change, CommitChangesOptions, PatchesState, ChangeInput, DeleteDocOptions, EditableVersionMetadata, ListVersionsOptions, VersionMetadata, PatchesSnapshot, Branch, CreateBranchMetadata, EditableBranchMetadata } from '../../types.js';
3
3
  import { PatchesConnection } from '../PatchesConnection.js';
4
4
  import { ConnectionState } from '../protocol/types.js';
5
+ import { Invite } from '../invite.js';
5
6
  import '../../json-patch/JSONPatch.js';
6
7
  import '@dabble/delta';
7
8
  import '../../json-patch/types.js';
@@ -68,6 +69,28 @@ declare class PatchesREST implements PatchesConnection {
68
69
  updateBranch(docId: string, branchId: string, metadata: EditableBranchMetadata): Promise<void>;
69
70
  deleteBranch(docId: string, branchId: string): Promise<void>;
70
71
  mergeBranch(docId: string, branchId: string): Promise<void>;
72
+ /**
73
+ * Fetch a single invite addressed to the authenticated user.
74
+ * `GET /docs/:docId/_invites/:inviteId`
75
+ */
76
+ getInvite(docId: string, inviteId: string): Promise<Invite>;
77
+ /**
78
+ * Accept or decline an invite. Returns whether the server applied a role
79
+ * change (`ok` from `{ ok: boolean }`).
80
+ * `POST /docs/:docId/_invites/:inviteId` body `{ accept: boolean }`
81
+ */
82
+ acceptInvite(docId: string, inviteId: string, accept: boolean): Promise<boolean>;
83
+ /**
84
+ * Remove the authenticated user from `roles.users` (non-owners only).
85
+ * `POST /docs/:docId/_self/leave`
86
+ */
87
+ leaveProject(docId: string): Promise<boolean>;
88
+ /**
89
+ * Owner/co-author revokes another member. `targetUid` is removed from
90
+ * `roles.users` and tombstoned under `roles.formerUsers.revokedAt`.
91
+ * `POST /docs/:docId/_members/:uid/revoke`
92
+ */
93
+ revokeMember(docId: string, targetUid: string): Promise<boolean>;
71
94
  private _setState;
72
95
  private _getHeaders;
73
96
  private _fetch;
@@ -4,6 +4,8 @@ import { StatusError } from "../error.js";
4
4
  import { onlineState } from "../websocket/onlineState.js";
5
5
  import { normalizeIds } from "./utils.js";
6
6
  const SESSION_STORAGE_KEY = "patches-clientId";
7
+ const REQUEST_TIMEOUT_MS = 3e4;
8
+ const CONNECT_TIMEOUT_MS = 3e4;
7
9
  class PatchesREST {
8
10
  /** The client ID used for SSE connection and subscription management. */
9
11
  clientId;
@@ -49,16 +51,26 @@ class PatchesREST {
49
51
  const es = new EventSource(`${this._url}/events/${this.clientId}`);
50
52
  this.eventSource = es;
51
53
  let settled = false;
54
+ const timer = globalThis.setTimeout(() => {
55
+ if (settled) return;
56
+ settled = true;
57
+ es.close();
58
+ if (this.eventSource === es) this.eventSource = null;
59
+ this._setState("error");
60
+ reject(new Error("SSE connection timed out"));
61
+ }, CONNECT_TIMEOUT_MS);
52
62
  es.onopen = () => {
53
63
  this._setState("connected");
54
64
  if (!settled) {
55
65
  settled = true;
66
+ globalThis.clearTimeout(timer);
56
67
  resolve();
57
68
  }
58
69
  };
59
70
  es.onerror = () => {
60
71
  if (!settled) {
61
72
  settled = true;
73
+ globalThis.clearTimeout(timer);
62
74
  this._setState("error");
63
75
  reject(new Error("SSE connection failed"));
64
76
  return;
@@ -177,6 +189,52 @@ class PatchesREST {
177
189
  async mergeBranch(docId, branchId) {
178
190
  await this._fetch(`/docs/${docId}/_branches/${encodeURIComponent(branchId)}/_merge`, { method: "POST" });
179
191
  }
192
+ // --- Invites & membership (pup REST) ---
193
+ /**
194
+ * Fetch a single invite addressed to the authenticated user.
195
+ * `GET /docs/:docId/_invites/:inviteId`
196
+ */
197
+ async getInvite(docId, inviteId) {
198
+ return this._fetch(`/docs/${docId}/_invites/${encodeURIComponent(inviteId)}`);
199
+ }
200
+ /**
201
+ * Accept or decline an invite. Returns whether the server applied a role
202
+ * change (`ok` from `{ ok: boolean }`).
203
+ * `POST /docs/:docId/_invites/:inviteId` body `{ accept: boolean }`
204
+ */
205
+ async acceptInvite(docId, inviteId, accept) {
206
+ const result = await this._fetch(`/docs/${docId}/_invites/${encodeURIComponent(inviteId)}`, {
207
+ method: "POST",
208
+ body: { accept }
209
+ });
210
+ return Boolean(result?.ok);
211
+ }
212
+ /**
213
+ * Remove the authenticated user from `roles.users` (non-owners only).
214
+ * `POST /docs/:docId/_self/leave`
215
+ */
216
+ async leaveProject(docId) {
217
+ const result = await this._fetch(`/docs/${docId}/_self/leave`, {
218
+ method: "POST",
219
+ body: {}
220
+ });
221
+ return Boolean(result?.ok);
222
+ }
223
+ /**
224
+ * Owner/co-author revokes another member. `targetUid` is removed from
225
+ * `roles.users` and tombstoned under `roles.formerUsers.revokedAt`.
226
+ * `POST /docs/:docId/_members/:uid/revoke`
227
+ */
228
+ async revokeMember(docId, targetUid) {
229
+ const result = await this._fetch(
230
+ `/docs/${docId}/_members/${encodeURIComponent(targetUid)}/revoke`,
231
+ {
232
+ method: "POST",
233
+ body: {}
234
+ }
235
+ );
236
+ return Boolean(result?.ok);
237
+ }
180
238
  // --- Private Helpers ---
181
239
  _setState(state) {
182
240
  if (state === this._state) return;
@@ -202,7 +260,8 @@ class PatchesREST {
202
260
  ...hasBody ? { "Content-Type": "application/json" } : {},
203
261
  ...headers
204
262
  },
205
- body: hasBody ? JSON.stringify(init.body) : void 0
263
+ body: hasBody ? JSON.stringify(init.body) : void 0,
264
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
206
265
  });
207
266
  if (!response.ok) {
208
267
  let message = response.statusText;
@@ -8,5 +8,6 @@ import '@dabble/delta';
8
8
  import '../../json-patch/types.js';
9
9
  import '../PatchesConnection.js';
10
10
  import '../protocol/types.js';
11
+ import '../invite.js';
11
12
  import '../websocket/AuthorizationProvider.js';
12
13
  import '../../server/types.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dabble/patches",
3
- "version": "0.8.20",
3
+ "version": "0.9.1",
4
4
  "description": "Immutable JSON Patch implementation based on RFC 6902 supporting operational transformation and last-writer-wins",
5
5
  "author": "Jacob Wright <jacwright@gmail.com>",
6
6
  "bugs": {