@dabble/patches 0.4.10 → 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.
@@ -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.created = Math.min(c.created, Date.now());
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.created < Date.now() - sessionTimeoutMillis) {
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].created < Date.now() - sessionTimeoutMillis;
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
- startDate: changes[0].created,
12
- endDate: changes[changes.length - 1].created,
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].created - changes[i - 1].created;
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].created - lastVersion.endDate <= sessionTimeoutMillis;
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
- startDate: sessionChanges[0].created,
43
- endDate: sessionChanges[sessionChanges.length - 1].created,
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
  });
@@ -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
- created: Date.now(),
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
- created: Date.now(),
22
+ createdAt: createClientTimestamp(),
22
23
  ...metadata
23
24
  };
24
25
  }
@@ -62,6 +62,7 @@ class JSONPatch {
62
62
  */
63
63
  replace(path, value, options) {
64
64
  if (value && value.toJSON) value = value.toJSON();
65
+ if (value === void 0) return this.remove(path);
65
66
  return this.op("replace", path, value, void 0, options?.soft);
66
67
  }
67
68
  /**
@@ -153,6 +153,9 @@ class PatchesSync {
153
153
  } else {
154
154
  const snapshot = await this.ws.getDoc(docId);
155
155
  await this.store.saveDoc(docId, snapshot);
156
+ if (doc) {
157
+ doc.import({ ...snapshot, changes: [] });
158
+ }
156
159
  }
157
160
  }
158
161
  if (doc) {
@@ -13,7 +13,7 @@ class FetchTransport {
13
13
  }
14
14
  async send(raw) {
15
15
  try {
16
- const response = await fetch(this.url, {
16
+ const response = await globalThis.fetch(this.url, {
17
17
  method: "POST",
18
18
  headers: {
19
19
  "Content-Type": "application/json",
@@ -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 = Date.now();
34
+ const now = createServerTimestamp();
34
35
  const initialVersionMetadata = createVersionMetadata({
35
36
  origin: "main",
36
37
  // Branch doc versions are 'main' until merged
37
- startDate: now,
38
- endDate: now,
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
- created: now,
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", "created", "status"]);
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) {
@@ -14,7 +14,7 @@ class PatchesHistoryManager {
14
14
  */
15
15
  async listVersions(docId, options = {}) {
16
16
  if (!options.orderBy) {
17
- options.orderBy = "startDate";
17
+ options.orderBy = "startedAt";
18
18
  }
19
19
  return await this.store.listVersions(docId, options);
20
20
  }
@@ -119,8 +119,8 @@ const nonModifiableMetadataFields = /* @__PURE__ */ new Set([
119
119
  "groupId",
120
120
  "origin",
121
121
  "branchName",
122
- "startDate",
123
- "endDate",
122
+ "startedAt",
123
+ "endedAt",
124
124
  "rev",
125
125
  "baseRev"
126
126
  ]);
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
- created: number;
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 record was created. */
73
- created: number;
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' | 'created' | 'status'>;
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
- /** Timestamp marking the beginning of the changes included in this version (e.g., first change in session). */
98
- startDate: number;
99
- /** Timestamp marking the end of the changes included in this version (e.g., last change in session). */
100
- endDate: number;
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' | 'startDate' | 'endDate' | 'rev' | 'baseRev'>;
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 start date, rev, or baseRev. Defaults to 'rev'. */
138
- orderBy?: 'startDate' | 'rev' | 'baseRev';
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.4.10",
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": {