@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/clayjs",
3
- "version": "1.7.0",
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
- * - discovery seeds it, because a freshly loaded page has never saved and so
14
- * holds no stamp of its own,
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
- let lastSeen = null;
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;
@@ -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
- // It is a credential for this response, never file content: htmlclay strips it from
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
  // ============================================
@@ -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