@dabble/patches 0.5.17 → 0.5.18

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.
@@ -0,0 +1,30 @@
1
+ import { Change } from '../../types.js';
2
+ import '../../json-patch/JSONPatch.js';
3
+ import '@dabble/delta';
4
+ import '../../json-patch/types.js';
5
+
6
+ /**
7
+ * Collapses redundant pending changes before sync to reduce network traffic.
8
+ *
9
+ * This optimization automatically detects and collapses multiple "replace" operations
10
+ * on the same JSON path with primitive values (boolean, number, string, null) into
11
+ * a single change containing only the final value.
12
+ *
13
+ * Example: If a user toggles a folder's open state 100 times while offline,
14
+ * this collapses those 100 changes into just 1 change with the final state.
15
+ *
16
+ * Safety guarantees:
17
+ * - Only collapses single-op changes (multi-op changes are atomic, preserve intent)
18
+ * - Only collapses "replace" operations (not add, remove, move)
19
+ * - Only collapses primitive values (not objects/arrays)
20
+ * - Detects path invalidation from structural changes (remove, array shifts, move)
21
+ * - Respects the submission bookmark to never collapse already-submitted changes
22
+ *
23
+ * @param changes Array of pending changes to potentially collapse
24
+ * @param afterRev Optional revision bookmark - changes at or before this rev are not collapsed
25
+ * (they may have been partially submitted to the server)
26
+ * @returns Collapsed array of changes, maintaining correct ordering
27
+ */
28
+ declare function collapsePendingChanges(changes: Change[], afterRev?: number): Change[];
29
+
30
+ export { collapsePendingChanges };
@@ -0,0 +1,78 @@
1
+ import "../../chunk-IZ2YBCUP.js";
2
+ function collapsePendingChanges(changes, afterRev) {
3
+ if (changes.length <= 1) {
4
+ return changes;
5
+ }
6
+ const pathState = /* @__PURE__ */ new Map();
7
+ const outputSlots = new Array(changes.length).fill(null);
8
+ for (let i = 0; i < changes.length; i++) {
9
+ const change = changes[i];
10
+ if (afterRev !== void 0 && change.rev !== void 0 && change.rev <= afterRev) {
11
+ outputSlots[i] = change;
12
+ continue;
13
+ }
14
+ updatePathInvalidations(change, pathState);
15
+ if (!isCollapsibleChange(change)) {
16
+ outputSlots[i] = change;
17
+ continue;
18
+ }
19
+ const path = change.ops[0].path;
20
+ const existing = pathState.get(path);
21
+ if (existing) {
22
+ outputSlots[existing.lastIndex] = null;
23
+ }
24
+ pathState.set(path, { lastChange: change, lastIndex: i });
25
+ outputSlots[i] = change;
26
+ }
27
+ return outputSlots.filter((c) => c !== null);
28
+ }
29
+ function isCollapsibleChange(change) {
30
+ if (change.ops.length !== 1) {
31
+ return false;
32
+ }
33
+ const op = change.ops[0];
34
+ if (op.op !== "replace") {
35
+ return false;
36
+ }
37
+ return isPrimitiveValue(op.value);
38
+ }
39
+ function isPrimitiveValue(value) {
40
+ if (value === null) return true;
41
+ const type = typeof value;
42
+ return type === "boolean" || type === "number" || type === "string";
43
+ }
44
+ function updatePathInvalidations(change, pathState) {
45
+ for (const op of change.ops) {
46
+ if (op.op === "remove" || op.op === "move") {
47
+ invalidatePathAndChildren(op.path, pathState);
48
+ if (op.op === "move" && "from" in op) {
49
+ invalidatePathAndChildren(op.from, pathState);
50
+ }
51
+ }
52
+ if (op.op === "add" || op.op === "remove") {
53
+ invalidateShiftedArrayPaths(op.path, pathState);
54
+ }
55
+ }
56
+ }
57
+ function invalidatePathAndChildren(opPath, pathState) {
58
+ for (const trackedPath of pathState.keys()) {
59
+ if (trackedPath === opPath || trackedPath.startsWith(opPath + "/")) {
60
+ pathState.delete(trackedPath);
61
+ }
62
+ }
63
+ }
64
+ function invalidateShiftedArrayPaths(opPath, pathState) {
65
+ const segments = opPath.split("/");
66
+ const lastSegment = segments[segments.length - 1];
67
+ if (/^\d+$/.test(lastSegment)) {
68
+ const arrayPath = segments.slice(0, -1).join("/");
69
+ for (const trackedPath of pathState.keys()) {
70
+ if (trackedPath.startsWith(arrayPath + "/")) {
71
+ pathState.delete(trackedPath);
72
+ }
73
+ }
74
+ }
75
+ }
76
+ export {
77
+ collapsePendingChanges
78
+ };
@@ -37,7 +37,7 @@ async function commitChanges(store, docId, changes, sessionTimeoutMillis, option
37
37
  const laterPartOfAnInitialBatch = batchId && changes[0].rev > 1;
38
38
  if (baseRev === 0 && currentRev > 0 && !laterPartOfAnInitialBatch && changes[0].ops[0]?.path === "") {
39
39
  throw new Error(
40
- `Document ${docId} already exists at rev ${currentRev}, but client is attempting to create it. Client needs to load the existing document.`
40
+ `Document ${docId} already exists (rev ${currentRev}). Cannot apply root-level replace (path: '') with baseRev 0 - this would overwrite the existing document. Load the existing document first, or use nested paths instead of replacing at root.`
41
41
  );
42
42
  }
43
43
  const lastChange = currentChanges[currentChanges.length - 1];
@@ -24,6 +24,8 @@ declare class InMemoryStore implements PatchesStore {
24
24
  deleteDoc(docId: string): Promise<void>;
25
25
  confirmDeleteDoc(docId: string): Promise<void>;
26
26
  close(): Promise<void>;
27
+ getLastAttemptedSubmissionRev(docId: string): Promise<number | undefined>;
28
+ setLastAttemptedSubmissionRev(docId: string, rev: number): Promise<void>;
27
29
  }
28
30
 
29
31
  export { InMemoryStore };
@@ -91,6 +91,16 @@ class InMemoryStore {
91
91
  async close() {
92
92
  this.docs.clear();
93
93
  }
94
+ // ─── Submission Bookmark ───────────────────────────────────────────────
95
+ async getLastAttemptedSubmissionRev(docId) {
96
+ return this.docs.get(docId)?.lastAttemptedSubmissionRev;
97
+ }
98
+ async setLastAttemptedSubmissionRev(docId, rev) {
99
+ const buf = this.docs.get(docId);
100
+ if (buf) {
101
+ buf.lastAttemptedSubmissionRev = rev;
102
+ }
103
+ }
94
104
  }
95
105
  export {
96
106
  InMemoryStore
@@ -1,4 +1,5 @@
1
1
  import { PatchesSnapshot, PatchesState, Change } from '../types.js';
2
+ import { Deferred } from '../utils/deferred.js';
2
3
  import { PatchesStore, TrackedDoc } from './PatchesStore.js';
3
4
  import '../json-patch/JSONPatch.js';
4
5
  import '@dabble/delta';
@@ -17,12 +18,12 @@ import '../json-patch/types.js';
17
18
  * A snapshot will not be created if there are pending changes based on revisions older than the 200th committed change until those pending changes are committed.
18
19
  */
19
20
  declare class IndexedDBStore implements PatchesStore {
20
- private db;
21
- private dbName?;
22
- private dbPromise;
21
+ protected db: IDBDatabase | null;
22
+ protected dbName?: string;
23
+ protected dbPromise: Deferred<IDBDatabase>;
23
24
  constructor(dbName?: string);
24
- private initDB;
25
- private getDB;
25
+ protected initDB(): Promise<void>;
26
+ protected getDB(): Promise<IDBDatabase>;
26
27
  /**
27
28
  * Set the name of the database, loads a new database connection.
28
29
  * @param dbName - The new name of the database.
@@ -35,7 +36,7 @@ declare class IndexedDBStore implements PatchesStore {
35
36
  */
36
37
  close(): Promise<void>;
37
38
  deleteDB(): Promise<void>;
38
- private transaction;
39
+ protected transaction(storeNames: string[], mode: IDBTransactionMode): Promise<[IDBTransactionWrapper, ...IDBStoreWrapper[]]>;
39
40
  /**
40
41
  * Rebuilds a document snapshot + pending queue *without* loading
41
42
  * the full PatchesDoc into memory.
@@ -113,6 +114,38 @@ declare class IndexedDBStore implements PatchesStore {
113
114
  * - build new patch: newChange.rev = pendingRev; baseRev = committedRev
114
115
  */
115
116
  getLastRevs(docId: string): Promise<[number, number]>;
117
+ /**
118
+ * Gets the last revision that was attempted to be submitted to the server.
119
+ * @param docId - The ID of the document.
120
+ * @returns The last attempted submission revision, or undefined if none.
121
+ */
122
+ getLastAttemptedSubmissionRev(docId: string): Promise<number | undefined>;
123
+ /**
124
+ * Sets the last revision that was attempted to be submitted to the server.
125
+ * @param docId - The ID of the document.
126
+ * @param rev - The revision being submitted.
127
+ */
128
+ setLastAttemptedSubmissionRev(docId: string, rev: number): Promise<void>;
129
+ }
130
+ declare class IDBTransactionWrapper {
131
+ protected tx: IDBTransaction;
132
+ protected promise: Promise<void>;
133
+ constructor(tx: IDBTransaction);
134
+ getStore(name: string): IDBStoreWrapper;
135
+ complete(): Promise<void>;
136
+ }
137
+ declare class IDBStoreWrapper {
138
+ protected store: IDBObjectStore;
139
+ constructor(store: IDBObjectStore);
140
+ protected createRange(lower?: any, upper?: any): IDBKeyRange | undefined;
141
+ getAll<T>(lower?: any, upper?: any, count?: number): Promise<T[]>;
142
+ get<T>(key: IDBValidKey): Promise<T | undefined>;
143
+ put<T>(value: T): Promise<IDBValidKey>;
144
+ delete(key: IDBValidKey): Promise<void>;
145
+ delete(lower: any, upper: any): Promise<void>;
146
+ count(lower?: any, upper?: any): Promise<number>;
147
+ getFirstFromCursor<T>(lower?: any, upper?: any): Promise<T | undefined>;
148
+ getLastFromCursor<T>(lower?: any, upper?: any): Promise<T | undefined>;
116
149
  }
117
150
 
118
151
  export { IndexedDBStore };
@@ -297,6 +297,31 @@ class IndexedDBStore {
297
297
  await tx.complete();
298
298
  return [lastCommitted?.rev ?? 0, lastPending?.rev ?? lastCommitted?.rev ?? 0];
299
299
  }
300
+ // ─── Submission Bookmark ───────────────────────────────────────────────
301
+ /**
302
+ * Gets the last revision that was attempted to be submitted to the server.
303
+ * @param docId - The ID of the document.
304
+ * @returns The last attempted submission revision, or undefined if none.
305
+ */
306
+ async getLastAttemptedSubmissionRev(docId) {
307
+ const [tx, docsStore] = await this.transaction(["docs"], "readonly");
308
+ const docMeta = await docsStore.get(docId);
309
+ await tx.complete();
310
+ return docMeta?.lastAttemptedSubmissionRev;
311
+ }
312
+ /**
313
+ * Sets the last revision that was attempted to be submitted to the server.
314
+ * @param docId - The ID of the document.
315
+ * @param rev - The revision being submitted.
316
+ */
317
+ async setLastAttemptedSubmissionRev(docId, rev) {
318
+ const [tx, docsStore] = await this.transaction(["docs"], "readwrite");
319
+ const docMeta = await docsStore.get(docId);
320
+ if (docMeta) {
321
+ await docsStore.put({ ...docMeta, lastAttemptedSubmissionRev: rev });
322
+ }
323
+ await tx.complete();
324
+ }
300
325
  }
301
326
  _init = __decoratorStart(null);
302
327
  __decorateElement(_init, 1, "getDoc", _getDoc_dec, IndexedDBStore);
@@ -10,6 +10,8 @@ interface TrackedDoc {
10
10
  committedRev: number;
11
11
  /** Optional flag indicating the document has been locally deleted. */
12
12
  deleted?: true;
13
+ /** The last revision that was attempted to be submitted to the server. */
14
+ lastAttemptedSubmissionRev?: number;
13
15
  }
14
16
  /**
15
17
  * Pluggable persistence layer contract used by Patches + PatchesSync.
@@ -217,6 +219,35 @@ interface PatchesStore {
217
219
  * // Store is no longer usable
218
220
  */
219
221
  close(): Promise<void>;
222
+ /**
223
+ * Gets the last revision that was attempted to be submitted to the server.
224
+ *
225
+ * This bookmark is used by change collapsing to avoid modifying changes that
226
+ * may have been partially committed by the server. Returns undefined if no
227
+ * submission has been attempted yet.
228
+ *
229
+ * @param docId Document identifier
230
+ * @returns The last attempted submission revision, or undefined if none
231
+ * @example
232
+ * const lastAttempted = await store.getLastAttemptedSubmissionRev('my-document');
233
+ * // Use this to protect changes from collapsing
234
+ */
235
+ getLastAttemptedSubmissionRev?(docId: string): Promise<number | undefined>;
236
+ /**
237
+ * Sets the last revision that was attempted to be submitted to the server.
238
+ *
239
+ * Called before sending changes to the server to mark them as "in flight".
240
+ * This prevents change collapsing from modifying these changes in case the
241
+ * server commits them but the client doesn't receive confirmation.
242
+ *
243
+ * @param docId Document identifier
244
+ * @param rev The revision being submitted
245
+ * @example
246
+ * // Before sending batch to server
247
+ * await store.setLastAttemptedSubmissionRev('my-document', lastChange.rev);
248
+ * await sendToServer(batch);
249
+ */
250
+ setLastAttemptedSubmissionRev?(docId: string, rev: number): Promise<void>;
220
251
  }
221
252
 
222
253
  export type { PatchesStore, TrackedDoc };
@@ -8,6 +8,7 @@ import '../types.js';
8
8
  import '../json-patch/JSONPatch.js';
9
9
  import '@dabble/delta';
10
10
  import '../json-patch/types.js';
11
+ import '../utils/deferred.js';
11
12
  import '../event-signal.js';
12
13
  import '../algorithms/shared/changeBatching.js';
13
14
  import '../net/protocol/types.js';
package/dist/index.d.ts CHANGED
@@ -26,5 +26,6 @@ export { move } from './json-patch/ops/move.js';
26
26
  export { remove } from './json-patch/ops/remove.js';
27
27
  export { replace } from './json-patch/ops/replace.js';
28
28
  export { test } from './json-patch/ops/test.js';
29
+ import './utils/deferred.js';
29
30
  import './algorithms/shared/changeBatching.js';
30
31
  import './net/protocol/types.js';
@@ -10,8 +10,14 @@ import { JSONPatchOp, JSONPatchOpHandlerMap, ApplyJSONPatchOptions } from './typ
10
10
  * (c) 2022 Jacob Wright
11
11
  *
12
12
  *
13
- * WARNING: using /array/- syntax to indicate the end of the array makes it impossible to transform arrays correctly in
14
- * all situaions. Please avoid using this syntax when using Operational Transformations.
13
+ * NOTE ON ARRAY APPEND SYNTAX: The /array/- path syntax (append to end) has limitations with
14
+ * Operational Transformations. It's safe when:
15
+ * - The appended value won't be modified by subsequent operations, OR
16
+ * - The append commits to the server before any operations reference the item by index
17
+ *
18
+ * It causes problems when you append with /items/- then reference by index (e.g., /items/3/name)
19
+ * in uncommitted operations — the index can be transformed but the - cannot, causing them to
20
+ * target different items after concurrent changes. See docs/json-patch.md for details.
15
21
  */
16
22
 
17
23
  type PathLike = string | {
@@ -8,8 +8,14 @@ import "../chunk-IZ2YBCUP.js";
8
8
  * (c) 2022 Jacob Wright
9
9
  *
10
10
  *
11
- * WARNING: using /array/- syntax to indicate the end of the array makes it impossible to transform arrays correctly in
12
- * all situaions. Please avoid using this syntax when using Operational Transformations.
11
+ * NOTE ON ARRAY APPEND SYNTAX: The /array/- path syntax (append to end) has limitations with
12
+ * Operational Transformations. It's safe when:
13
+ * - The appended value won't be modified by subsequent operations, OR
14
+ * - The append commits to the server before any operations reference the item by index
15
+ *
16
+ * It causes problems when you append with /items/- then reference by index (e.g., /items/3/name)
17
+ * in uncommitted operations — the index can be transformed but the - cannot, causing them to
18
+ * target different items after concurrent changes. See docs/json-patch.md for details.
13
19
  */
14
20
  import { Delta } from "@dabble/delta";
15
21
  import { applyPatch } from "./applyPatch.js";
@@ -29,7 +29,12 @@ const add = {
29
29
  pluckWithShallowCopy(state, keys, true, true).splice(index, 0, value);
30
30
  } else {
31
31
  if (!deepEqual(target[lastKey], value)) {
32
- pluckWithShallowCopy(state, keys, true, true)[lastKey] = value;
32
+ const container = pluckWithShallowCopy(state, keys, true, true);
33
+ if (Array.isArray(container) && lastKey === "-") {
34
+ container.push(value);
35
+ } else {
36
+ container[lastKey] = value;
37
+ }
33
38
  }
34
39
  }
35
40
  },
@@ -9,8 +9,14 @@ import { JSONPatchOp, JSONPatchOpHandlerMap } from './types.js';
9
9
  * (c) 2022 Jacob Wright
10
10
  *
11
11
  *
12
- * WARNING: using /array/- syntax to indicate the end of the array makes it impossible to transform arrays correctly in
13
- * all situaions. Please avoid using this syntax when using Operational Transformations.
12
+ * NOTE ON ARRAY APPEND SYNTAX: The /array/- path syntax (append to end) has limitations with
13
+ * Operational Transformations. It's safe when:
14
+ * - The appended value won't be modified by subsequent operations, OR
15
+ * - The append commits to the server before any operations reference the item by index
16
+ *
17
+ * It causes problems when you append with /items/- then reference by index (e.g., /items/3/name)
18
+ * in uncommitted operations — the index can be transformed but the - cannot, causing them to
19
+ * target different items after concurrent changes. See docs/json-patch.md for details.
14
20
  */
15
21
 
16
22
  /**
@@ -8,8 +8,14 @@ import "../chunk-IZ2YBCUP.js";
8
8
  * (c) 2022 Jacob Wright
9
9
  *
10
10
  *
11
- * WARNING: using /array/- syntax to indicate the end of the array makes it impossible to transform arrays correctly in
12
- * all situaions. Please avoid using this syntax when using Operational Transformations.
11
+ * NOTE ON ARRAY APPEND SYNTAX: The /array/- path syntax (append to end) has limitations with
12
+ * Operational Transformations. It's safe when:
13
+ * - The appended value won't be modified by subsequent operations, OR
14
+ * - The append commits to the server before any operations reference the item by index
15
+ *
16
+ * It causes problems when you append with /items/- then reference by index (e.g., /items/3/name)
17
+ * in uncommitted operations — the index can be transformed but the - cannot, causing them to
18
+ * target different items after concurrent changes. See docs/json-patch.md for details.
13
19
  */
14
20
  import { getTypes } from "./ops/index.js";
15
21
  import { runWithObject } from "./state.js";
@@ -17,8 +17,18 @@ function pluckWithShallowCopy(state, keys, createMissingObjects, createMissingAr
17
17
  let object = state.root;
18
18
  for (let i = 0, imax = keys.length - 1; i < imax; i++) {
19
19
  const key = keys[i];
20
- const container = createMissingArrays && keys[i + 1] === "0" ? EMPTY_ARRAY : EMPTY;
21
- object = object[key] = createMissingObjects && !object[key] ? getValue(state, container) : getValue(state, object[key]);
20
+ const container = createMissingArrays && (keys[i + 1] === "0" || keys[i + 1] === "-") ? EMPTY_ARRAY : EMPTY;
21
+ if (key === "-" && Array.isArray(object)) {
22
+ if (createMissingObjects && object.length === 0) {
23
+ const newItem = getValue(state, container);
24
+ object.push(newItem);
25
+ object = newItem;
26
+ } else {
27
+ object = getValue(state, object[object.length - 1]);
28
+ }
29
+ } else {
30
+ object = object[key] = createMissingObjects && !object[key] ? getValue(state, container) : getValue(state, object[key]);
31
+ }
22
32
  }
23
33
  return object;
24
34
  }
@@ -134,14 +134,6 @@ declare class PatchesSync {
134
134
  * Helper to detect DOC_DELETED (410) errors from the server.
135
135
  */
136
136
  protected _isDocDeletedError(err: unknown): boolean;
137
- /**
138
- * Helper to detect "document already exists" errors from the server.
139
- */
140
- private _isDocExistsError;
141
- /**
142
- * Recovers from a "document already exists" error by fetching server state and retrying.
143
- */
144
- private _recoverFromDocExists;
145
137
  }
146
138
 
147
139
  export { PatchesSync, type PatchesSyncOptions, type PatchesSyncState };
@@ -8,6 +8,7 @@ import {
8
8
  var __receiveCommittedChanges_dec, _syncDoc_dec, _init;
9
9
  import { isEqual } from "@dabble/delta";
10
10
  import { applyCommittedChanges } from "../algorithms/client/applyCommittedChanges.js";
11
+ import { collapsePendingChanges } from "../algorithms/client/collapsePendingChanges.js";
11
12
  import { breakChangesIntoBatches } from "../algorithms/shared/changeBatching.js";
12
13
  import { Patches } from "../client/Patches.js";
13
14
  import { signal } from "../event-signal.js";
@@ -217,6 +218,13 @@ class PatchesSync {
217
218
  if (!pending.length) {
218
219
  return;
219
220
  }
221
+ if (this.store.getLastAttemptedSubmissionRev && this.store.setLastAttemptedSubmissionRev) {
222
+ const afterRev = await this.store.getLastAttemptedSubmissionRev(docId);
223
+ pending = collapsePendingChanges(pending, afterRev);
224
+ if (!pending.length) {
225
+ return;
226
+ }
227
+ }
220
228
  const batches = breakChangesIntoBatches(pending, {
221
229
  maxPayloadBytes: this.maxPayloadBytes,
222
230
  maxStorageBytes: this.maxStorageBytes,
@@ -226,6 +234,10 @@ class PatchesSync {
226
234
  if (!this.state.connected) {
227
235
  throw new Error("Disconnected during flush");
228
236
  }
237
+ if (this.store.setLastAttemptedSubmissionRev) {
238
+ const lastRevInBatch = batch[batch.length - 1].rev;
239
+ await this.store.setLastAttemptedSubmissionRev(docId, lastRevInBatch);
240
+ }
229
241
  const range = [batch[0].rev, batch[batch.length - 1].rev];
230
242
  const committed = await this.ws.commitChanges(docId, batch);
231
243
  await this._applyServerChangesToDoc(docId, committed, range);
@@ -236,10 +248,6 @@ class PatchesSync {
236
248
  await this._handleRemoteDocDeleted(docId);
237
249
  return;
238
250
  }
239
- if (this._isDocExistsError(err)) {
240
- await this._recoverFromDocExists(docId);
241
- return;
242
- }
243
251
  console.error(`Flush failed for doc ${docId}:`, err);
244
252
  this.onError.emit(err, { docId });
245
253
  throw err;
@@ -368,23 +376,6 @@ class PatchesSync {
368
376
  _isDocDeletedError(err) {
369
377
  return typeof err === "object" && err !== null && "code" in err && err.code === 410;
370
378
  }
371
- /**
372
- * Helper to detect "document already exists" errors from the server.
373
- */
374
- _isDocExistsError(err) {
375
- const message = err?.message ?? "";
376
- return message.includes("already exists");
377
- }
378
- /**
379
- * Recovers from a "document already exists" error by fetching server state and retrying.
380
- */
381
- async _recoverFromDocExists(docId) {
382
- const serverChanges = await this.ws.getChangesSince(docId, 0);
383
- const rebasedPending = await this._applyServerChangesToDoc(docId, serverChanges);
384
- if (rebasedPending.length > 0) {
385
- await this.flushDoc(docId, rebasedPending);
386
- }
387
- }
388
379
  }
389
380
  _init = __decoratorStart(null);
390
381
  __decorateElement(_init, 1, "syncDoc", _syncDoc_dec, PatchesSync);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dabble/patches",
3
- "version": "0.5.17",
3
+ "version": "0.5.18",
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": {