@company-semantics/contracts 51.0.0 → 51.2.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 +3 -3
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +67 -4
- package/src/comments/README.md +175 -32
- package/src/comments/__tests__/README.md +60 -1
- package/src/comments/__tests__/anchor-constants.test.ts +161 -0
- package/src/comments/__tests__/create.test.ts +300 -0
- package/src/comments/__tests__/receipt.test.ts +143 -0
- package/src/comments/__tests__/resolve.test.ts +391 -0
- package/src/comments/__tests__/schemas.test.ts +29 -1
- package/src/comments/__tests__/verify.test.ts +262 -0
- package/src/comments/anchor-constants.ts +61 -0
- package/src/comments/anchor.ts +20 -11
- package/src/comments/create.ts +128 -0
- package/src/comments/index.ts +36 -0
- package/src/comments/receipt.ts +146 -0
- package/src/comments/resolve.ts +261 -0
- package/src/comments/schemas.ts +27 -8
- package/src/comments/verify.ts +128 -0
- package/src/execution/__tests__/registry.test.ts +89 -0
- package/src/execution/kinds.ts +21 -1
- package/src/execution/registry.ts +73 -0
- package/src/generated/openapi-routes.ts +1 -0
- package/src/index.ts +73 -0
- package/src/message-parts/__tests__/confirmation.test.ts +3 -0
- package/src/message-parts/confirmation.ts +3 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The application receipt — the durable proof that an accepted suggestion's
|
|
3
|
+
* edit ALREADY LANDED IN THE BODY (ADR-CONTRACTS-116, ADR-CONTRACTS-122,
|
|
4
|
+
* ADR-CONTRACTS-127).
|
|
5
|
+
*
|
|
6
|
+
* WHY THERE IS A RECEIPT AT ALL. Acceptance is apply-then-record: the edit is
|
|
7
|
+
* written into the CRDT first and the server is told afterwards, which is what
|
|
8
|
+
* makes `accepted` mean `applied` — and which opens exactly one window, between
|
|
9
|
+
* the transaction and the acknowledgement, in which the document has already
|
|
10
|
+
* changed and the thread still says `open`. A closed tab, a POST that never came
|
|
11
|
+
* back and a lost network all land in that window. The receipt closes it: it
|
|
12
|
+
* travels in the SAME transaction as the edit, so either both survived or
|
|
13
|
+
* neither did, and every client that syncs the body syncs the proof beside it.
|
|
14
|
+
* `./verify`'s no-op refusal is the other half of this same argument — a
|
|
15
|
+
* "mutation" that changes not one character would still write a receipt, and the
|
|
16
|
+
* thread would read `accepted` over a document that never received the proposal.
|
|
17
|
+
*
|
|
18
|
+
* THE KEY IS THE CONTRACT, exactly as `COMPANY_MD_COLLAB_TEXT_KEY` is for the
|
|
19
|
+
* body text (`../org/company-md-collab`). A receipt written under any other name
|
|
20
|
+
* lands in a map nobody reads while the reader observes a map nobody writes: the
|
|
21
|
+
* accept control stays live over an edit that already happened, a second apply
|
|
22
|
+
* is offered for an edit already in the body, and NOTHING ANYWHERE THROWS. One
|
|
23
|
+
* implementation can get away with spelling the name inline. Two cannot — that
|
|
24
|
+
* is the same two-independent-implementations-with-no-arbiter hazard the anchor
|
|
25
|
+
* schema is published for (ADR-CONTRACTS-116), and the receipt now has a second
|
|
26
|
+
* writer coming, so the name is spelled once, here.
|
|
27
|
+
*
|
|
28
|
+
* IT LIVES BESIDE THE BODY, NEVER INSIDE IT. This is a ROOT map on the same
|
|
29
|
+
* document as the text, not a span within the text: a receipt must never appear
|
|
30
|
+
* in the markdown source, in an export, or in the content hash. A materializer
|
|
31
|
+
* that reads the text root and nothing else is what keeps that true, and nothing
|
|
32
|
+
* here may tempt a writer into the body.
|
|
33
|
+
*
|
|
34
|
+
* ABSENCE IS NOT PROOF. A reader holding no replica holds no receipts, so an
|
|
35
|
+
* empty map means "no receipt is KNOWN HERE" and never "no receipt exists".
|
|
36
|
+
* Every consumer must therefore act on PRESENCE only; a consumer that concluded
|
|
37
|
+
* anything from silence would turn a reader that merely has no session into one
|
|
38
|
+
* that does the wrong thing rather than nothing.
|
|
39
|
+
*
|
|
40
|
+
* NO YJS HERE, DELIBERATELY — the same seam `./resolve` and `./create` hold.
|
|
41
|
+
* This module names the key and states one row's shape. Getting the map,
|
|
42
|
+
* iterating it and observing it are the consumer's, because `yjs` is an import
|
|
43
|
+
* the vocabulary-guard forbids this package from making.
|
|
44
|
+
*
|
|
45
|
+
* A LEAF, like `./suggestion`: this module imports nothing but `zod`. It has no
|
|
46
|
+
* reason to reach for the anchor or the resolution ladder, and staying a leaf
|
|
47
|
+
* means anything in this directory can import it later without a cycle.
|
|
48
|
+
*
|
|
49
|
+
* SPELLING DEVIATION FROM THE AUTHORITATIVE REFERENCE (PRD-00941, must_log).
|
|
50
|
+
* Nothing semantic, on three counts. (1) The reference writes
|
|
51
|
+
* `z.object({...}).strict()`; what ships is `z.strictObject`, zod v4's idiom for
|
|
52
|
+
* exactly that and this directory's existing spelling (see `./anchor`'s
|
|
53
|
+
* PRD-00922 note). (2) The reference single-quotes its strings; `.prettierrc`
|
|
54
|
+
* sets `singleQuote: false`, so `pnpm format:check` rewrites them. (3) The
|
|
55
|
+
* reference writes `readSuggestionReceipt`'s signature on one line, which is 81
|
|
56
|
+
* columns against `printWidth: 80`, so Prettier reflows it onto three. A
|
|
57
|
+
* different spelling of the same schema and the same function; nothing about
|
|
58
|
+
* what is accepted or refused changed.
|
|
59
|
+
*/
|
|
60
|
+
import { z } from "zod";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The root map key every application receipt is written under, keyed within
|
|
64
|
+
* that map by thread id.
|
|
65
|
+
*
|
|
66
|
+
* THIS IS THE NAME OF THE MAP, NOT A MAP. It is a single string, and the reason
|
|
67
|
+
* it is a published constant rather than an inline literal is that a typo is
|
|
68
|
+
* silent on BOTH ends — the writer's receipt lands somewhere nobody reads, and
|
|
69
|
+
* the reader watches somewhere nobody writes.
|
|
70
|
+
*/
|
|
71
|
+
export const SUGGESTION_RECEIPTS_MAP = "suggestionReceipts";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* What one applied suggestion left behind.
|
|
75
|
+
*
|
|
76
|
+
* STRICT, AND WITHOUT A `v` HATCH — unlike `CommentAnchorSchema` and
|
|
77
|
+
* `SuggestionSchema`, and that difference is deliberate rather than an
|
|
78
|
+
* oversight. Those two are negotiated payloads whose shapes must be able to grow
|
|
79
|
+
* while old readers keep parsing, so they version. This row is not negotiated:
|
|
80
|
+
* it is three fields that already exist, written by the shipped applier exactly
|
|
81
|
+
* as spelled here, and this module PROMOTES that convention rather than
|
|
82
|
+
* redesigning it. Adding a required `v` now would make the published parser
|
|
83
|
+
* reject every receipt currently in existence — the failure this module was
|
|
84
|
+
* written to prevent, committed by the module itself.
|
|
85
|
+
*
|
|
86
|
+
* So the three fields are closed, and a fourth fact does not arrive by widening
|
|
87
|
+
* this row. It arrives as a new key, or as a versioned successor schema that
|
|
88
|
+
* readers adopt before any writer emits it. The drop direction is why that is
|
|
89
|
+
* safe: an unrecognised row parses to `null`, which every consumer must already
|
|
90
|
+
* handle correctly, because absence is not proof and a reader with no replica
|
|
91
|
+
* sees exactly the same thing. Accepting the extras instead would surface a row
|
|
92
|
+
* this package half-understands to a decision surface, where one reader acts on
|
|
93
|
+
* the unknown field and another does not.
|
|
94
|
+
*/
|
|
95
|
+
export const SuggestionReceiptSchema = z.strictObject({
|
|
96
|
+
/**
|
|
97
|
+
* The claim token whose holder applied the edit, and the dedupe key for the
|
|
98
|
+
* apply itself.
|
|
99
|
+
*
|
|
100
|
+
* `min(1)` and nothing more. The backend's request boundary constrains this
|
|
101
|
+
* token to a 64-char hex string, and that constraint is already published
|
|
102
|
+
* through the generated API surface; restating it here would make a THIRD
|
|
103
|
+
* description of one token that no parity guard compares — the same call
|
|
104
|
+
* `./schemas` makes in leaving `userId` a bare `z.string()`. What this schema
|
|
105
|
+
* has to reject is a row that is not a token at all.
|
|
106
|
+
*/
|
|
107
|
+
claimToken: z.string().min(1),
|
|
108
|
+
/**
|
|
109
|
+
* Wall clock at the moment the edit landed. DISPLAY ONLY — never an ordering
|
|
110
|
+
* key, never an expiry input. The lease is the monotonic one.
|
|
111
|
+
*
|
|
112
|
+
* A bare `z.number()`, which in zod v4 already refuses `NaN` and the
|
|
113
|
+
* infinities. That matters more here than for a JSON response: a CRDT map
|
|
114
|
+
* replicates raw JavaScript values, so a peer can put a literal `NaN` in this
|
|
115
|
+
* field and it will arrive as one.
|
|
116
|
+
*/
|
|
117
|
+
appliedAt: z.number(),
|
|
118
|
+
/**
|
|
119
|
+
* Who applied it. A bare non-empty string rather than `.uuid()`, mirroring
|
|
120
|
+
* `./schemas`' `userId` for the same reason: the shapes in this package
|
|
121
|
+
* mirror the backend's looseness rather than tightening past it.
|
|
122
|
+
*/
|
|
123
|
+
byUserId: z.string().min(1),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export type SuggestionReceipt = z.infer<typeof SuggestionReceiptSchema>;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A receipt is parsed, not trusted.
|
|
130
|
+
*
|
|
131
|
+
* These values arrive over the wire from a peer and the CRDT replicates
|
|
132
|
+
* whatever was put there, with no schema of its own and no boundary in between.
|
|
133
|
+
* A row that is not three well-typed fields is therefore DROPPED rather than
|
|
134
|
+
* surfaced: a malformed receipt reaching a decision surface would withdraw a
|
|
135
|
+
* live suggestion's controls with no way back, or post a garbage claim token on
|
|
136
|
+
* every render. Returning the row's recognisable fields as a partial receipt
|
|
137
|
+
* would be the same mistake wearing a type.
|
|
138
|
+
*
|
|
139
|
+
* Total: any input, no throw, `null` for everything that is not a receipt.
|
|
140
|
+
*/
|
|
141
|
+
export function readSuggestionReceipt(
|
|
142
|
+
value: unknown,
|
|
143
|
+
): SuggestionReceipt | null {
|
|
144
|
+
const parsed = SuggestionReceiptSchema.safeParse(value);
|
|
145
|
+
return parsed.success ? parsed.data : null;
|
|
146
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walking the ladder — the READ half of the contract the anchor writes
|
|
3
|
+
* (ADR-CONTRACTS-116, ADR-CONTRACTS-127).
|
|
4
|
+
*
|
|
5
|
+
* WHY THE INTERPRETATION IS PUBLISHED AND NOT ONLY THE SHAPE. The backend
|
|
6
|
+
* validates an anchor's shape once, at the route boundary, then stores opaque
|
|
7
|
+
* jsonb it never interprets. So the client that WROTE the anchor and the client
|
|
8
|
+
* that later RESOLVES it must agree about what these fields MEAN with no
|
|
9
|
+
* server-side arbiter between them — and a disagreement produces no error at
|
|
10
|
+
* all. The write succeeds, the read succeeds, and the highlight lands on the
|
|
11
|
+
* wrong sentence. Publishing the schema bounded that risk; publishing the
|
|
12
|
+
* resolver closes it, because a second implementation can now be an IMPORT
|
|
13
|
+
* rather than a second description.
|
|
14
|
+
*
|
|
15
|
+
* EACH RUNG EITHER ANSWERS OR DECLINES, AND A RUNG THAT CANNOT VOUCH FOR ITS
|
|
16
|
+
* ANSWER MUST DECLINE. The whole design turns on refusing rather than guessing,
|
|
17
|
+
* because the wrong answer looks exactly like the right one. Under-anchoring is
|
|
18
|
+
* the deliberate error direction: a comment shown as unanchored is legible and
|
|
19
|
+
* recoverable, while a comment shown against the wrong sentence reads as a
|
|
20
|
+
* considered objection to a paragraph its author never saw.
|
|
21
|
+
*
|
|
22
|
+
* NO YJS HERE, DELIBERATELY. Everything below is a pure function of strings and
|
|
23
|
+
* indices. Rung 1 — decoding a `Y.RelativePosition` — needs `yjs`, which the
|
|
24
|
+
* vocabulary-guard forbids this package from importing, and it is the ONE rung
|
|
25
|
+
* that needs a live replica anyway. A consumer that holds a `Y.Text` composes
|
|
26
|
+
* it on top: it tries its own rung 1 and falls through to
|
|
27
|
+
* {@link resolveAnchorFromText}, which is total and never throws. That is also
|
|
28
|
+
* what lets a comment resolve on a card with no session, no stream and no CRDT
|
|
29
|
+
* replica — and what lets the server resolve one at all.
|
|
30
|
+
*
|
|
31
|
+
* All arithmetic is in UTF-16 code units (plain JavaScript string coordinates),
|
|
32
|
+
* like every other offset on the comment path. No Unicode normalization happens
|
|
33
|
+
* anywhere here: normalising before measuring changes the length and
|
|
34
|
+
* desynchronises every offset after the first composed character.
|
|
35
|
+
*/
|
|
36
|
+
import { ANCHOR_CONTEXT_CHARS } from "./anchor-constants";
|
|
37
|
+
import type { CommentAnchor } from "./anchor";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Where an anchor landed.
|
|
41
|
+
*
|
|
42
|
+
* `document` is not a degraded `resolved` and `orphaned` is not an error — they
|
|
43
|
+
* are three distinct outcomes, and collapsing any pair of them loses the
|
|
44
|
+
* distinction the comment rail draws on.
|
|
45
|
+
*
|
|
46
|
+
* `rung` stays `1 | 2` even though this module can only ever emit `2`: the
|
|
47
|
+
* consumer that composes the Yjs rung on top returns `rung: 1` into this same
|
|
48
|
+
* type, and narrowing it here would force that consumer to declare a second,
|
|
49
|
+
* divergent result type — which is the two-independent-descriptions problem
|
|
50
|
+
* this domain exists to prevent.
|
|
51
|
+
*/
|
|
52
|
+
export type AnchorResolution =
|
|
53
|
+
| { status: "resolved"; start: number; end: number; rung: 1 | 2 }
|
|
54
|
+
| { status: "document" }
|
|
55
|
+
| { status: "orphaned" };
|
|
56
|
+
|
|
57
|
+
/** Whether a needle occurs in a haystack exactly once, and where. */
|
|
58
|
+
export type Occurrence =
|
|
59
|
+
| { kind: "absent" }
|
|
60
|
+
| { kind: "unique"; index: number }
|
|
61
|
+
| { kind: "ambiguous" };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether `text` still reads `leftContext` immediately before `point` and
|
|
65
|
+
* `rightContext` immediately after it.
|
|
66
|
+
*
|
|
67
|
+
* The two length guards are the whole reason this is a function rather than two
|
|
68
|
+
* inline slices: `slice` reads a negative start from the END of the string and
|
|
69
|
+
* clamps a long end, so a context that CANNOT fit around `point` would be
|
|
70
|
+
* compared against an unrelated substring instead of failing. NOT FITTING IS A
|
|
71
|
+
* MISMATCH.
|
|
72
|
+
*
|
|
73
|
+
* EXPORTED because the acceptance path re-asks this question as a second,
|
|
74
|
+
* independent assertion before it writes the body (see `./verify`). A private
|
|
75
|
+
* copy there would be two descriptions of "are the contexts still there" — and
|
|
76
|
+
* the copy that forgot a length guard would compare against an unrelated
|
|
77
|
+
* substring and answer `true`, which is the one wrong answer that mutates the
|
|
78
|
+
* document.
|
|
79
|
+
*/
|
|
80
|
+
export function contextsMatchAt(
|
|
81
|
+
text: string,
|
|
82
|
+
point: number,
|
|
83
|
+
leftContext: string,
|
|
84
|
+
rightContext: string,
|
|
85
|
+
): boolean {
|
|
86
|
+
if (point < 0 || point > text.length) return false;
|
|
87
|
+
if (point < leftContext.length) return false;
|
|
88
|
+
if (point + rightContext.length > text.length) return false;
|
|
89
|
+
return (
|
|
90
|
+
text.slice(point - leftContext.length, point) === leftContext &&
|
|
91
|
+
text.slice(point, point + rightContext.length) === rightContext
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether `needle` occurs in `haystack` exactly once, and where.
|
|
97
|
+
*
|
|
98
|
+
* Overlapping occurrences COUNT — the second search resumes at `first + 1`, not
|
|
99
|
+
* at `first + needle.length`. A quote that overlaps itself (`"aa"` in `"aaa"`)
|
|
100
|
+
* is no less ambiguous for the overlap, and the conservative count is the one
|
|
101
|
+
* that errs toward orphaning.
|
|
102
|
+
*
|
|
103
|
+
* An EMPTY needle is `ambiguous`, never `unique`: it matches at every position,
|
|
104
|
+
* which is a fact about the search and not evidence about the point.
|
|
105
|
+
*/
|
|
106
|
+
export function findSoleOccurrence(
|
|
107
|
+
haystack: string,
|
|
108
|
+
needle: string,
|
|
109
|
+
): Occurrence {
|
|
110
|
+
if (needle.length === 0) return { kind: "ambiguous" };
|
|
111
|
+
const first = haystack.indexOf(needle);
|
|
112
|
+
if (first === -1) return { kind: "absent" };
|
|
113
|
+
if (haystack.indexOf(needle, first + 1) !== -1) return { kind: "ambiguous" };
|
|
114
|
+
return { kind: "unique", index: first };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const HIGH_SURROGATE_MIN = 0xd800;
|
|
118
|
+
const HIGH_SURROGATE_MAX = 0xdbff;
|
|
119
|
+
const LOW_SURROGATE_MIN = 0xdc00;
|
|
120
|
+
const LOW_SURROGATE_MAX = 0xdfff;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The context preceding `index`, never opening on half a character.
|
|
124
|
+
*
|
|
125
|
+
* A fixed-width slice can land between a surrogate pair, and the lone surrogate
|
|
126
|
+
* that results is ill-formed once the anchor is encoded for the wire. Trimming
|
|
127
|
+
* costs one code unit of context and changes nothing about matching — the
|
|
128
|
+
* document contains the same code units either way.
|
|
129
|
+
*
|
|
130
|
+
* Slices {@link ANCHOR_CONTEXT_CHARS}, the CAPTURE width, which is deliberately
|
|
131
|
+
* narrower than what the schema ACCEPTS. This is a constructor helper: nothing
|
|
132
|
+
* in the ladder below calls it, and it is published here so the construction
|
|
133
|
+
* half (`./create`) and the resolution half agree about the same string work.
|
|
134
|
+
*/
|
|
135
|
+
export function contextBefore(text: string, index: number): string {
|
|
136
|
+
let from = Math.max(0, index - ANCHOR_CONTEXT_CHARS);
|
|
137
|
+
const code = text.charCodeAt(from);
|
|
138
|
+
if (from > 0 && code >= LOW_SURROGATE_MIN && code <= LOW_SURROGATE_MAX) {
|
|
139
|
+
from += 1;
|
|
140
|
+
}
|
|
141
|
+
return text.slice(from, index);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The context following `index`, never closing on half a character. */
|
|
145
|
+
export function contextAfter(text: string, index: number): string {
|
|
146
|
+
let to = Math.min(text.length, index + ANCHOR_CONTEXT_CHARS);
|
|
147
|
+
if (to > index && to < text.length) {
|
|
148
|
+
const code = text.charCodeAt(to - 1);
|
|
149
|
+
if (code >= HIGH_SURROGATE_MIN && code <= HIGH_SURROGATE_MAX) to -= 1;
|
|
150
|
+
}
|
|
151
|
+
return text.slice(index, to);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Rung 2 for the text anchor — the context-qualified search, then the bare
|
|
156
|
+
* quote. Both refuse ambiguity; neither ever settles for a first match.
|
|
157
|
+
*/
|
|
158
|
+
function resolveByQuote(
|
|
159
|
+
anchor: Extract<CommentAnchor, { type: "text" }>,
|
|
160
|
+
text: string,
|
|
161
|
+
): AnchorResolution {
|
|
162
|
+
const context = anchor.prefix + anchor.quote + anchor.suffix;
|
|
163
|
+
if (context !== anchor.quote) {
|
|
164
|
+
const qualified = findSoleOccurrence(text, context);
|
|
165
|
+
// Two context-qualified matches mean the context FAILED to identify a
|
|
166
|
+
// candidate. Binding to the first would be the guess this ladder exists to
|
|
167
|
+
// forbid, and falling back to the bare quote here would only re-ask a
|
|
168
|
+
// question already known to be ambiguous.
|
|
169
|
+
if (qualified.kind === "ambiguous") return { status: "orphaned" };
|
|
170
|
+
if (qualified.kind === "unique") {
|
|
171
|
+
const start = qualified.index + anchor.prefix.length;
|
|
172
|
+
return {
|
|
173
|
+
status: "resolved",
|
|
174
|
+
start,
|
|
175
|
+
end: start + anchor.quote.length,
|
|
176
|
+
rung: 2,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
// Absent: the surrounding sentence was edited but the quote itself may have
|
|
180
|
+
// survived. Fall through.
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const bare = findSoleOccurrence(text, anchor.quote);
|
|
184
|
+
if (bare.kind !== "unique") return { status: "orphaned" };
|
|
185
|
+
return {
|
|
186
|
+
status: "resolved",
|
|
187
|
+
start: bare.index,
|
|
188
|
+
end: bare.index + anchor.quote.length,
|
|
189
|
+
rung: 2,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Rung 2 for the zero-width variant — find the seam the two contexts spell out
|
|
195
|
+
* when the caret between them is closed up, and resolve ONLY if it is unique.
|
|
196
|
+
*
|
|
197
|
+
* A repeated seam is the insertion anchor's version of a repeated quote, and it
|
|
198
|
+
* gets the same answer: `orphaned`. "Insert at the first `item done`" is the
|
|
199
|
+
* shortcut this rung exists to forbid — in a list of identical rows it is wrong
|
|
200
|
+
* two times out of three, and it never looks wrong.
|
|
201
|
+
*/
|
|
202
|
+
function resolveInsertionBySeam(
|
|
203
|
+
anchor: Extract<CommentAnchor, { type: "text-insertion" }>,
|
|
204
|
+
text: string,
|
|
205
|
+
): AnchorResolution {
|
|
206
|
+
const { leftContext, rightContext } = anchor;
|
|
207
|
+
const seam = leftContext + rightContext;
|
|
208
|
+
|
|
209
|
+
if (seam === "") {
|
|
210
|
+
// Only authorable on an EMPTY document, where 0 is the one point there is.
|
|
211
|
+
// Once the document has text that anchor says nothing about WHERE among it
|
|
212
|
+
// the caret belongs — and an empty needle matches everywhere, which is a
|
|
213
|
+
// fact about the search and not evidence about the point.
|
|
214
|
+
//
|
|
215
|
+
// Not redundant with `findSoleOccurrence`'s empty-needle refusal below:
|
|
216
|
+
// this branch is reached first, and it is the one that makes "the only
|
|
217
|
+
// point an empty document has" resolvable at all.
|
|
218
|
+
return text.length === 0
|
|
219
|
+
? { status: "resolved", start: 0, end: 0, rung: 2 }
|
|
220
|
+
: { status: "orphaned" };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const seen = findSoleOccurrence(text, seam);
|
|
224
|
+
if (seen.kind !== "unique") return { status: "orphaned" };
|
|
225
|
+
const point = seen.index + leftContext.length;
|
|
226
|
+
return { status: "resolved", start: point, end: point, rung: 2 };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Resolve `anchor` against the current `text`, using nothing but the text.
|
|
231
|
+
*
|
|
232
|
+
* TOTAL AND NEVER THROWS: every input produces one of the three outcomes, which
|
|
233
|
+
* is what lets a consumer use it as the floor of its own ladder without a
|
|
234
|
+
* try/catch or a null check.
|
|
235
|
+
*
|
|
236
|
+
* `text` is the coordinate space the caller will paint into — the live
|
|
237
|
+
* `Y.Text`'s contents where a session is attached, the materialized document
|
|
238
|
+
* body where it is not. A consumer that holds a `Y.Text` tries its own
|
|
239
|
+
* relative-position rung FIRST and calls this when that rung declines; the
|
|
240
|
+
* geometry both paths produce must be identical, or two people looking at the
|
|
241
|
+
* same document disagree about where a comment sits.
|
|
242
|
+
*
|
|
243
|
+
* The three outcomes, by variant:
|
|
244
|
+
* - `document` — the thread hangs off the subject AS A WHOLE. It draws no
|
|
245
|
+
* highlight, but it is correctly anchored; reporting it as `orphaned` would
|
|
246
|
+
* put it behind the rail's "this text is gone" affordance.
|
|
247
|
+
* - `text` — {@link resolveByQuote}: the context-qualified string first, the
|
|
248
|
+
* bare quote as the fallback, ambiguity orphaned at BOTH levels.
|
|
249
|
+
* - `text-insertion` — {@link resolveInsertionBySeam}: the unique
|
|
250
|
+
* `leftContext`+`rightContext` seam, and nothing else. Always zero-width.
|
|
251
|
+
*/
|
|
252
|
+
export function resolveAnchorFromText(
|
|
253
|
+
anchor: CommentAnchor,
|
|
254
|
+
text: string,
|
|
255
|
+
): AnchorResolution {
|
|
256
|
+
if (anchor.type === "document") return { status: "document" };
|
|
257
|
+
if (anchor.type === "text-insertion") {
|
|
258
|
+
return resolveInsertionBySeam(anchor, text);
|
|
259
|
+
}
|
|
260
|
+
return resolveByQuote(anchor, text);
|
|
261
|
+
}
|
package/src/comments/schemas.ts
CHANGED
|
@@ -110,11 +110,14 @@ export type CommentMention = z.infer<typeof CommentMentionSchema>;
|
|
|
110
110
|
* One comment as the app renders it.
|
|
111
111
|
*
|
|
112
112
|
* THE REDACTION INVARIANT: `body` is null EXACTLY WHEN `deletedAt` is set, and a
|
|
113
|
-
* soft-deleted comment also arrives with `mentions` empty.
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
113
|
+
* soft-deleted comment also arrives with `mentions` empty.
|
|
114
|
+
*
|
|
115
|
+
* WHERE A REDACTED COMMENT ACTUALLY APPEARS is narrower than it once was, and
|
|
116
|
+
* this docblock used to overstate it. The redacted projection is the DELETE
|
|
117
|
+
* acknowledgement — the response to the caller who performed the delete, telling
|
|
118
|
+
* them what they destroyed. It is NOT a row in a thread listing: ADR-BE-545 has
|
|
119
|
+
* the read omit deleted comments outright, and drop a comment-kind thread once
|
|
120
|
+
* nothing live is left on it. A reader never sees a tombstone in a conversation.
|
|
118
121
|
*
|
|
119
122
|
* The invariant is DOCUMENTED here and enforced by the writer, not by a Zod
|
|
120
123
|
* refinement. A refinement would make this package refuse a response the server
|
|
@@ -166,6 +169,18 @@ export type CommentProjection = z.infer<typeof CommentSchema>;
|
|
|
166
169
|
* would make this package refuse a response the server is willing to emit and
|
|
167
170
|
* would be a third wire description no parity guard can check.
|
|
168
171
|
*
|
|
172
|
+
* `lastAnchoredOffset` IS NOT AN ANCHOR. It is a source offset recording where
|
|
173
|
+
* this thread's passage USED TO BE, reported by the client that watched the
|
|
174
|
+
* passage disappear (ADR-BE-548). It exists so a client can keep an ORPHANED
|
|
175
|
+
* thread in the reader's list where the conversation happened, instead of piling
|
|
176
|
+
* every one of them at the top — and it may be used for NOTHING ELSE. It must
|
|
177
|
+
* never yield a range, and nothing may place a highlight, a decoration or a
|
|
178
|
+
* leader line from it: a comment shown against the wrong sentence is strictly
|
|
179
|
+
* worse than one shown as unanchored (ADR-APP-120), and that ordering is what
|
|
180
|
+
* bounds this field. `null` is ordinary — it means no client has watched this
|
|
181
|
+
* thread's passage go — and the value is WRITE-ONCE on the server, so it records
|
|
182
|
+
* the first sighting and never a correction.
|
|
183
|
+
*
|
|
169
184
|
* THE TWO-LEVEL STATUS MODEL: `status` stays exactly `open | resolved`
|
|
170
185
|
* (`COMMENT_THREAD_STATUSES` is NOT widened — widening a closed published enum
|
|
171
186
|
* is a parse outage for every deployed client). A decided suggestion arrives as
|
|
@@ -185,6 +200,7 @@ export const CommentThreadSummarySchema = z.object({
|
|
|
185
200
|
createdByUserId: z.string().nullable(),
|
|
186
201
|
resolvedByUserId: z.string().nullable(),
|
|
187
202
|
resolvedAt: z.string().nullable(),
|
|
203
|
+
lastAnchoredOffset: z.number().int().nullable(),
|
|
188
204
|
createdAt: z.string(),
|
|
189
205
|
updatedAt: z.string(),
|
|
190
206
|
});
|
|
@@ -193,9 +209,12 @@ export type CommentThreadSummary = z.infer<typeof CommentThreadSummarySchema>;
|
|
|
193
209
|
/**
|
|
194
210
|
* A thread with ALL its comments, oldest-first by (createdAt ASC, id ASC).
|
|
195
211
|
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
* comment
|
|
212
|
+
* DELETED COMMENTS ARE OMITTED, NOT REDACTED. This once said the opposite, and
|
|
213
|
+
* the prose outlived the rule: ADR-BE-545 made the read drop deleted comments
|
|
214
|
+
* entirely, and drop a comment-kind thread once nothing live is left on it —
|
|
215
|
+
* deleting the comment that IS a thread deletes the thread with it, so the reply
|
|
216
|
+
* order a redacted row used to hold open no longer has anything to hold. The
|
|
217
|
+
* wire SHAPE is unchanged either way; what changed is which rows arrive.
|
|
199
218
|
*/
|
|
200
219
|
export const CommentThreadSchema = CommentThreadSummarySchema.extend({
|
|
201
220
|
comments: z.array(CommentSchema),
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two questions asked BEFORE the body is written — is the passage still
|
|
3
|
+
* what the anchor says it is, and what edit does this op make of that range?
|
|
4
|
+
* (ADR-CONTRACTS-122, ADR-CONTRACTS-127.)
|
|
5
|
+
*
|
|
6
|
+
* WHY THESE ARE PUBLISHED AT ALL. Both are total functions of their arguments —
|
|
7
|
+
* no `Y.Text`, no clock, no lease, nothing touching a sanctioned-mutation
|
|
8
|
+
* window. The app's own applier records that as the property that makes them
|
|
9
|
+
* "the only part of the applier that can be lifted out of it": the depth
|
|
10
|
+
* counter and the lease have to stay in one module, because an exported pair
|
|
11
|
+
* that opened and closed that window would be a generic bypass. What is left
|
|
12
|
+
* once they are removed is exactly this file — and once it is an import rather
|
|
13
|
+
* than a second description, a NON-BROWSER applier (the server, a comment pass)
|
|
14
|
+
* becomes possible without a second answer to "did this suggestion still apply".
|
|
15
|
+
*
|
|
16
|
+
* THE APPLIER MUST NOT TRUST THE FINDER. {@link anchorStillReads} is a SECOND
|
|
17
|
+
* assertion, independent of {@link resolveAnchorFromText} by construction: it
|
|
18
|
+
* reads the anchor's own stored evidence against the live text and takes the
|
|
19
|
+
* resolution only as a position. That a rung already checked something like it
|
|
20
|
+
* does not make it redundant — the rung answers "where does this land", this
|
|
21
|
+
* answers "is what lands there still the thing", and a consumer that composes
|
|
22
|
+
* its own Yjs rung on top supplies a position this module never saw.
|
|
23
|
+
*
|
|
24
|
+
* All arithmetic is in UTF-16 code units, like every other offset on the
|
|
25
|
+
* comment path, and no Unicode normalization happens here.
|
|
26
|
+
*/
|
|
27
|
+
import type { CommentAnchor } from "./anchor";
|
|
28
|
+
import { contextsMatchAt, type AnchorResolution } from "./resolve";
|
|
29
|
+
import type { Suggestion } from "./suggestion";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A resolution that actually named a range.
|
|
33
|
+
*
|
|
34
|
+
* Narrowed from {@link AnchorResolution} rather than restated, so a consumer
|
|
35
|
+
* composing the Yjs rung on top hands its own `rung: 1` answer straight in.
|
|
36
|
+
*/
|
|
37
|
+
type Resolved = Extract<AnchorResolution, { status: "resolved" }>;
|
|
38
|
+
|
|
39
|
+
/** One text mutation: delete `deleteLength` at `at`, then insert `insert`. */
|
|
40
|
+
export interface PlannedEdit {
|
|
41
|
+
readonly at: number;
|
|
42
|
+
readonly deleteLength: number;
|
|
43
|
+
readonly insert: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Does the resolved place still hold what the anchor says it holds?
|
|
48
|
+
*
|
|
49
|
+
* By variant:
|
|
50
|
+
* - `text` — the live slice must still be the stored `quote`, exactly. Not a
|
|
51
|
+
* prefix of it, not a normalized form of it.
|
|
52
|
+
* - `text-insertion` — the resolution must still be ZERO-WIDTH, and both
|
|
53
|
+
* contexts must still meet at that point ({@link contextsMatchAt}, imported
|
|
54
|
+
* rather than re-derived: a private copy that forgot a length guard would
|
|
55
|
+
* compare against an unrelated substring and answer `true`, which is the
|
|
56
|
+
* one wrong answer that mutates the document).
|
|
57
|
+
* - `document` — `false`. A whole-document anchor makes no claim about a
|
|
58
|
+
* range, so it can never vouch for one. Unreachable through the shipped
|
|
59
|
+
* ladder, which answers `{ status: "document" }` for that variant and never
|
|
60
|
+
* a `resolved` — but a consumer's own rung can hand one in, and the refusal
|
|
61
|
+
* is what makes that harmless.
|
|
62
|
+
*/
|
|
63
|
+
export function anchorStillReads(
|
|
64
|
+
anchor: CommentAnchor,
|
|
65
|
+
text: string,
|
|
66
|
+
resolution: Resolved,
|
|
67
|
+
): boolean {
|
|
68
|
+
if (anchor.type === "text") {
|
|
69
|
+
return text.slice(resolution.start, resolution.end) === anchor.quote;
|
|
70
|
+
}
|
|
71
|
+
if (anchor.type === "text-insertion") {
|
|
72
|
+
return (
|
|
73
|
+
resolution.start === resolution.end &&
|
|
74
|
+
contextsMatchAt(
|
|
75
|
+
text,
|
|
76
|
+
resolution.start,
|
|
77
|
+
anchor.leftContext,
|
|
78
|
+
anchor.rightContext,
|
|
79
|
+
)
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What this op does to that range — or `null` when it would do nothing.
|
|
87
|
+
*
|
|
88
|
+
* Derived from the CANONICAL suggestion payload, never from a caller's
|
|
89
|
+
* restatement of it: that seam is where "accept this suggestion" would quietly
|
|
90
|
+
* become "apply something like it".
|
|
91
|
+
*
|
|
92
|
+
* A NO-OP IS A REFUSAL, NOT A CHEAP SUCCESS. An `insert` carrying no text, or a
|
|
93
|
+
* `delete` over a collapsed range, would run a transaction that writes a
|
|
94
|
+
* receipt and changes not one character — the thread would go to `accepted`
|
|
95
|
+
* over a document that never received the proposal, which is the invariant pair
|
|
96
|
+
* read backwards. Both shapes mean the payload and the anchor disagree about
|
|
97
|
+
* what kind of edit this is, and that disagreement is not this module's to
|
|
98
|
+
* settle.
|
|
99
|
+
*
|
|
100
|
+
* `insert` lands at `end`, matching where the redline draws the proposed text,
|
|
101
|
+
* so the diff the user accepted and the edit they get agree about the seam.
|
|
102
|
+
*
|
|
103
|
+
* The switch is exhaustive over {@link Suggestion}'s `op` with NO default arm:
|
|
104
|
+
* a fourth op must arrive as a type error here rather than be absorbed as a
|
|
105
|
+
* silent `undefined` return.
|
|
106
|
+
*/
|
|
107
|
+
export function plannedEdit(
|
|
108
|
+
suggestion: Suggestion,
|
|
109
|
+
resolution: Resolved,
|
|
110
|
+
): PlannedEdit | null {
|
|
111
|
+
const insert = suggestion.insertedText ?? "";
|
|
112
|
+
const span = resolution.end - resolution.start;
|
|
113
|
+
|
|
114
|
+
switch (suggestion.op) {
|
|
115
|
+
case "insert":
|
|
116
|
+
return insert === ""
|
|
117
|
+
? null
|
|
118
|
+
: { at: resolution.end, deleteLength: 0, insert };
|
|
119
|
+
case "delete":
|
|
120
|
+
return span === 0
|
|
121
|
+
? null
|
|
122
|
+
: { at: resolution.start, deleteLength: span, insert: "" };
|
|
123
|
+
case "replace":
|
|
124
|
+
return span === 0 || insert === ""
|
|
125
|
+
? null
|
|
126
|
+
: { at: resolution.start, deleteLength: span, insert };
|
|
127
|
+
}
|
|
128
|
+
}
|