flarp 1.0.0 → 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 +64 -10
- package/package.json +2 -4
- package/src/xml/Node.js +140 -40
- package/src/xml/Tree.js +25 -5
package/README.md
CHANGED
|
@@ -77,7 +77,7 @@ Or use directly:
|
|
|
77
77
|
### `<f-state>` — The Store
|
|
78
78
|
|
|
79
79
|
```html
|
|
80
|
-
<f-state
|
|
80
|
+
<f-state
|
|
81
81
|
key="myapp" <!-- Persistence key -->
|
|
82
82
|
autosave="500" <!-- Debounce ms -->
|
|
83
83
|
src="/api/data" <!-- Load from URL -->
|
|
@@ -165,7 +165,7 @@ Components don't need to be nested inside `<f-state>`. Flarp searches siblings a
|
|
|
165
165
|
<f-state id="app">
|
|
166
166
|
<User><n>Alice</n></User>
|
|
167
167
|
</f-state>
|
|
168
|
-
|
|
168
|
+
|
|
169
169
|
<main>
|
|
170
170
|
<!-- Finds sibling f-state automatically -->
|
|
171
171
|
<f-text path="User.Name"></f-text>
|
|
@@ -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** —
|
|
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('
|
|
207
|
+
const node = store.at('user.name');
|
|
208
|
+
|
|
209
|
+
node.uuid; // "a1b2c3..." (stable identity)
|
|
210
|
+
node.rev; // "5-f94bc318..." (revision)
|
|
211
|
+
node.revNumber; // 5
|
|
212
|
+
node.revId; // "f94bc318..." (write identifier)
|
|
208
213
|
|
|
209
|
-
node.
|
|
210
|
-
node.rev; // 5
|
|
211
|
-
node.serialize(); // "<n uuid="..." rev="5">Alice</n>"
|
|
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:
|
|
@@ -256,10 +302,10 @@ import { findStore } from 'flarp';
|
|
|
256
302
|
class UserCard extends HTMLElement {
|
|
257
303
|
connectedCallback() {
|
|
258
304
|
const store = findStore(this);
|
|
259
|
-
|
|
305
|
+
|
|
260
306
|
store.state.when('ready', () => {
|
|
261
307
|
const name = store.at('User.Name');
|
|
262
|
-
|
|
308
|
+
|
|
263
309
|
name.subscribe(v => {
|
|
264
310
|
this.innerHTML = `<h2>${v}</h2>`;
|
|
265
311
|
});
|
|
@@ -323,6 +369,14 @@ Modern browsers with ES modules:
|
|
|
323
369
|
|
|
324
370
|
---
|
|
325
371
|
|
|
372
|
+
## Name
|
|
373
|
+
|
|
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.
|
|
377
|
+
|
|
378
|
+
---
|
|
379
|
+
|
|
326
380
|
## License
|
|
327
381
|
|
|
328
382
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flarp",
|
|
3
|
-
"version": "
|
|
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",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"src"
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
|
+
"save": "git add .; git commit -m 'Updated Release'; npm version patch; npm publish; git push --follow-tags;",
|
|
20
21
|
"dev": "http-server -c-1 -o",
|
|
21
22
|
"start": "http-server -c-1 -o"
|
|
22
23
|
},
|
|
@@ -28,8 +29,6 @@
|
|
|
28
29
|
"web-components",
|
|
29
30
|
"dom"
|
|
30
31
|
],
|
|
31
|
-
|
|
32
|
-
|
|
33
32
|
"author": "catpea (https://github.com/catpea)",
|
|
34
33
|
"license": "MIT",
|
|
35
34
|
"repository": {
|
|
@@ -44,5 +43,4 @@
|
|
|
44
43
|
"type": "github",
|
|
45
44
|
"url": "https://github.com/sponsors/catpea"
|
|
46
45
|
}
|
|
47
|
-
|
|
48
46
|
}
|
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
|
|
6
|
+
* - Uniquely identifiable (UUID - stable identity)
|
|
7
|
+
* - Version-tracked with conflict detection (rev = number-uuid)
|
|
8
8
|
* - Serializable on its own
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* ## Revision Format (CouchDB-style)
|
|
11
|
+
*
|
|
12
|
+
* The `rev` attribute follows the format: `{revisionNumber}-{revisionUUID}`
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
14
|
+
* - revisionNumber: Incrementing integer (1, 2, 3...)
|
|
15
|
+
* - revisionUUID: Fresh UUID generated on each write
|
|
15
16
|
*
|
|
16
|
-
*
|
|
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.
|
|
19
|
-
* node.rev; // revision number
|
|
25
|
+
* node.text = 'new value'; // Generates new rev like "3-a1b2c3..."
|
|
20
26
|
*
|
|
21
|
-
* node.
|
|
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',
|
|
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
|
|
196
|
+
* Get full revision string (e.g., "3-a1b2c3...")
|
|
160
197
|
*/
|
|
161
198
|
get rev() {
|
|
162
|
-
return
|
|
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
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
277
|
-
* @
|
|
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
|
|
285
|
-
const
|
|
368
|
+
const localRev = parseRev(this.rev);
|
|
369
|
+
const remoteRev = parseRev(remoteEl.getAttribute('rev'));
|
|
286
370
|
|
|
287
|
-
//
|
|
288
|
-
if (remoteRev >
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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
|
|
314
|
-
this.#element.setAttribute('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',
|
|
138
|
+
imported.setAttribute('uuid', uuid());
|
|
119
139
|
}
|
|
120
140
|
if (!imported.hasAttribute('rev')) {
|
|
121
|
-
imported.setAttribute('rev',
|
|
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',
|
|
243
|
-
newEl.setAttribute('rev',
|
|
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]) {
|