flarp 1.0.1 → 2.0.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.
package/README.md CHANGED
@@ -199,16 +199,19 @@ store.emit('addUser', { name: 'Bob', role: 'Developer' });
199
199
 
200
200
  Every XML node is independently:
201
201
  - **Reactive** — Has its own Signal
202
- - **Identifiable** — UUID attribute
203
- - **Versioned** — `rev` for conflict resolution
202
+ - **Identifiable** — `uuid` attribute (stable identity)
203
+ - **Versioned** — `rev` for conflict detection & resolution
204
204
  - **Serializable** — `node.serialize()`
205
205
 
206
206
  ```js
207
- const node = store.at('User.Name');
207
+ const node = store.at('user.name');
208
208
 
209
- node.uuid; // "a1b2c3..."
210
- node.rev; // 5
211
- node.serialize(); // "<n uuid="..." rev="5">Alice</n>"
209
+ node.uuid; // "a1b2c3..." (stable identity)
210
+ node.rev; // "5-f94bc318..." (revision)
211
+ node.revNumber; // 5
212
+ node.revId; // "f94bc318..." (write identifier)
213
+
214
+ node.serialize(); // '<name uuid="..." rev="5-...">Alice</name>'
212
215
 
213
216
  // Subscribe to just this node
214
217
  node.subscribe(value => console.log(value));
@@ -216,6 +219,49 @@ node.subscribe(value => console.log(value));
216
219
 
217
220
  ---
218
221
 
222
+ ## Revision Format (CouchDB-style)
223
+
224
+ Flarp uses CouchDB-style revision tracking: `{number}-{uuid}`
225
+
226
+ ```
227
+ rev="3-ee30f92d-a785-4603-b0b8-b681b8707e39"
228
+ │ └─ Revision UUID (fresh on each write)
229
+ └─ Revision number (increments)
230
+ ```
231
+
232
+ **Why two parts?**
233
+
234
+ 1. **Revision number** — Quick comparison (higher wins)
235
+ 2. **Revision UUID** — Conflict detection & tie-breaking
236
+
237
+ **Conflict scenario:**
238
+ ```
239
+ User A saves: rev="3-ee30f92d..."
240
+ User B saves: rev="3-56b1d51a..."
241
+ ↑ Same number = CONFLICT detected
242
+
243
+ Tie-breaker: Sort UUIDs alphabetically, first wins
244
+ "56b1d51a" < "ee30f92d" → User B wins
245
+ ```
246
+
247
+ This ensures:
248
+ - No data loss (both writes saved with unique UUIDs)
249
+ - Consistent winner selection (all nodes agree independently)
250
+ - Eventual consistency across distributed systems
251
+
252
+ ```js
253
+ // Check for conflict
254
+ if (node.conflictsWith(remoteRev)) {
255
+ console.log('Conflict detected!');
256
+ }
257
+
258
+ // Merge with automatic conflict resolution
259
+ const result = node.merge(remoteXml);
260
+ // { applied: true, conflict: true, winner: 'remote' }
261
+ ```
262
+
263
+ ---
264
+
219
265
  ## Persistence
220
266
 
221
267
  State persists to localStorage automatically:
@@ -323,16 +369,18 @@ Modern browsers with ES modules:
323
369
 
324
370
  ---
325
371
 
326
- ## License
372
+ ## Name
327
373
 
328
- MIT
374
+ flarp (from Jargon File)
375
+
376
+ /flarp/ [Rutgers University] Yet another metasyntactic variable (see foo). Among those who use it, it is associated with a legend that any program not containing the word "flarp" somewhere will not work. The legend is discreetly silent on the reliability of programs which *do* contain the magic word.
329
377
 
330
378
  ---
331
379
 
332
- *Built for developers who believe state management can be simple.*
380
+ ## License
333
381
 
334
- ## Name
382
+ MIT
335
383
 
336
- flarp (from Jargon File)
384
+ ---
337
385
 
338
- /flarp/ [Rutgers University] Yet another metasyntactic variable (see foo). Among those who use it, it is associated with a legend that any program not containing the word "flarp" somewhere will not work. The legend is discreetly silent on the reliability of programs which *do* contain the magic word.
386
+ *Built for developers who believe state management can be simple.*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flarp",
3
- "version": "1.0.1",
3
+ "version": "2.0.1",
4
4
  "description": "DOM-native XML state management for Web Components",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/xml/Node.js CHANGED
@@ -3,22 +3,30 @@
3
3
  *
4
4
  * Each Node is:
5
5
  * - Independently reactive (has its own Signal)
6
- * - Uniquely identifiable (UUID)
7
- * - Version-tracked (rev for conflict resolution)
6
+ * - Uniquely identifiable (UUID - stable identity)
7
+ * - Version-tracked with conflict detection (rev = number-uuid)
8
8
  * - Serializable on its own
9
9
  *
10
- * @example
11
- * const node = Node.wrap(element);
10
+ * ## Revision Format (CouchDB-style)
11
+ *
12
+ * The `rev` attribute follows the format: `{revisionNumber}-{revisionUUID}`
12
13
  *
13
- * node.text; // read text content
14
- * node.text = 'new value'; // write (increments rev)
14
+ * - revisionNumber: Incrementing integer (1, 2, 3...)
15
+ * - revisionUUID: Fresh UUID generated on each write
15
16
  *
16
- * node.subscribe(text => console.log(text));
17
+ * This enables:
18
+ * 1. Conflict detection: Same revisionNumber + different revisionUUID = conflict
19
+ * 2. Consistent tie-breaking: Sort revisionUUIDs alphabetically, first wins
20
+ * 3. Eventual consistency: All nodes independently agree on the same winner
21
+ *
22
+ * @example
23
+ * const node = Node.wrap(element);
17
24
  *
18
- * node.uuid; // unique identifier
19
- * node.rev; // revision number
25
+ * node.text = 'new value'; // Generates new rev like "3-a1b2c3..."
20
26
  *
21
- * node.serialize(); // just this node as XML string
27
+ * node.revNumber; // 3
28
+ * node.revId; // "a1b2c3..."
29
+ * node.rev; // "3-a1b2c3..."
22
30
  */
23
31
 
24
32
  import Signal from '../core/Signal.js';
@@ -39,6 +47,35 @@ function uuid() {
39
47
  });
40
48
  }
41
49
 
50
+ /**
51
+ * Parse rev string into components
52
+ * @param {string} rev - Format: "number-uuid" or just "number"
53
+ * @returns {{ number: number, id: string }}
54
+ */
55
+ function parseRev(rev) {
56
+ if (!rev) return { number: 0, id: '' };
57
+
58
+ const dashIndex = rev.indexOf('-');
59
+ if (dashIndex === -1) {
60
+ // Legacy format: just a number
61
+ return { number: parseInt(rev, 10) || 0, id: '' };
62
+ }
63
+
64
+ return {
65
+ number: parseInt(rev.slice(0, dashIndex), 10) || 0,
66
+ id: rev.slice(dashIndex + 1)
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Create new rev string
72
+ * @param {number} number - Revision number
73
+ * @returns {string} Format: "number-uuid"
74
+ */
75
+ function createRev(number) {
76
+ return `${number}-${uuid()}`;
77
+ }
78
+
42
79
  export default class Node {
43
80
  #element;
44
81
  #signal;
@@ -84,14 +121,14 @@ export default class Node {
84
121
  this.#element = element;
85
122
  this.#signal = new Signal(element.textContent);
86
123
 
87
- // Ensure UUID
124
+ // Ensure UUID (stable node identity)
88
125
  if (!element.hasAttribute('uuid')) {
89
126
  element.setAttribute('uuid', uuid());
90
127
  }
91
128
 
92
- // Ensure rev
129
+ // Ensure rev with proper format
93
130
  if (!element.hasAttribute('rev')) {
94
- element.setAttribute('rev', '0');
131
+ element.setAttribute('rev', createRev(1));
95
132
  }
96
133
 
97
134
  // Observe mutations to sync signal
@@ -116,7 +153,7 @@ export default class Node {
116
153
  }
117
154
 
118
155
  /**
119
- * Set text content (increments rev)
156
+ * Set text content (increments rev with new UUID)
120
157
  */
121
158
  set text(value) {
122
159
  this.#element.textContent = value;
@@ -149,17 +186,31 @@ export default class Node {
149
186
  }
150
187
 
151
188
  /**
152
- * Get UUID
189
+ * Get node identity UUID (stable)
153
190
  */
154
191
  get uuid() {
155
192
  return this.#element.getAttribute('uuid');
156
193
  }
157
194
 
158
195
  /**
159
- * Get revision number
196
+ * Get full revision string (e.g., "3-a1b2c3...")
160
197
  */
161
198
  get rev() {
162
- return parseInt(this.#element.getAttribute('rev') || '0', 10);
199
+ return this.#element.getAttribute('rev') || '0-';
200
+ }
201
+
202
+ /**
203
+ * Get revision number only
204
+ */
205
+ get revNumber() {
206
+ return parseRev(this.rev).number;
207
+ }
208
+
209
+ /**
210
+ * Get revision UUID only (the tie-breaker)
211
+ */
212
+ get revId() {
213
+ return parseRev(this.rev).id;
163
214
  }
164
215
 
165
216
  /**
@@ -259,42 +310,91 @@ export default class Node {
259
310
  }
260
311
 
261
312
  /**
262
- * Compare with another node for conflict resolution
263
- * Higher rev wins, UUID tiebreaker (alphabetically first wins)
264
- * @param {Node} other
265
- * @returns {Node} Winner
313
+ * Compare revisions for conflict resolution
314
+ *
315
+ * Rules:
316
+ * 1. Higher revision number wins
317
+ * 2. Same revision number = CONFLICT
318
+ * - Tie-breaker: alphabetically first revId wins
319
+ * - This ensures all nodes independently agree on the same winner
320
+ *
321
+ * @param {Node} a
322
+ * @param {Node} b
323
+ * @returns {{ winner: Node, conflict: boolean }}
266
324
  */
267
- static resolve(a, b) {
268
- if (a.rev !== b.rev) {
269
- return a.rev > b.rev ? a : b;
325
+ static compare(a, b) {
326
+ const revA = parseRev(a.rev);
327
+ const revB = parseRev(b.rev);
328
+
329
+ // Different revision numbers - higher wins, no conflict
330
+ if (revA.number !== revB.number) {
331
+ return {
332
+ winner: revA.number > revB.number ? a : b,
333
+ conflict: false
334
+ };
270
335
  }
271
- return a.uuid <= b.uuid ? a : b;
336
+
337
+ // Same revision number - this is a conflict!
338
+ // Tie-breaker: sort revIds, first wins (consistent across all nodes)
339
+ return {
340
+ winner: revA.id <= revB.id ? a : b,
341
+ conflict: revA.id !== revB.id // Same revId means same write, not a conflict
342
+ };
343
+ }
344
+
345
+ /**
346
+ * Check if this node conflicts with remote data
347
+ * @param {string} remoteRev - Remote rev string
348
+ * @returns {boolean}
349
+ */
350
+ conflictsWith(remoteRev) {
351
+ const local = parseRev(this.rev);
352
+ const remote = parseRev(remoteRev);
353
+
354
+ return local.number === remote.number && local.id !== remote.id;
272
355
  }
273
356
 
274
357
  /**
275
358
  * Merge remote changes into this node
276
- * @param {string} remoteXml
277
- * @returns {boolean} True if remote won
359
+ *
360
+ * @param {string} remoteXml - Remote node as XML string
361
+ * @returns {{ applied: boolean, conflict: boolean, winner: 'local'|'remote' }}
278
362
  */
279
363
  merge(remoteXml) {
280
364
  const parser = new DOMParser();
281
365
  const doc = parser.parseFromString(remoteXml, 'application/xml');
282
366
  const remoteEl = doc.documentElement;
283
367
 
284
- const remoteRev = parseInt(remoteEl.getAttribute('rev') || '0', 10);
285
- const remoteUuid = remoteEl.getAttribute('uuid');
368
+ const localRev = parseRev(this.rev);
369
+ const remoteRev = parseRev(remoteEl.getAttribute('rev'));
286
370
 
287
- // Compare versions
288
- if (remoteRev > this.rev ||
289
- (remoteRev === this.rev && remoteUuid < this.uuid)) {
290
- // Remote wins - apply changes
291
- this.#element.innerHTML = remoteEl.innerHTML;
292
- this.#element.setAttribute('rev', String(remoteRev));
293
- this.#signal.value = this.#element.textContent;
294
- return true;
371
+ // Remote has higher revision - apply it
372
+ if (remoteRev.number > localRev.number) {
373
+ this.#applyRemote(remoteEl, remoteRev);
374
+ return { applied: true, conflict: false, winner: 'remote' };
295
375
  }
296
376
 
297
- return false;
377
+ // Local has higher revision - keep local
378
+ if (localRev.number > remoteRev.number) {
379
+ return { applied: false, conflict: false, winner: 'local' };
380
+ }
381
+
382
+ // Same revision number - conflict!
383
+ const conflict = localRev.id !== remoteRev.id;
384
+
385
+ // Tie-breaker: alphabetically first revId wins
386
+ if (remoteRev.id < localRev.id) {
387
+ this.#applyRemote(remoteEl, remoteRev);
388
+ return { applied: true, conflict, winner: 'remote' };
389
+ }
390
+
391
+ return { applied: false, conflict, winner: 'local' };
392
+ }
393
+
394
+ #applyRemote(remoteEl, remoteRev) {
395
+ this.#element.innerHTML = remoteEl.innerHTML;
396
+ this.#element.setAttribute('rev', `${remoteRev.number}-${remoteRev.id}`);
397
+ this.#signal.value = this.#element.textContent;
298
398
  }
299
399
 
300
400
  /**
@@ -310,8 +410,8 @@ export default class Node {
310
410
  }
311
411
 
312
412
  #incrementRev() {
313
- const rev = this.rev + 1;
314
- this.#element.setAttribute('rev', String(rev));
413
+ const currentNumber = this.revNumber;
414
+ this.#element.setAttribute('rev', createRev(currentNumber + 1));
315
415
  }
316
416
  }
317
417
 
package/src/xml/Tree.js CHANGED
@@ -19,6 +19,26 @@ import State from '../core/State.js';
19
19
  import Node, { AttrNode } from './Node.js';
20
20
  import * as Path from './Path.js';
21
21
 
22
+ /**
23
+ * Generate UUID with fallback
24
+ */
25
+ function uuid() {
26
+ if (crypto.randomUUID) {
27
+ return crypto.randomUUID();
28
+ }
29
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
30
+ const r = Math.random() * 16 | 0;
31
+ return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
32
+ });
33
+ }
34
+
35
+ /**
36
+ * Create CouchDB-style rev string
37
+ */
38
+ function createRev(number = 1) {
39
+ return `${number}-${uuid()}`;
40
+ }
41
+
22
42
  export default class Tree {
23
43
  #root;
24
44
  #parser = new DOMParser();
@@ -113,12 +133,12 @@ export default class Tree {
113
133
  for (const child of Array.from(doc.documentElement.children)) {
114
134
  const imported = document.importNode(child, true);
115
135
 
116
- // Ensure UUID/rev
136
+ // Ensure UUID/rev with proper format
117
137
  if (!imported.hasAttribute('uuid')) {
118
- imported.setAttribute('uuid', crypto.randomUUID?.() || Math.random().toString(36).slice(2));
138
+ imported.setAttribute('uuid', uuid());
119
139
  }
120
140
  if (!imported.hasAttribute('rev')) {
121
- imported.setAttribute('rev', '1');
141
+ imported.setAttribute('rev', createRev(1));
122
142
  }
123
143
 
124
144
  resolved.element.appendChild(imported);
@@ -239,8 +259,8 @@ export default class Tree {
239
259
 
240
260
  if (matches.length === 0) {
241
261
  const newEl = document.createElement(tag);
242
- newEl.setAttribute('uuid', crypto.randomUUID?.() || Math.random().toString(36).slice(2));
243
- newEl.setAttribute('rev', '1');
262
+ newEl.setAttribute('uuid', uuid());
263
+ newEl.setAttribute('rev', createRev(1));
244
264
  current.appendChild(newEl);
245
265
  current = newEl;
246
266
  } else if (index !== null && !matches[index]) {