@dabble/patches 0.4.11 → 0.5.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/algorithms/server/commitChanges.js +6 -3
- package/dist/algorithms/server/createVersion.js +3 -2
- package/dist/algorithms/server/handleOfflineSessionsAndBatches.js +6 -4
- package/dist/data/change.js +3 -2
- package/dist/net/http/FetchTransport.js +1 -1
- package/dist/server/PatchesBranchManager.js +6 -5
- package/dist/server/PatchesHistoryManager.js +1 -1
- package/dist/server/PatchesServer.js +2 -2
- package/dist/types.d.ts +16 -14
- package/dist/utils/dates.d.ts +52 -0
- package/dist/utils/dates.js +57 -0
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import "../../chunk-IZ2YBCUP.js";
|
|
2
|
+
import { clampTimestamp, createServerTimestamp, timestampDiff } from "../../utils/dates.js";
|
|
2
3
|
import { applyChanges } from "../shared/applyChanges.js";
|
|
3
4
|
import { createVersion } from "./createVersion.js";
|
|
4
5
|
import { getSnapshotAtRevision } from "./getSnapshotAtRevision.js";
|
|
@@ -14,6 +15,7 @@ async function commitChanges(store, docId, changes, sessionTimeoutMillis) {
|
|
|
14
15
|
const currentState = applyChanges(initialState, currentChanges);
|
|
15
16
|
const currentRev = currentChanges.at(-1)?.rev ?? initialRev;
|
|
16
17
|
const baseRev = changes[0].baseRev ?? currentRev;
|
|
18
|
+
const serverNow = createServerTimestamp();
|
|
17
19
|
let rev = baseRev + 1;
|
|
18
20
|
changes.forEach((c) => {
|
|
19
21
|
if (c.baseRev == null) c.baseRev = baseRev;
|
|
@@ -22,7 +24,8 @@ async function commitChanges(store, docId, changes, sessionTimeoutMillis) {
|
|
|
22
24
|
}
|
|
23
25
|
if (c.rev == null) c.rev = rev++;
|
|
24
26
|
else rev = c.rev + 1;
|
|
25
|
-
c.
|
|
27
|
+
c.committedAt = serverNow;
|
|
28
|
+
c.createdAt = clampTimestamp(c.createdAt, serverNow);
|
|
26
29
|
});
|
|
27
30
|
if (baseRev > currentRev) {
|
|
28
31
|
throw new Error(
|
|
@@ -36,7 +39,7 @@ async function commitChanges(store, docId, changes, sessionTimeoutMillis) {
|
|
|
36
39
|
);
|
|
37
40
|
}
|
|
38
41
|
const lastChange = currentChanges[currentChanges.length - 1];
|
|
39
|
-
if (lastChange && lastChange.
|
|
42
|
+
if (lastChange && timestampDiff(serverNow, lastChange.createdAt) > sessionTimeoutMillis) {
|
|
40
43
|
await createVersion(store, docId, currentState, currentChanges);
|
|
41
44
|
}
|
|
42
45
|
const committedChanges = await store.listChanges(docId, {
|
|
@@ -48,7 +51,7 @@ async function commitChanges(store, docId, changes, sessionTimeoutMillis) {
|
|
|
48
51
|
if (incomingChanges.length === 0) {
|
|
49
52
|
return [committedChanges, []];
|
|
50
53
|
}
|
|
51
|
-
const isOfflineTimestamp = incomingChanges[0].
|
|
54
|
+
const isOfflineTimestamp = timestampDiff(serverNow, incomingChanges[0].createdAt) > sessionTimeoutMillis;
|
|
52
55
|
if (isOfflineTimestamp || batchId) {
|
|
53
56
|
incomingChanges = await handleOfflineSessionsAndBatches(
|
|
54
57
|
store,
|
|
@@ -8,8 +8,9 @@ async function createVersion(store, docId, state, changes, metadata) {
|
|
|
8
8
|
}
|
|
9
9
|
const sessionMetadata = createVersionMetadata({
|
|
10
10
|
origin: "main",
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// Convert client timestamps to UTC for version metadata (enables lexicographic sorting)
|
|
12
|
+
startedAt: new Date(changes[0].createdAt).toISOString(),
|
|
13
|
+
endedAt: new Date(changes[changes.length - 1].createdAt).toISOString(),
|
|
13
14
|
rev: changes[changes.length - 1].rev,
|
|
14
15
|
baseRev,
|
|
15
16
|
...metadata
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import "../../chunk-IZ2YBCUP.js";
|
|
2
2
|
import { createSortableId } from "crypto-id";
|
|
3
3
|
import { createVersionMetadata } from "../../data/version.js";
|
|
4
|
+
import { timestampDiff } from "../../utils/dates.js";
|
|
4
5
|
import { applyChanges } from "../shared/applyChanges.js";
|
|
5
6
|
import { getStateAtRevision } from "./getStateAtRevision.js";
|
|
6
7
|
async function handleOfflineSessionsAndBatches(store, sessionTimeoutMillis, docId, changes, baseRev, batchId) {
|
|
@@ -22,11 +23,11 @@ async function handleOfflineSessionsAndBatches(store, sessionTimeoutMillis, docI
|
|
|
22
23
|
let sessionStartIndex = 0;
|
|
23
24
|
for (let i = 1; i <= changes.length; i++) {
|
|
24
25
|
const isLastChange = i === changes.length;
|
|
25
|
-
const timeDiff = isLastChange ? Infinity : changes[i].
|
|
26
|
+
const timeDiff = isLastChange ? Infinity : timestampDiff(changes[i].createdAt, changes[i - 1].createdAt);
|
|
26
27
|
if (timeDiff > sessionTimeoutMillis || isLastChange) {
|
|
27
28
|
const sessionChanges = changes.slice(sessionStartIndex, i);
|
|
28
29
|
if (sessionChanges.length > 0) {
|
|
29
|
-
const isContinuation = !!lastVersion && sessionChanges[0].
|
|
30
|
+
const isContinuation = !!lastVersion && timestampDiff(sessionChanges[0].createdAt, lastVersion.endedAt) <= sessionTimeoutMillis;
|
|
30
31
|
if (isContinuation) {
|
|
31
32
|
const mergedState = applyChanges(offlineBaseState, sessionChanges);
|
|
32
33
|
await store.saveChanges(docId, sessionChanges);
|
|
@@ -39,8 +40,9 @@ async function handleOfflineSessionsAndBatches(store, sessionTimeoutMillis, docI
|
|
|
39
40
|
parentId,
|
|
40
41
|
groupId,
|
|
41
42
|
origin: "offline",
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
// Convert client timestamps to UTC for version metadata (enables lexicographic sorting)
|
|
44
|
+
startedAt: new Date(sessionChanges[0].createdAt).toISOString(),
|
|
45
|
+
endedAt: new Date(sessionChanges[sessionChanges.length - 1].createdAt).toISOString(),
|
|
44
46
|
rev: sessionChanges[sessionChanges.length - 1].rev,
|
|
45
47
|
baseRev
|
|
46
48
|
});
|
package/dist/data/change.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import "../chunk-IZ2YBCUP.js";
|
|
2
2
|
import { inc } from "alphacounter";
|
|
3
3
|
import { createId } from "crypto-id";
|
|
4
|
+
import { createClientTimestamp } from "../utils/dates.js";
|
|
4
5
|
function createChangeId(rev) {
|
|
5
6
|
return inc.from(rev) + createId(4);
|
|
6
7
|
}
|
|
@@ -9,7 +10,7 @@ function createChange(baseRev, rev, ops, metadata) {
|
|
|
9
10
|
return {
|
|
10
11
|
id: createId(8),
|
|
11
12
|
ops: baseRev,
|
|
12
|
-
|
|
13
|
+
createdAt: createClientTimestamp(),
|
|
13
14
|
...rev
|
|
14
15
|
};
|
|
15
16
|
} else {
|
|
@@ -18,7 +19,7 @@ function createChange(baseRev, rev, ops, metadata) {
|
|
|
18
19
|
baseRev,
|
|
19
20
|
rev,
|
|
20
21
|
ops,
|
|
21
|
-
|
|
22
|
+
createdAt: createClientTimestamp(),
|
|
22
23
|
...metadata
|
|
23
24
|
};
|
|
24
25
|
}
|
|
@@ -2,6 +2,7 @@ import "../chunk-IZ2YBCUP.js";
|
|
|
2
2
|
import { createId } from "crypto-id";
|
|
3
3
|
import { createChange } from "../data/change.js";
|
|
4
4
|
import { createVersionMetadata } from "../data/version.js";
|
|
5
|
+
import { createServerTimestamp } from "../utils/dates.js";
|
|
5
6
|
class PatchesBranchManager {
|
|
6
7
|
constructor(store, patchesServer) {
|
|
7
8
|
this.store = store;
|
|
@@ -30,12 +31,12 @@ class PatchesBranchManager {
|
|
|
30
31
|
}
|
|
31
32
|
const stateAtRev = (await this.patchesServer.getStateAtRevision(docId, rev)).state;
|
|
32
33
|
const branchDocId = createId();
|
|
33
|
-
const now =
|
|
34
|
+
const now = createServerTimestamp();
|
|
34
35
|
const initialVersionMetadata = createVersionMetadata({
|
|
35
36
|
origin: "main",
|
|
36
37
|
// Branch doc versions are 'main' until merged
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
startedAt: now,
|
|
39
|
+
endedAt: now,
|
|
39
40
|
rev,
|
|
40
41
|
baseRev: rev,
|
|
41
42
|
name: metadata?.name,
|
|
@@ -48,7 +49,7 @@ class PatchesBranchManager {
|
|
|
48
49
|
id: branchDocId,
|
|
49
50
|
branchedFromId: docId,
|
|
50
51
|
branchedRev: rev,
|
|
51
|
-
|
|
52
|
+
createdAt: now,
|
|
52
53
|
status: "open"
|
|
53
54
|
};
|
|
54
55
|
await this.store.createBranch(branch);
|
|
@@ -126,7 +127,7 @@ class PatchesBranchManager {
|
|
|
126
127
|
return committedMergeChanges;
|
|
127
128
|
}
|
|
128
129
|
}
|
|
129
|
-
const nonModifiableMetadataFields = /* @__PURE__ */ new Set(["id", "branchedFromId", "branchedRev", "
|
|
130
|
+
const nonModifiableMetadataFields = /* @__PURE__ */ new Set(["id", "branchedFromId", "branchedRev", "createdAt", "status"]);
|
|
130
131
|
function assertBranchMetadata(metadata) {
|
|
131
132
|
if (!metadata) return;
|
|
132
133
|
for (const key in metadata) {
|
package/dist/types.d.ts
CHANGED
|
@@ -17,8 +17,8 @@ interface ChangeInput {
|
|
|
17
17
|
baseRev?: number;
|
|
18
18
|
/** Optional revision number. If omitted, server assigns based on current state. */
|
|
19
19
|
rev?: number;
|
|
20
|
-
/** Client-side timestamp when the change was created. */
|
|
21
|
-
|
|
20
|
+
/** Client-side ISO timestamp when the change was created (with timezone offset). */
|
|
21
|
+
createdAt: string;
|
|
22
22
|
/** Optional batch identifier for grouping changes that belong to the same client batch (for multi-batch offline/large edits). */
|
|
23
23
|
batchId?: string;
|
|
24
24
|
/** Optional arbitrary metadata associated with the change. */
|
|
@@ -33,6 +33,8 @@ interface Change extends ChangeInput {
|
|
|
33
33
|
baseRev: number;
|
|
34
34
|
/** The revision number assigned by the server after commit. */
|
|
35
35
|
rev: number;
|
|
36
|
+
/** Server-side ISO timestamp when the change was committed (UTC with Z). */
|
|
37
|
+
committedAt: string;
|
|
36
38
|
}
|
|
37
39
|
/**
|
|
38
40
|
* Represents the state of a document in the OT protocol.
|
|
@@ -69,8 +71,8 @@ interface Branch {
|
|
|
69
71
|
branchedFromId: string;
|
|
70
72
|
/** The revision number on the source document where the branch occurred. */
|
|
71
73
|
branchedRev: number;
|
|
72
|
-
/** Server-side timestamp when the branch
|
|
73
|
-
|
|
74
|
+
/** Server-side ISO timestamp when the branch was created (UTC with Z). */
|
|
75
|
+
createdAt: string;
|
|
74
76
|
/** Optional user-friendly name for the branch. */
|
|
75
77
|
name?: string;
|
|
76
78
|
/** Current status of the branch. */
|
|
@@ -78,7 +80,7 @@ interface Branch {
|
|
|
78
80
|
/** Optional arbitrary metadata associated with the branch record. */
|
|
79
81
|
[metadata: string]: any;
|
|
80
82
|
}
|
|
81
|
-
type EditableBranchMetadata = Disallowed<Branch, 'id' | 'branchedFromId' | 'branchedRev' | '
|
|
83
|
+
type EditableBranchMetadata = Disallowed<Branch, 'id' | 'branchedFromId' | 'branchedRev' | 'createdAt' | 'status'>;
|
|
82
84
|
/**
|
|
83
85
|
* Metadata, state snapshot, and included changes for a specific version.
|
|
84
86
|
*/
|
|
@@ -94,10 +96,10 @@ interface VersionMetadata {
|
|
|
94
96
|
origin: 'main' | 'offline' | 'branch';
|
|
95
97
|
/** User-defined name if origin is 'branch'. */
|
|
96
98
|
branchName?: string;
|
|
97
|
-
/**
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
|
|
99
|
+
/** Server-side ISO timestamp of version start (UTC with Z). */
|
|
100
|
+
startedAt: string;
|
|
101
|
+
/** Server-side ISO timestamp of version end (UTC with Z). */
|
|
102
|
+
endedAt: string;
|
|
101
103
|
/** The revision number this version was created at. */
|
|
102
104
|
rev: number;
|
|
103
105
|
/** The revision number on the main timeline before the changes that created this version. If this is an offline/branch version, this is the revision number of the source document where the branch was created and not . */
|
|
@@ -108,7 +110,7 @@ interface VersionMetadata {
|
|
|
108
110
|
type Disallowed<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> & {
|
|
109
111
|
[P in K]?: never;
|
|
110
112
|
};
|
|
111
|
-
type EditableVersionMetadata = Disallowed<VersionMetadata, 'id' | 'parentId' | 'groupId' | 'origin' | 'branchName' | '
|
|
113
|
+
type EditableVersionMetadata = Disallowed<VersionMetadata, 'id' | 'parentId' | 'groupId' | 'origin' | 'branchName' | 'startedAt' | 'endedAt' | 'rev' | 'baseRev'>;
|
|
112
114
|
/**
|
|
113
115
|
* Options for listing committed server changes. *Always* ordered by revision number.
|
|
114
116
|
*/
|
|
@@ -129,13 +131,13 @@ interface ListChangesOptions {
|
|
|
129
131
|
*/
|
|
130
132
|
interface ListVersionsOptions {
|
|
131
133
|
/** List versions whose orderBy field is *after* this value. */
|
|
132
|
-
startAfter?: number;
|
|
134
|
+
startAfter?: number | string;
|
|
133
135
|
/** List versions whose orderBy field is strictly *before* this value. */
|
|
134
|
-
endBefore?: number;
|
|
136
|
+
endBefore?: number | string;
|
|
135
137
|
/** Maximum number of versions to return. */
|
|
136
138
|
limit?: number;
|
|
137
|
-
/** Sort by
|
|
138
|
-
orderBy?: '
|
|
139
|
+
/** Sort by startedAt, rev, or baseRev. Defaults to 'rev'. */
|
|
140
|
+
orderBy?: 'startedAt' | 'rev' | 'baseRev';
|
|
139
141
|
/** Return versions in descending order. Defaults to false (ascending). When reversed, startAfter and endBefore apply to the *reversed* list. */
|
|
140
142
|
reverse?: boolean;
|
|
141
143
|
/** Filter by the origin type. */
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date utility functions for creating and manipulating ISO 8601 timestamps.
|
|
3
|
+
*
|
|
4
|
+
* Client-side timestamps use local timezone offsets (e.g., +04:00).
|
|
5
|
+
* Server-side timestamps use UTC with Z suffix.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Creates an ISO string with local timezone offset for client dates.
|
|
9
|
+
* Example: "2025-12-26T14:00:00.000+04:00"
|
|
10
|
+
*/
|
|
11
|
+
declare function createClientTimestamp(): string;
|
|
12
|
+
/**
|
|
13
|
+
* Creates an ISO string with Z suffix for server dates.
|
|
14
|
+
* Example: "2025-12-26T10:00:00.000Z"
|
|
15
|
+
*/
|
|
16
|
+
declare function createServerTimestamp(): string;
|
|
17
|
+
/**
|
|
18
|
+
* Parses an ISO string to a Date for comparisons.
|
|
19
|
+
*/
|
|
20
|
+
declare function parseTimestamp(iso: string): Date;
|
|
21
|
+
/**
|
|
22
|
+
* Calculates milliseconds between two ISO timestamps.
|
|
23
|
+
* Returns (a - b) in milliseconds.
|
|
24
|
+
*/
|
|
25
|
+
declare function timestampDiff(a: string, b: string): number;
|
|
26
|
+
/**
|
|
27
|
+
* Extracts the timezone offset string from an ISO timestamp.
|
|
28
|
+
* Returns "+04:00", "-05:00", or "Z".
|
|
29
|
+
*/
|
|
30
|
+
declare function extractTimezoneOffset(iso: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Clamps a timestamp to not exceed a limit, preserving the original timezone offset.
|
|
33
|
+
* Returns the original if it's <= limit, otherwise returns limit in original's timezone.
|
|
34
|
+
*
|
|
35
|
+
* Example:
|
|
36
|
+
* timestamp: "2025-12-26T18:00:00.000+04:00" (future)
|
|
37
|
+
* limit: "2025-12-26T10:00:00.000Z" (server time)
|
|
38
|
+
* result: "2025-12-26T14:00:00.000+04:00" (clamped, same instant as limit)
|
|
39
|
+
*/
|
|
40
|
+
declare function clampTimestamp(timestamp: string, limit: string): string;
|
|
41
|
+
/**
|
|
42
|
+
* Formats a Date with a specific timezone offset string.
|
|
43
|
+
* The date is adjusted to display the correct local time for that offset.
|
|
44
|
+
*/
|
|
45
|
+
declare function formatWithOffset(date: Date, offset: string): string;
|
|
46
|
+
/**
|
|
47
|
+
* Gets the local timezone offset string for the current environment.
|
|
48
|
+
* Returns "+04:00", "-05:00", or "Z" for UTC.
|
|
49
|
+
*/
|
|
50
|
+
declare function getLocalTimezoneOffset(): string;
|
|
51
|
+
|
|
52
|
+
export { clampTimestamp, createClientTimestamp, createServerTimestamp, extractTimezoneOffset, formatWithOffset, getLocalTimezoneOffset, parseTimestamp, timestampDiff };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import "../chunk-IZ2YBCUP.js";
|
|
2
|
+
function createClientTimestamp() {
|
|
3
|
+
const now = /* @__PURE__ */ new Date();
|
|
4
|
+
return formatWithOffset(now, getLocalTimezoneOffset());
|
|
5
|
+
}
|
|
6
|
+
function createServerTimestamp() {
|
|
7
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
8
|
+
}
|
|
9
|
+
function parseTimestamp(iso) {
|
|
10
|
+
return new Date(iso);
|
|
11
|
+
}
|
|
12
|
+
function timestampDiff(a, b) {
|
|
13
|
+
return new Date(a).getTime() - new Date(b).getTime();
|
|
14
|
+
}
|
|
15
|
+
function extractTimezoneOffset(iso) {
|
|
16
|
+
const match = iso.match(/([+-]\d{2}:\d{2}|Z)$/);
|
|
17
|
+
return match ? match[1] : "Z";
|
|
18
|
+
}
|
|
19
|
+
function clampTimestamp(timestamp, limit) {
|
|
20
|
+
const timestampDate = new Date(timestamp);
|
|
21
|
+
const limitDate = new Date(limit);
|
|
22
|
+
if (timestampDate <= limitDate) {
|
|
23
|
+
return timestamp;
|
|
24
|
+
}
|
|
25
|
+
const offset = extractTimezoneOffset(timestamp);
|
|
26
|
+
return formatWithOffset(limitDate, offset);
|
|
27
|
+
}
|
|
28
|
+
function formatWithOffset(date, offset) {
|
|
29
|
+
if (offset === "Z") {
|
|
30
|
+
return date.toISOString();
|
|
31
|
+
}
|
|
32
|
+
const match = offset.match(/([+-])(\d{2}):(\d{2})/);
|
|
33
|
+
if (!match) return date.toISOString();
|
|
34
|
+
const sign = match[1] === "+" ? 1 : -1;
|
|
35
|
+
const offsetMinutes = sign * (parseInt(match[2]) * 60 + parseInt(match[3]));
|
|
36
|
+
const localDate = new Date(date.getTime() + offsetMinutes * 60 * 1e3);
|
|
37
|
+
const iso = localDate.toISOString();
|
|
38
|
+
return iso.slice(0, -1) + offset;
|
|
39
|
+
}
|
|
40
|
+
function getLocalTimezoneOffset() {
|
|
41
|
+
const offset = -(/* @__PURE__ */ new Date()).getTimezoneOffset();
|
|
42
|
+
if (offset === 0) return "Z";
|
|
43
|
+
const hours = Math.floor(Math.abs(offset) / 60);
|
|
44
|
+
const mins = Math.abs(offset) % 60;
|
|
45
|
+
const sign = offset >= 0 ? "+" : "-";
|
|
46
|
+
return `${sign}${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
|
|
47
|
+
}
|
|
48
|
+
export {
|
|
49
|
+
clampTimestamp,
|
|
50
|
+
createClientTimestamp,
|
|
51
|
+
createServerTimestamp,
|
|
52
|
+
extractTimezoneOffset,
|
|
53
|
+
formatWithOffset,
|
|
54
|
+
getLocalTimezoneOffset,
|
|
55
|
+
parseTimestamp,
|
|
56
|
+
timestampDiff
|
|
57
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dabble/patches",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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": {
|