@panphora/clayjs 1.7.0 → 1.8.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/clay.standalone.js +6278 -5396
- package/package.json +2 -1
- package/src/core/etag.js +67 -3
- package/src/core/host-attrs.js +14 -0
- package/src/core/save.js +13 -5
- package/src/lib/root-attrs.js +14 -0
- package/src/plugins/ai-edit.js +307 -89
- package/src/sync/conflict-footprints.js +272 -35
- package/src/sync/conflict-revert.js +18 -8
- package/src/sync/conflicts.js +2 -0
- package/src/sync/live-sync.js +328 -37
- package/src/sync/stream.js +17 -4
- package/src/vendor/hyper-morph.vendor.js +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panphora/clayjs",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "clayjs: malleable HTML files. Save lifecycle for self-saving HTML.",
|
|
6
6
|
"license": "MIT-0",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@esm-bundle/chai": "^4.3.4-fix.0",
|
|
69
|
+
"@panphora/bevel": "0.1.0",
|
|
69
70
|
"@web/test-runner": "^0.20.2",
|
|
70
71
|
"@web/test-runner-commands": "^0.9.0",
|
|
71
72
|
"@web/test-runner-playwright": "^0.11.1",
|
package/src/core/etag.js
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Three things move the stamp, and nothing else does:
|
|
12
12
|
*
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
13
|
+
* - the response that delivered this page seeds it, when the host stamped that
|
|
14
|
+
* response: the stamp names the bytes the navigation was built from, which is
|
|
15
|
+
* the only evidence this tab has about the version it is looking at,
|
|
15
16
|
* - an accepted save replaces it with the one that response carried, and clears
|
|
16
17
|
* it when a response carries none: after our own write, any stamp still held
|
|
17
18
|
* here is known to describe bytes the host has stopped storing,
|
|
@@ -19,6 +20,23 @@
|
|
|
19
20
|
* carried, because the file changed under a tab that never saved. A frame
|
|
20
21
|
* with no stamp on it falls back to clearing and asking the host.
|
|
21
22
|
*
|
|
23
|
+
* Discovery seeds it only for a page that arrived without a stamp of its own. That
|
|
24
|
+
* is not a detail: both hosts read the file AGAIN when they answer discovery, so
|
|
25
|
+
* their answer describes a later moment than the navigation. This tab may have been
|
|
26
|
+
* served A, the file may have become B, and discovery answers B — bytes this tab has
|
|
27
|
+
* never seen. Adopting that would claim the newer version while holding the older
|
|
28
|
+
* bytes, and this tab's next save would pass If-Match and overwrite the update it
|
|
29
|
+
* never received. It would also erase the only evidence that the update is missing.
|
|
30
|
+
* The exception is an explicit overwrite, `seedEtag({ fresh: true })`, which a person
|
|
31
|
+
* asks for by name; that may take whatever the host says now.
|
|
32
|
+
*
|
|
33
|
+
* `represented` rides beside the stamp and answers a different question: which
|
|
34
|
+
* version of the file is the DOM in front of the person? A save compares against
|
|
35
|
+
* `lastSeen`, but discovery moves that without this tab having received anything, so
|
|
36
|
+
* the two only agree when the last change came with content. Startup repair compares
|
|
37
|
+
* the file against `represented` to decide whether this page is still the page the
|
|
38
|
+
* navigation delivered.
|
|
39
|
+
*
|
|
22
40
|
* A peer's SNAPSHOT does not move it. §10 relays never write to disk, so the
|
|
23
41
|
* stamp is still true after one lands, and treating one as a disk change would
|
|
24
42
|
* refetch discovery on every keystroke another editor makes.
|
|
@@ -30,8 +48,13 @@
|
|
|
30
48
|
|
|
31
49
|
import { hostMeta } from "./host-meta.js";
|
|
32
50
|
import { isEditMode } from "./is-edit-mode.js";
|
|
51
|
+
import { servedDocumentEtag } from "./host-attrs.js";
|
|
33
52
|
|
|
34
|
-
|
|
53
|
+
// Both start at the version this response was built from, when the host said which one
|
|
54
|
+
// that is, so a page whose file changes before its first discovery answer still knows
|
|
55
|
+
// what its own DOM came from.
|
|
56
|
+
let lastSeen = servedDocumentEtag;
|
|
57
|
+
let represented = servedDocumentEtag;
|
|
35
58
|
let conditional = false;
|
|
36
59
|
// Bumped by every write. A discovery answer that resolves after a save has
|
|
37
60
|
// already recorded a newer stamp must not overwrite it.
|
|
@@ -54,6 +77,25 @@ export function conditionalSaves() {
|
|
|
54
77
|
export function recordEtag(value) {
|
|
55
78
|
lastSeen = typeof value === "string" && value !== "" ? value : null;
|
|
56
79
|
generation++;
|
|
80
|
+
// Whatever this value came from — a save response, a disk frame, a deliberate
|
|
81
|
+
// forget — it describes content this tab has taken, so it is also what the tab
|
|
82
|
+
// represents.
|
|
83
|
+
represented = lastSeen;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The version of the file this tab's content came from, or null. */
|
|
87
|
+
export function representedEtag() {
|
|
88
|
+
return represented;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Drop the represented stamp without touching the save stamp.
|
|
93
|
+
*
|
|
94
|
+
* For content that arrived with no version on it: the DOM now holds bytes the host
|
|
95
|
+
* never named, so this tab can no longer say which version it is looking at.
|
|
96
|
+
*/
|
|
97
|
+
export function forgetRepresentedEtag() {
|
|
98
|
+
represented = null;
|
|
57
99
|
}
|
|
58
100
|
|
|
59
101
|
/** Forget the stamp: the next save goes out unconditional. */
|
|
@@ -89,6 +131,13 @@ export async function seedEtag({ fresh = false, clearIfMissing = fresh } = {}) {
|
|
|
89
131
|
|
|
90
132
|
if (generation !== at) return lastSeen;
|
|
91
133
|
|
|
134
|
+
// A stamped navigation is provenance, and discovery is not evidence about it. This
|
|
135
|
+
// answer is about a later moment than the response that built this page, so it may
|
|
136
|
+
// neither advance nor clear the stamp while this tab still represents what it was
|
|
137
|
+
// served. Only an explicit overwrite — fresh AND clearing allowed — moves it, and
|
|
138
|
+
// that is also the only case where an empty answer is taken as the truth.
|
|
139
|
+
if (servedDocumentEtag && !(fresh && clearIfMissing)) return lastSeen;
|
|
140
|
+
|
|
92
141
|
const seed = meta.document?.etag;
|
|
93
142
|
if (typeof seed === "string" && seed !== "") {
|
|
94
143
|
lastSeen = seed;
|
|
@@ -120,6 +169,21 @@ export async function seedEtag({ fresh = false, clearIfMissing = fresh } = {}) {
|
|
|
120
169
|
// A frame that live-sync HELD is deliberately not covered, because it never
|
|
121
170
|
// dispatches this event. That tab has unsaved local edits and has not seen the
|
|
122
171
|
// new disk bytes, which is precisely the case a 412 exists for.
|
|
172
|
+
|
|
173
|
+
// The represented stamp goes first, and in both lanes. An unstamped disk apply leaves
|
|
174
|
+
// this tab holding a DOM whose version nobody stated, so neither lane may keep claiming
|
|
175
|
+
// the old one — a view-mode tab included, since that is the lane startup repair runs
|
|
176
|
+
// in. A stamped frame needs nothing here: live-sync has already recorded it as part of
|
|
177
|
+
// applying the content.
|
|
178
|
+
document.addEventListener("clay:sync-applied", (event) => {
|
|
179
|
+
if (event.detail?.source !== "disk") return;
|
|
180
|
+
if (typeof event.detail.etag === "string" && event.detail.etag) return;
|
|
181
|
+
forgetRepresentedEtag();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Edit mode goes further: the stamp it SAVES with has to move too, and the host is the
|
|
185
|
+
// only source for the value, so it forgets the one it holds and asks again with the
|
|
186
|
+
// overwrite flags. A view-mode tab never saves, so its save stamp can stay where it is.
|
|
123
187
|
if (isEditMode) {
|
|
124
188
|
document.addEventListener("clay:sync-applied", (event) => {
|
|
125
189
|
if (event.detail?.source !== "disk") return;
|
package/src/core/host-attrs.js
CHANGED
|
@@ -9,6 +9,20 @@
|
|
|
9
9
|
|
|
10
10
|
import { SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS } from "../lib/root-attrs.js";
|
|
11
11
|
|
|
12
|
+
// The version of the bytes this response was built from, captured ONCE, here.
|
|
13
|
+
//
|
|
14
|
+
// §5's discovery answers about whatever is on disk when the ANSWER is built, which is
|
|
15
|
+
// a later moment than the navigation that delivered this page: the file can change in
|
|
16
|
+
// between, and its answer then names a revision this tab has never seen. The root
|
|
17
|
+
// attribute is the only place that says which one this tab is actually looking at, so
|
|
18
|
+
// it is read at module evaluation and never again. A later re-read would pick up
|
|
19
|
+
// whatever a morph or a stream restart left on the root, which is exactly the claim
|
|
20
|
+
// this value exists to avoid.
|
|
21
|
+
export const servedDocumentEtag =
|
|
22
|
+
typeof document === "undefined"
|
|
23
|
+
? null
|
|
24
|
+
: document.documentElement.getAttribute("documentetag") || null;
|
|
25
|
+
|
|
12
26
|
let warnedAboutLegacyToken = false;
|
|
13
27
|
|
|
14
28
|
/**
|
package/src/core/save.js
CHANGED
|
@@ -26,7 +26,7 @@ import { seedEtag, lastSeenEtag } from "./etag.js";
|
|
|
26
26
|
import { gateCaptureToken, gateClearIfUnchanged, pageMaybeDirty } from "../lib/dirty-gate.js";
|
|
27
27
|
import { hasUnsavedState } from "../lib/unsaved-state.js";
|
|
28
28
|
import { autosaveActive } from "../lib/autosave-state.js";
|
|
29
|
-
import { ROOT_LIBRARY_ATTRS, SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS } from "../lib/root-attrs.js";
|
|
29
|
+
import { ROOT_LIBRARY_ATTRS, SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS, HOST_RESPONSE_ATTRS } from "../lib/root-attrs.js";
|
|
30
30
|
import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
|
|
31
31
|
import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
|
|
32
32
|
// A deliberate import cycle: unsaved-warning reads this module's saved baseline, and
|
|
@@ -51,16 +51,24 @@ addDocumentTransform(clone => {
|
|
|
51
51
|
for (const name of ROOT_LIBRARY_ATTRS) clone.removeAttribute(name);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
// Keep the host's save token out of the saved bytes, both spellings
|
|
54
|
+
// Keep the host's save token out of the saved bytes, both spellings, and the response
|
|
55
|
+
// metadata with it.
|
|
55
56
|
//
|
|
56
|
-
//
|
|
57
|
-
// every save body on arrival, so it never reached disk anyway. Sending it made the
|
|
57
|
+
// A save token is a credential for this response, never file content: htmlclay strips it
|
|
58
|
+
// from every save body on arrival, so it never reached disk anyway. Sending it made the
|
|
58
59
|
// source map, which models the bytes a save sent, describe a root tag one attribute
|
|
59
60
|
// longer than the file, and every offset after it was off by that much. The save
|
|
60
61
|
// itself is authorized by the URL, which reads the token from the live page. The
|
|
61
62
|
// document id is NOT stripped: htmlclay keeps it on disk on purpose.
|
|
63
|
+
//
|
|
64
|
+
// `documentetag` is the same kind of thing for a different reason. It names the version
|
|
65
|
+
// of the response this tab loaded, and it is replaced on every serve, so writing it to
|
|
66
|
+
// disk would freeze one response's stamp into the file and give the next reader a
|
|
67
|
+
// provenance claim about bytes nobody built a response from. Root only, like the rest of
|
|
68
|
+
// this transform: the same name on a child is the author's, and stripping those would
|
|
69
|
+
// delete page content.
|
|
62
70
|
addDocumentTransform(clone => {
|
|
63
|
-
for (const name of [...SAVE_TOKEN_ATTRS, ...LEGACY_SAVE_TOKEN_ATTRS]) clone.removeAttribute(name);
|
|
71
|
+
for (const name of [...SAVE_TOKEN_ATTRS, ...LEGACY_SAVE_TOKEN_ATTRS, ...HOST_RESPONSE_ATTRS]) clone.removeAttribute(name);
|
|
64
72
|
});
|
|
65
73
|
|
|
66
74
|
// ============================================
|
package/src/lib/root-attrs.js
CHANGED
|
@@ -63,6 +63,19 @@ export const LEGACY_SAVE_TOKEN_ATTRS = ["htmlclaytoken"];
|
|
|
63
63
|
// what fixed that, and the split holds whatever token spellings are read above.
|
|
64
64
|
export const HOST_IDENTITY_ATTRS = ["documentid", "htmlclayid"];
|
|
65
65
|
|
|
66
|
+
// Response metadata, and neither of the other two. `documentetag` names the version
|
|
67
|
+
// of the bytes the host built THIS response from, which is the one thing neither the
|
|
68
|
+
// token nor the identity can say: a token grants a capability and an identity names a
|
|
69
|
+
// file, while the stamp names a revision. It rides on the response only, so an
|
|
70
|
+
// incoming morph must never apply a peer's copy and an outgoing sync must never carry
|
|
71
|
+
// this tab's. Read once, at module evaluation, by host-attrs.js — never re-read from
|
|
72
|
+
// the live root, which a morph may since have rewritten.
|
|
73
|
+
//
|
|
74
|
+
// Kept out of SAVE_TOKEN_ATTRS on purpose: host-attrs.js returns the first name it
|
|
75
|
+
// finds in that list straight into the save URL, and a version stamp is not a
|
|
76
|
+
// credential.
|
|
77
|
+
export const HOST_RESPONSE_ATTRS = ["documentetag"];
|
|
78
|
+
|
|
66
79
|
// What a host may have injected, and therefore what has to be stripped before a save
|
|
67
80
|
// and kept out of an incoming morph. Wider than what is READ, on purpose: the old token
|
|
68
81
|
// spelling is still injected by every htmlclay, so it still has to be stripped, whether
|
|
@@ -87,6 +100,7 @@ export const ROOT_LIBRARY_ATTRS = ["savestatus", "editmode", "pageowner"];
|
|
|
87
100
|
// can no longer save at all.
|
|
88
101
|
export const TAB_LOCAL_ROOT_ATTRS = new Set([
|
|
89
102
|
...HOST_TOKEN_ATTRS,
|
|
103
|
+
...HOST_RESPONSE_ATTRS,
|
|
90
104
|
...ROOT_LIBRARY_ATTRS,
|
|
91
105
|
]);
|
|
92
106
|
|