@dabble/patches 0.2.26 → 0.2.28

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.
@@ -27,9 +27,9 @@ export declare class PatchesHistoryClient<T = any> {
27
27
  /** Update the name of a specific version. */
28
28
  updateVersion(versionId: string, metadata: EditableVersionMetadata): Promise<void>;
29
29
  /** Load the state for a specific version */
30
- getStateAtVersion(versionId: string): Promise<any>;
30
+ getVersionState(versionId: string): Promise<any>;
31
31
  /** Load the changes for a specific version */
32
- getChangesForVersion(versionId: string): Promise<Change[]>;
32
+ getVersionChanges(versionId: string): Promise<Change[]>;
33
33
  /** Scrub to a specific change within a version where changeIndex is 1-based and 0 is the parent version */
34
34
  scrubTo(versionId: string, changeIndex: number): Promise<void>;
35
35
  /** Clear caches and listeners */
@@ -73,7 +73,7 @@ export class PatchesHistoryClient {
73
73
  await this.listVersions(); // Refresh the list of versions
74
74
  }
75
75
  /** Load the state for a specific version */
76
- async getStateAtVersion(versionId) {
76
+ async getVersionState(versionId) {
77
77
  let data = this.cache.get(versionId);
78
78
  if (!data || data.state === undefined) {
79
79
  const { state } = await this.api.getVersionState(this.id, versionId);
@@ -85,7 +85,7 @@ export class PatchesHistoryClient {
85
85
  return data.state;
86
86
  }
87
87
  /** Load the changes for a specific version */
88
- async getChangesForVersion(versionId) {
88
+ async getVersionChanges(versionId) {
89
89
  let data = this.cache.get(versionId);
90
90
  if (!data || data.changes === undefined) {
91
91
  const changes = await this.api.getVersionChanges(this.id, versionId);
@@ -99,8 +99,8 @@ export class PatchesHistoryClient {
99
99
  const version = this.versions.find(v => v.id === versionId);
100
100
  // Load state and changes for the version
101
101
  const [state, changes] = await Promise.all([
102
- version?.parentId ? this.getStateAtVersion(version.parentId) : undefined,
103
- this.getChangesForVersion(versionId),
102
+ version?.parentId ? this.getVersionState(version.parentId) : undefined,
103
+ this.getVersionChanges(versionId),
104
104
  ]);
105
105
  // Apply changes up to changeIndex to the state (if needed)
106
106
  if (changeIndex > 0) {
@@ -0,0 +1,4 @@
1
+ export declare class StatusError extends Error {
2
+ code: number;
3
+ constructor(code: number, message: string);
4
+ }
@@ -0,0 +1,6 @@
1
+ export class StatusError extends Error {
2
+ constructor(code, message) {
3
+ super(message);
4
+ this.code = code;
5
+ }
6
+ }
@@ -95,7 +95,7 @@ export class JSONRPCServer {
95
95
  return respond(response);
96
96
  }
97
97
  catch (err) {
98
- return respond(rpcError(err?.code ?? -32000, err?.message ?? 'Server error', err?.stack));
98
+ return respond(rpcError(err?.code ?? -32000, err?.message ?? 'Server error', err?.code ? undefined : err?.stack));
99
99
  }
100
100
  }
101
101
  else {
@@ -39,7 +39,7 @@ export declare class RPCServer {
39
39
  getDoc(params: {
40
40
  docId: string;
41
41
  atRev?: number;
42
- }, ctx?: AuthContext): Promise<import("../../types.js").PatchesSnapshot<any>>;
42
+ }, ctx?: AuthContext): Promise<import("../../types.js").PatchesState<any>>;
43
43
  /**
44
44
  * Gets changes that occurred for a document after a specific revision number.
45
45
  * @param connectionId - The ID of the connection making the request
@@ -84,11 +84,11 @@ export declare class RPCServer {
84
84
  versionId: string;
85
85
  metadata: EditableVersionMetadata;
86
86
  }, ctx?: AuthContext): Promise<void>;
87
- getStateAtVersion(params: {
87
+ getVersionState(params: {
88
88
  docId: string;
89
89
  versionId: string;
90
90
  }, ctx?: AuthContext): Promise<any>;
91
- getChangesForVersion(params: {
91
+ getVersionChanges(params: {
92
92
  docId: string;
93
93
  versionId: string;
94
94
  }, ctx?: AuthContext): Promise<Change[]>;
@@ -1,3 +1,4 @@
1
+ import { StatusError } from '../error.js';
1
2
  import { JSONRPCServer } from '../protocol/JSONRPCServer.js';
2
3
  import { allowAll } from './AuthorizationProvider.js';
3
4
  export class RPCServer {
@@ -24,8 +25,8 @@ export class RPCServer {
24
25
  this.rpc.registerMethod('listVersions', this.listVersions.bind(this));
25
26
  this.rpc.registerMethod('createVersion', this.createVersion.bind(this));
26
27
  this.rpc.registerMethod('updateVersion', this.updateVersion.bind(this));
27
- this.rpc.registerMethod('getVersionState', this.getStateAtVersion.bind(this));
28
- this.rpc.registerMethod('getVersionChanges', this.getChangesForVersion.bind(this));
28
+ this.rpc.registerMethod('getVersionState', this.getVersionState.bind(this));
29
+ this.rpc.registerMethod('getVersionChanges', this.getVersionChanges.bind(this));
29
30
  this.rpc.registerMethod('listServerChanges', this.listServerChanges.bind(this));
30
31
  }
31
32
  // Branch manager operations (if provided)
@@ -114,13 +115,13 @@ export class RPCServer {
114
115
  await this.assertWrite(ctx, docId, 'updateVersion', params);
115
116
  return this.history.updateVersion(docId, versionId, metadata);
116
117
  }
117
- async getStateAtVersion(params, ctx) {
118
+ async getVersionState(params, ctx) {
118
119
  this.assertHistoryEnabled();
119
120
  const { docId, versionId } = params;
120
121
  await this.assertRead(ctx, docId, 'getStateAtVersion', params);
121
122
  return this.history.getStateAtVersion(docId, versionId);
122
123
  }
123
- async getChangesForVersion(params, ctx) {
124
+ async getVersionChanges(params, ctx) {
124
125
  this.assertHistoryEnabled();
125
126
  const { docId, versionId } = params;
126
127
  await this.assertRead(ctx, docId, 'getChangesForVersion', params);
@@ -165,7 +166,7 @@ export class RPCServer {
165
166
  async assertAccess(ctx, docId, kind, method, params) {
166
167
  const ok = await this.auth.canAccess(ctx, docId, kind, method, params);
167
168
  if (!ok) {
168
- throw new Error(`${kind.toUpperCase()}_FORBIDDEN:${docId}`);
169
+ throw new StatusError(401, `${kind.toUpperCase()}_FORBIDDEN:${docId}`);
169
170
  }
170
171
  }
171
172
  assertRead(ctx, docId, method, params) {
@@ -176,12 +177,12 @@ export class RPCServer {
176
177
  }
177
178
  assertHistoryEnabled() {
178
179
  if (!this.history) {
179
- throw new Error('History is not enabled');
180
+ throw new StatusError(404, 'History is not enabled');
180
181
  }
181
182
  }
182
183
  assertBranchingEnabled() {
183
184
  if (!this.branches) {
184
- throw new Error('Branching is not enabled');
185
+ throw new StatusError(404, 'Branching is not enabled');
185
186
  }
186
187
  }
187
188
  }
@@ -1,3 +1,4 @@
1
+ import type { JSONPatch } from '../json-patch/JSONPatch.js';
1
2
  import type { Change, EditableVersionMetadata, PatchesSnapshot, PatchesState, VersionMetadata } from '../types.js';
2
3
  import type { PatchesStoreBackend } from './types.js';
3
4
  /**
@@ -25,11 +26,12 @@ export declare class PatchesServer {
25
26
  readonly onDocDeleted: import("../event-signal.js").Signal<(docId: string, originClientId?: string) => void>;
26
27
  constructor(store: PatchesStoreBackend, options?: PatchesServerOptions);
27
28
  /**
28
- * Get the latest version of a document and changes since the last version.
29
+ * Get the state of a document at a specific revision (or the latest state if no revision is provided).
29
30
  * @param docId - The ID of the document.
30
- * @returns The latest version of the document and changes since the last version.
31
+ * @param rev - The revision number.
32
+ * @returns The state of the document at the specified revision.
31
33
  */
32
- getDoc(docId: string, atRev?: number): Promise<PatchesSnapshot>;
34
+ getDoc(docId: string, atRev?: number): Promise<PatchesState>;
33
35
  /**
34
36
  * Get changes that occurred after a specific revision.
35
37
  * @param docId - The ID of the document.
@@ -47,6 +49,12 @@ export declare class PatchesServer {
47
49
  * - transformedChanges: The client's changes after being transformed against concurrent changes
48
50
  */
49
51
  commitChanges(docId: string, changes: Change[], originClientId?: string): Promise<[Change[], Change[]]>;
52
+ /**
53
+ * Make a server-side change to a document.
54
+ * @param mutator
55
+ * @returns
56
+ */
57
+ change<T = Record<string, any>>(docId: string, mutator: (draft: T, patch: JSONPatch) => void, metadata?: Record<string, any>): Promise<Change | null>;
50
58
  /**
51
59
  * Deletes a document.
52
60
  * @param docId The document ID.
@@ -1,6 +1,7 @@
1
1
  import { createId } from 'crypto-id';
2
2
  import { signal } from '../event-signal.js';
3
3
  import { applyPatch } from '../json-patch/applyPatch.js';
4
+ import { createJSONPatch } from '../json-patch/createJSONPatch.js';
4
5
  import { transformPatch } from '../json-patch/transformPatch.js';
5
6
  import { applyChanges } from '../utils.js';
6
7
  /**
@@ -18,12 +19,13 @@ export class PatchesServer {
18
19
  this.sessionTimeoutMillis = (options.sessionTimeoutMinutes ?? 30) * 60 * 1000;
19
20
  }
20
21
  /**
21
- * Get the latest version of a document and changes since the last version.
22
+ * Get the state of a document at a specific revision (or the latest state if no revision is provided).
22
23
  * @param docId - The ID of the document.
23
- * @returns The latest version of the document and changes since the last version.
24
+ * @param rev - The revision number.
25
+ * @returns The state of the document at the specified revision.
24
26
  */
25
27
  async getDoc(docId, atRev) {
26
- return this._getSnapshotAtRevision(docId, atRev);
28
+ return this.getStateAtRevision(docId, atRev);
27
29
  }
28
30
  /**
29
31
  * Get changes that occurred after a specific revision.
@@ -55,7 +57,7 @@ export class PatchesServer {
55
57
  }
56
58
  // Add check for inconsistent baseRev within the batch if needed
57
59
  if (changes.some(c => c.baseRev !== baseRev)) {
58
- throw new Error(`Client changes must have consistent baseRev for doc ${docId}.`);
60
+ throw new Error(`Client changes must have consistent baseRev in all changes for doc ${docId}.`);
59
61
  }
60
62
  // 1. Load server state details (assuming store methods exist)
61
63
  let { state: currentState, rev: currentRev, changes: currentChanges } = await this._getSnapshotAtRevision(docId);
@@ -66,14 +68,12 @@ export class PatchesServer {
66
68
  throw new Error(`Client baseRev (${baseRev}) is ahead of server revision (${currentRev}) for doc ${docId}. Client needs to reload the document.`);
67
69
  }
68
70
  const partOfInitialBatch = batchId && changes[0].rev > 1;
69
- if (baseRev === 0 && currentRev > 0 && !partOfInitialBatch) {
71
+ if (baseRev === 0 && currentRev > 0 && !partOfInitialBatch && changes[0].ops[0].path === '') {
70
72
  throw new Error(`Client baseRev is 0 but server has already been created for doc ${docId}. Client needs to load the existing document.`);
71
73
  }
72
74
  // Ensure all new changes' `created` field is in the past, that each `rev` is correct, and that `baseRev` is set
73
- let rev = baseRev + 1;
74
75
  changes.forEach(c => {
75
76
  c.created = Math.min(c.created, Date.now());
76
- c.rev = rev++;
77
77
  c.baseRev = baseRev;
78
78
  });
79
79
  // 2. Check if we need to create a new version - if the last change was created more than a session ago
@@ -103,6 +103,7 @@ export class PatchesServer {
103
103
  // against committed changes that happened *after* the client's baseRev.
104
104
  // The state used for transformation should be the server state *at the client's baseRev*.
105
105
  let stateAtBaseRev = (await this.getStateAtRevision(docId, baseRev)).state;
106
+ let rev = currentRev + 1;
106
107
  const committedOps = committedChanges.flatMap(c => c.ops);
107
108
  // Apply transformation based on state at baseRev
108
109
  const transformedChanges = changes
@@ -113,14 +114,19 @@ export class PatchesServer {
113
114
  return null; // Change is obsolete after transformation
114
115
  }
115
116
  try {
117
+ const previous = stateAtBaseRev;
116
118
  stateAtBaseRev = applyPatch(stateAtBaseRev, change.ops, { strict: true });
119
+ if (previous === stateAtBaseRev) {
120
+ // Changes were no-ops, we can skip this change
121
+ return null;
122
+ }
117
123
  }
118
124
  catch (error) {
119
125
  console.error(`Error applying change ${change.id} to state:`, error);
120
126
  return null;
121
127
  }
122
128
  // Return a new change object with transformed ops and original metadata
123
- return { ...change, ops: transformedOps };
129
+ return { ...change, rev: rev++, ops: transformedOps };
124
130
  })
125
131
  .filter(Boolean);
126
132
  // Persist the newly transformed changes
@@ -134,6 +140,31 @@ export class PatchesServer {
134
140
  // Return committed changes and newly transformed changes separately
135
141
  return [committedChanges, transformedChanges];
136
142
  }
143
+ /**
144
+ * Make a server-side change to a document.
145
+ * @param mutator
146
+ * @returns
147
+ */
148
+ async change(docId, mutator, metadata) {
149
+ const { state, rev } = await this.getDoc(docId);
150
+ const patch = createJSONPatch(state, mutator);
151
+ if (patch.ops.length === 0) {
152
+ return null;
153
+ }
154
+ // It's the baseRev that matters for sending.
155
+ const change = {
156
+ id: createId(),
157
+ ops: patch.ops,
158
+ baseRev: rev,
159
+ rev: rev + 1,
160
+ created: Date.now(),
161
+ ...metadata,
162
+ };
163
+ // Apply to local state to ensure no errors are thrown
164
+ patch.apply(state);
165
+ await this.commitChanges(docId, [change]);
166
+ return change;
167
+ }
137
168
  /**
138
169
  * Deletes a document.
139
170
  * @param docId The document ID.
package/dist/types.d.ts CHANGED
@@ -7,7 +7,7 @@ export interface Change {
7
7
  /** The revision number assigned on the client to the optimistic revision and updated by the server after commit. */
8
8
  rev: number;
9
9
  /** The server revision this change was based on. Required for client->server changes. */
10
- baseRev?: number;
10
+ baseRev: number;
11
11
  /** Client-side timestamp when the change was created. */
12
12
  created: number;
13
13
  /** Optional batch identifier for grouping changes that belong to the same client batch (for multi-batch offline/large edits). */
@@ -1,4 +1,3 @@
1
- import { TextEncoder } from 'util'; // Node.js TextEncoder
2
1
  /** Estimate JSON string byte size. */
3
2
  export function getJSONByteSize(data) {
4
3
  // Basic estimation, might not be perfectly accurate due to encoding nuances
package/dist/utils.d.ts CHANGED
@@ -1,13 +1,4 @@
1
1
  import type { Change, Deferred } from './types.js';
2
- /**
3
- * Splits an array of changes into two arrays based on the presence of a baseRev.
4
- * The first array contains changes before the first change with a baseRev,
5
- * and the second array contains the change with baseRev and all subsequent changes.
6
- *
7
- * @param changes - Array of changes to split
8
- * @returns A tuple containing [changes before baseRev, changes with and after baseRev]
9
- */
10
- export declare function splitChanges(changes: Change[]): [Change[], Change[]];
11
2
  /**
12
3
  * Applies a sequence of changes to a state object.
13
4
  * Each change is applied in sequence using the applyPatch function.
package/dist/utils.js CHANGED
@@ -1,17 +1,5 @@
1
1
  import { applyPatch } from './json-patch/applyPatch.js';
2
2
  import { JSONPatch } from './json-patch/JSONPatch.js';
3
- /**
4
- * Splits an array of changes into two arrays based on the presence of a baseRev.
5
- * The first array contains changes before the first change with a baseRev,
6
- * and the second array contains the change with baseRev and all subsequent changes.
7
- *
8
- * @param changes - Array of changes to split
9
- * @returns A tuple containing [changes before baseRev, changes with and after baseRev]
10
- */
11
- export function splitChanges(changes) {
12
- const index = changes.findIndex(c => c.baseRev);
13
- return [changes.slice(0, index), changes.slice(index)];
14
- }
15
3
  /**
16
4
  * Applies a sequence of changes to a state object.
17
5
  * Each change is applied in sequence using the applyPatch function.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dabble/patches",
3
- "version": "0.2.26",
3
+ "version": "0.2.28",
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": {