@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@company-semantics/contracts",
|
|
3
|
-
"version": "51.
|
|
3
|
+
"version": "51.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -148,9 +148,9 @@
|
|
|
148
148
|
"markdownlint-cli2": "^0.23.2",
|
|
149
149
|
"openapi-typescript": "^7.13.0",
|
|
150
150
|
"prettier": "^3.9.6",
|
|
151
|
-
"tsx": "^4.23.
|
|
151
|
+
"tsx": "^4.23.12",
|
|
152
152
|
"typescript": "^5.8.3",
|
|
153
|
-
"typescript-eslint": "^8.
|
|
153
|
+
"typescript-eslint": "^8.67.0",
|
|
154
154
|
"vite": "^8.2.1",
|
|
155
155
|
"vitest": "^4.1.10",
|
|
156
156
|
"yaml": "^2.9.0"
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
|
|
2
|
-
export const SPEC_HASH = '
|
|
3
|
-
export const SPEC_HASH_FULL = '
|
|
2
|
+
export const SPEC_HASH = 'b044ca016033' as const;
|
|
3
|
+
export const SPEC_HASH_FULL = 'b044ca016033c3a7224160a73898e3f551218dbef0139a23601f1ca4eb171d15' as const;
|
package/src/api/generated.ts
CHANGED
|
@@ -3565,6 +3565,23 @@ export interface paths {
|
|
|
3565
3565
|
patch?: never;
|
|
3566
3566
|
trace?: never;
|
|
3567
3567
|
};
|
|
3568
|
+
"/api/comments/threads/{threadId}/anchor-trace": {
|
|
3569
|
+
parameters: {
|
|
3570
|
+
query?: never;
|
|
3571
|
+
header?: never;
|
|
3572
|
+
path?: never;
|
|
3573
|
+
cookie?: never;
|
|
3574
|
+
};
|
|
3575
|
+
get?: never;
|
|
3576
|
+
put?: never;
|
|
3577
|
+
/** Report where a thread's anchored passage used to be */
|
|
3578
|
+
post: operations["stampCommentAnchorTrace"];
|
|
3579
|
+
delete?: never;
|
|
3580
|
+
options?: never;
|
|
3581
|
+
head?: never;
|
|
3582
|
+
patch?: never;
|
|
3583
|
+
trace?: never;
|
|
3584
|
+
};
|
|
3568
3585
|
"/api/comments/threads/{threadId}/accept-intent": {
|
|
3569
3586
|
parameters: {
|
|
3570
3587
|
query?: never;
|
|
@@ -4720,7 +4737,7 @@ export interface components {
|
|
|
4720
4737
|
summary: {
|
|
4721
4738
|
executionId: string;
|
|
4722
4739
|
/** @enum {string} */
|
|
4723
|
-
kind: "integration.connect" | "integration.disconnect" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest";
|
|
4740
|
+
kind: "integration.connect" | "integration.disconnect" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest" | "companyMd.commentPass" | "semantic.transform" | "companyMd.importContextDoc";
|
|
4724
4741
|
target: {
|
|
4725
4742
|
/** @constant */
|
|
4726
4743
|
type: "slack";
|
|
@@ -6514,6 +6531,9 @@ export interface components {
|
|
|
6514
6531
|
CommentThreadSummaryResponse: {
|
|
6515
6532
|
thread: components["schemas"]["CommentThreadSummary"];
|
|
6516
6533
|
};
|
|
6534
|
+
AnchorTraceRequest: {
|
|
6535
|
+
lastAnchoredOffset: number;
|
|
6536
|
+
};
|
|
6517
6537
|
/** @description The acceptance lease: the opaque claim token that alone can complete the accept, and how long the lease lives. */
|
|
6518
6538
|
SuggestionClaimResponse: {
|
|
6519
6539
|
claimToken: string;
|
|
@@ -6550,12 +6570,13 @@ export interface components {
|
|
|
6550
6570
|
/** @enum {string} */
|
|
6551
6571
|
category: "version" | "source" | "lifecycle" | "access" | "sync";
|
|
6552
6572
|
/** @enum {string} */
|
|
6553
|
-
eventType: "doc_created" | "source_added" | "source_removed" | "content_generated" | "external_sync_completed" | "access_granted" | "access_revoked" | "access_updated" | "sharing_policy_changed" | "visibility_changed" | "owner_transferred" | "discoverability_changed";
|
|
6573
|
+
eventType: "doc_created" | "doc_renamed" | "doc_moved" | "source_added" | "source_attached" | "source_detached" | "source_removed" | "source_extraction_started" | "source_extraction_succeeded" | "source_extraction_failed" | "content_generated" | "external_sync_completed" | "access_granted" | "access_revoked" | "access_updated" | "sharing_policy_changed" | "visibility_changed" | "owner_transferred" | "discoverability_changed";
|
|
6554
6574
|
actor: components["schemas"]["CompanyMdDocVersionActor"] | null;
|
|
6555
6575
|
subject: string | null;
|
|
6556
6576
|
target: components["schemas"]["CompanyMdDocHistoryTarget"] | null;
|
|
6557
6577
|
previousValue: string | null;
|
|
6558
6578
|
newValue: string | null;
|
|
6579
|
+
correlationId: string | null;
|
|
6559
6580
|
};
|
|
6560
6581
|
/** @description The singular actor a version is attributed to, in product vocabulary. */
|
|
6561
6582
|
CompanyMdDocVersionActor: {
|
|
@@ -6612,6 +6633,7 @@ export interface components {
|
|
|
6612
6633
|
createdByUserId: string | null;
|
|
6613
6634
|
resolvedByUserId: string | null;
|
|
6614
6635
|
resolvedAt: string | null;
|
|
6636
|
+
lastAnchoredOffset: number | null;
|
|
6615
6637
|
createdAt: string;
|
|
6616
6638
|
updatedAt: string;
|
|
6617
6639
|
comments: components["schemas"]["Comment"][];
|
|
@@ -6686,6 +6708,7 @@ export interface components {
|
|
|
6686
6708
|
createdByUserId: string | null;
|
|
6687
6709
|
resolvedByUserId: string | null;
|
|
6688
6710
|
resolvedAt: string | null;
|
|
6711
|
+
lastAnchoredOffset: number | null;
|
|
6689
6712
|
createdAt: string;
|
|
6690
6713
|
updatedAt: string;
|
|
6691
6714
|
};
|
|
@@ -8519,7 +8542,7 @@ export interface operations {
|
|
|
8519
8542
|
query?: {
|
|
8520
8543
|
cursor?: string;
|
|
8521
8544
|
limit?: number;
|
|
8522
|
-
kind?: "integration.connect" | "integration.disconnect" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest";
|
|
8545
|
+
kind?: "integration.connect" | "integration.disconnect" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest" | "companyMd.commentPass" | "semantic.transform" | "companyMd.importContextDoc";
|
|
8523
8546
|
targetType?: string;
|
|
8524
8547
|
periodStart?: string;
|
|
8525
8548
|
periodEnd?: string;
|
|
@@ -8654,7 +8677,7 @@ export interface operations {
|
|
|
8654
8677
|
query?: {
|
|
8655
8678
|
limit?: number;
|
|
8656
8679
|
offset?: number;
|
|
8657
|
-
kind?: "integration.connect" | "integration.disconnect" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest";
|
|
8680
|
+
kind?: "integration.connect" | "integration.disconnect" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest" | "companyMd.commentPass" | "semantic.transform" | "companyMd.importContextDoc";
|
|
8658
8681
|
kindPrefix?: string;
|
|
8659
8682
|
};
|
|
8660
8683
|
header?: never;
|
|
@@ -12833,6 +12856,46 @@ export interface operations {
|
|
|
12833
12856
|
};
|
|
12834
12857
|
};
|
|
12835
12858
|
};
|
|
12859
|
+
stampCommentAnchorTrace: {
|
|
12860
|
+
parameters: {
|
|
12861
|
+
query?: never;
|
|
12862
|
+
header?: never;
|
|
12863
|
+
path: {
|
|
12864
|
+
threadId: string;
|
|
12865
|
+
};
|
|
12866
|
+
cookie?: never;
|
|
12867
|
+
};
|
|
12868
|
+
requestBody: {
|
|
12869
|
+
content: {
|
|
12870
|
+
"application/json": components["schemas"]["AnchorTraceRequest"];
|
|
12871
|
+
};
|
|
12872
|
+
};
|
|
12873
|
+
responses: {
|
|
12874
|
+
/** @description Offset recorded, or the already-recorded thread returned unchanged (first write wins) */
|
|
12875
|
+
200: {
|
|
12876
|
+
headers: {
|
|
12877
|
+
[name: string]: unknown;
|
|
12878
|
+
};
|
|
12879
|
+
content: {
|
|
12880
|
+
"application/json": components["schemas"]["CommentThreadSummaryResponse"];
|
|
12881
|
+
};
|
|
12882
|
+
};
|
|
12883
|
+
/** @description Invalid body */
|
|
12884
|
+
400: {
|
|
12885
|
+
headers: {
|
|
12886
|
+
[name: string]: unknown;
|
|
12887
|
+
};
|
|
12888
|
+
content?: never;
|
|
12889
|
+
};
|
|
12890
|
+
/** @description Thread not found, or the caller may not comment on its subject */
|
|
12891
|
+
404: {
|
|
12892
|
+
headers: {
|
|
12893
|
+
[name: string]: unknown;
|
|
12894
|
+
};
|
|
12895
|
+
content?: never;
|
|
12896
|
+
};
|
|
12897
|
+
};
|
|
12898
|
+
};
|
|
12836
12899
|
acceptSuggestionIntent: {
|
|
12837
12900
|
parameters: {
|
|
12838
12901
|
query?: never;
|
package/src/comments/README.md
CHANGED
|
@@ -2,26 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
## Purpose
|
|
4
4
|
|
|
5
|
-
The published **read** vocabulary of the comment surface
|
|
5
|
+
The published **read** vocabulary of the comment surface — where a thread hangs
|
|
6
6
|
(`CommentAnchorSchema`), what a thread and its comments look like on the wire
|
|
7
7
|
(`CommentThreadSchema`, `CommentSchema`, `CommentMentionSchema`), and who a
|
|
8
|
-
comment may name (`MentionableResponseSchema`)
|
|
8
|
+
comment may name (`MentionableResponseSchema`) — together with the one published
|
|
9
|
+
INTERPRETATION of an anchor, in both directions: reading one against a document
|
|
10
|
+
(`resolveAnchorFromText`, `anchorStillReads`, `plannedEdit`), composing one from
|
|
11
|
+
a range (`createTextAnchorFromRange`, `createTextInsertionAnchorAt`), the size
|
|
12
|
+
bounds both halves share (`./anchor-constants`), and the receipt that proves an
|
|
13
|
+
accepted suggestion's edit already landed (`SUGGESTION_RECEIPTS_MAP`).
|
|
9
14
|
|
|
10
15
|
This exists as a published contract rather than an app-local type because two
|
|
11
16
|
independent clients have to agree about it without a server in the middle to
|
|
12
|
-
arbitrate — see the first
|
|
17
|
+
arbitrate — see the first two invariants. Publishing the SHAPE bounded that
|
|
18
|
+
risk; publishing the interpretation closes it (ADR-CONTRACTS-127), because a
|
|
19
|
+
second implementation is now an IMPORT rather than a second description.
|
|
20
|
+
Interpretation is pure, dependency-free derivation of published vocabulary, so
|
|
21
|
+
it stays inside the vocabulary-guard's rules — request bodies still do not.
|
|
13
22
|
|
|
14
23
|
## Invariants
|
|
15
24
|
|
|
16
|
-
- **
|
|
17
|
-
|
|
18
|
-
drift in it.** The route boundary validates the anchor's SHAPE and stores the
|
|
25
|
+
- **STORAGE IS STILL A PASSTHROUGH, and it still cannot catch drift in an
|
|
26
|
+
anchor.** The route boundary validates the anchor's SHAPE and stores the
|
|
19
27
|
result as opaque jsonb; the service, the database and every projection treat
|
|
20
28
|
it as bytes and interpret nothing. So if the writing client and the resolving
|
|
21
29
|
client disagree about what these fields mean, the write succeeds, the read
|
|
22
30
|
succeeds, and the comment silently lands on the wrong text. There is no
|
|
23
31
|
server-side tripwire, and there cannot be one — which is precisely why the
|
|
24
|
-
shape is published here instead of being declared twice.
|
|
32
|
+
shape is published here instead of being declared twice. ADR-CONTRACTS-127
|
|
33
|
+
left this half exactly as it was.
|
|
34
|
+
- **INTERPRETATION IS NO LONGER PER-CONSUMER — that is the half that did
|
|
35
|
+
change, and both halves are true at once.** `resolveAnchorFromText`,
|
|
36
|
+
`anchorStillReads`, `plannedEdit`, `createTextAnchorFromRange` and
|
|
37
|
+
`createTextInsertionAnchorAt` are the ONE published reading of those bytes
|
|
38
|
+
(ADR-CONTRACTS-127), so the backend becoming an eligible applier and a comment
|
|
39
|
+
pass running server-side are imports rather than a third and fourth
|
|
40
|
+
description of the same rules. This does NOT make the server an arbiter: the
|
|
41
|
+
anchor column is still opaque jsonb, still uninterpreted by the database and
|
|
42
|
+
by every projection, and the boundary still validates shape only. A reader who
|
|
43
|
+
takes either half alone gets the model wrong — one way they expect the server
|
|
44
|
+
to catch a bad anchor, the other way they write a second resolver.
|
|
25
45
|
- **`v` is the evolution hatch, and it is the only thing that bounds that
|
|
26
46
|
risk.** A client that meets an anchor it cannot resolve sees an unfamiliar `v`
|
|
27
47
|
and degrades on purpose instead of guessing at fields it half-recognises. New
|
|
@@ -43,15 +63,102 @@ arbitrate — see the first invariant.
|
|
|
43
63
|
silently. Instead the point carries an optional `relPos` (one
|
|
44
64
|
`Y.RelativePosition` at the caret) plus bilateral `leftContext`/`rightContext`
|
|
45
65
|
(each max 64 UTF-16 code units, required-but-may-be-empty at document edges).
|
|
46
|
-
Resolution
|
|
47
|
-
|
|
48
|
-
|
|
66
|
+
Resolution: rung 1 decodes `relPos` and verifies BOTH contexts around the
|
|
67
|
+
decoded point (stale-not-correct, like the text anchor's quote check) and
|
|
68
|
+
stays with the consumer, since decoding needs `yjs` and a live replica; rung 2
|
|
69
|
+
is published here as `resolveAnchorFromText` and searches for the UNIQUE
|
|
49
70
|
`leftContext`+`rightContext` seam — any ambiguity resolves orphaned, never a
|
|
50
71
|
guess; rung 3 orphaned. This variant ships contracts-first: its corpus cases
|
|
51
72
|
were added HERE with the deliberate same-diff digest re-pin ADR-CONTRACTS-116
|
|
52
73
|
allows (PRD-00933), and the backend boundary adopts both in PRD-00934 — until
|
|
53
74
|
then the upstream diff shows expected forward drift, and `anchor-corpus:sync`
|
|
54
75
|
must not be run (it would erase the new cases).
|
|
76
|
+
- **AMBIGUITY RESOLVES ORPHANED, AT BOTH LEVELS, AND NEVER BINDS TO THE FIRST
|
|
77
|
+
OCCURRENCE.** For a `text` anchor, `resolveAnchorFromText` searches the
|
|
78
|
+
context-qualified `prefix`+`quote`+`suffix` first, and TWO occurrences of that
|
|
79
|
+
string mean the context FAILED to identify a candidate — so the answer is
|
|
80
|
+
`orphaned`, and it does not then fall back to the bare quote, which would only
|
|
81
|
+
re-ask a question already known to be ambiguous. Absence is not ambiguity: an
|
|
82
|
+
ABSENT context does fall through to the bare `quote`, where a repeated quote is
|
|
83
|
+
orphaned in its own right. A repeated insertion seam gets the same answer for
|
|
84
|
+
the same reason, and an EMPTY seam is refused on a non-empty document rather
|
|
85
|
+
than treated as matching everywhere — an empty needle is a fact about the
|
|
86
|
+
search, not evidence about the point. (The one exception is the empty
|
|
87
|
+
document, where 0 is the only point there is.)
|
|
88
|
+
Under-anchoring is the deliberate error direction — a comment shown as
|
|
89
|
+
unanchored is legible and recoverable, while a comment shown against the wrong
|
|
90
|
+
sentence reads as a considered objection to a paragraph its author never saw.
|
|
91
|
+
Nothing outside this package enforces the rule: a consumer composing its own
|
|
92
|
+
rung 1 can resolve ambiguously THERE and never reach the published ladder's
|
|
93
|
+
refusal, so this is written down rather than typed (ADR-CONTRACTS-127).
|
|
94
|
+
- **The applier must not trust the finder.** `anchorStillReads` is a SECOND
|
|
95
|
+
assertion, independent of `resolveAnchorFromText` by construction: it reads the
|
|
96
|
+
anchor's own stored evidence against the live text and takes the resolution
|
|
97
|
+
only as a position. That a rung already checked something like it does not make
|
|
98
|
+
it redundant — the rung answers "where does this land", the assertion answers
|
|
99
|
+
"is what lands there still the thing", and a consumer composing the Yjs rung on
|
|
100
|
+
top supplies a position this package never computed. A `document` anchor
|
|
101
|
+
answers `false`: it names no characters, so it can vouch for no range.
|
|
102
|
+
- **A no-op is a refusal, not a cheap success.** `plannedEdit` returns `null` for
|
|
103
|
+
an `insert`/`replace` carrying no text and for a `delete`/`replace` over a
|
|
104
|
+
collapsed range. Each of those would otherwise run a transaction that writes a
|
|
105
|
+
receipt and changes not one character — the thread reported as `accepted` over
|
|
106
|
+
a document that never received the proposal. Both shapes mean the payload and
|
|
107
|
+
the anchor disagree about what kind of edit this is, and settling that
|
|
108
|
+
disagreement is not this package's to do.
|
|
109
|
+
- **The receipt is the durable proof that the edit ALREADY LANDED, and its KEY
|
|
110
|
+
is the contract.** Acceptance is apply-then-record, so there is one window —
|
|
111
|
+
between the transaction and the acknowledgement — in which the document has
|
|
112
|
+
changed and the thread still says `open`. `SUGGESTION_RECEIPTS_MAP` names the
|
|
113
|
+
root map the receipt is written into, in the SAME transaction as the edit, so
|
|
114
|
+
either both survived or neither did. A receipt written under any other name
|
|
115
|
+
lands in a map nobody reads while the reader watches a map nobody writes:
|
|
116
|
+
nothing throws, and the accept control stays live over an edit already in the
|
|
117
|
+
body. The name is spelled once, here, exactly as `COMPANY_MD_COLLAB_TEXT_KEY`
|
|
118
|
+
is for the body text.
|
|
119
|
+
- **The receipt lives BESIDE the body, never inside it, and ABSENCE IS NOT
|
|
120
|
+
PROOF.** It is a root map on the same document as the text, not a span in the
|
|
121
|
+
text — it must never reach the markdown source, an export, or the content
|
|
122
|
+
hash. And a reader holding no replica holds no receipts, so an empty map means
|
|
123
|
+
"no receipt is KNOWN HERE", never "no receipt exists": every consumer acts on
|
|
124
|
+
PRESENCE only. `readSuggestionReceipt` drops a malformed row to `null` rather
|
|
125
|
+
than surfacing a partial one precisely because that lands in the state every
|
|
126
|
+
consumer already has to handle correctly.
|
|
127
|
+
- **The receipt row is STRICT and carries no `v`, unlike the anchor and the
|
|
128
|
+
suggestion.** Those two are negotiated payloads that must grow while old
|
|
129
|
+
readers keep parsing. The receipt is three fields the shipped applier already
|
|
130
|
+
writes, promoted here rather than redesigned — a required `v` would make the
|
|
131
|
+
published parser reject every receipt in existence. A fourth fact arrives as a
|
|
132
|
+
new key or a versioned successor schema, never by widening this row.
|
|
133
|
+
- **CAPTURE AND ACCEPT ARE DIFFERENT NUMBERS, and collapsing them is a SILENT
|
|
134
|
+
REJECTION.** `ANCHOR_CONTEXT_CHARS` (32) is how much context a CONSTRUCTOR
|
|
135
|
+
captures either side of a range; `ANCHOR_AFFIX_MAX_CHARS` (64) is the largest
|
|
136
|
+
affix `CommentAnchorSchema` will ACCEPT. The gap is the point, not an
|
|
137
|
+
oversight: the capture width is a write-side choice that may change, while the
|
|
138
|
+
accept bound is a promise made to every anchor already in storage — durable
|
|
139
|
+
jsonb the server never re-validates. Narrowing accept to capture refuses
|
|
140
|
+
anchors a client with a wider capture already wrote; widening capture past
|
|
141
|
+
accept composes anchors the boundary then refuses, losing a comment AFTER it
|
|
142
|
+
was written. Both numbers now have exactly one declaration each, in
|
|
143
|
+
`./anchor-constants`, which is what keeps a schema change and a constructor
|
|
144
|
+
change from drifting apart — but nothing mechanical stops the two from being
|
|
145
|
+
set equal, so the distinction lives here.
|
|
146
|
+
- **Composing an anchor refuses rather than truncates, for the same reason
|
|
147
|
+
resolving it refuses rather than guesses.** `createTextAnchorFromRange` and
|
|
148
|
+
`createTextInsertionAnchorAt` return `null` for a non-integer or out-of-order
|
|
149
|
+
range, a range outside the text, an EMPTY range (the zero-width case has its
|
|
150
|
+
own constructor) and a quote past `QUOTE_MAX_CHARS`. A truncated quote yields
|
|
151
|
+
an anchor that parses, passes the boundary and resolves — onto a range the
|
|
152
|
+
caller never selected. Both validate the object they built through
|
|
153
|
+
`CommentAnchorSchema` before returning it, so the only anchors this package
|
|
154
|
+
emits are anchors the route boundary accepts: an anchor rejected there is a
|
|
155
|
+
comment lost AFTER it was composed.
|
|
156
|
+
- **The relative-position fields are never produced here, and their absence is
|
|
157
|
+
a fact, not an omission.** Encoding a `Y.RelativePosition` needs `yjs`, which
|
|
158
|
+
the vocabulary-guard forbids this package from importing, and it needs a live
|
|
159
|
+
replica besides. A consumer holding a `Y.Text` merges `relStart`/`relEnd` (or
|
|
160
|
+
`relPos`) onto the returned anchor itself — the same seam `./resolve` holds,
|
|
161
|
+
where a consumer composes rung 1 on top of the published text-only ladder.
|
|
55
162
|
- **A comment's `body` is null exactly when `deletedAt` is set, and such a
|
|
56
163
|
comment carries no mentions.** A soft-deleted comment is a REDACTED
|
|
57
164
|
PROJECTION, not a removed row — the tombstone keeps its position in the
|
|
@@ -93,32 +200,68 @@ arbitrate — see the first invariant.
|
|
|
93
200
|
|
|
94
201
|
## Public API
|
|
95
202
|
|
|
96
|
-
| Export | Description
|
|
97
|
-
| --------------------------------- |
|
|
98
|
-
| `CommentAnchorSchema` | `{document,v:1}` \| `{text,v:1,…}` \| `{text-insertion,v:1,…}`, strict
|
|
99
|
-
| `COMMENT_ANCHOR_TYPES` | The anchor discriminants, as a vocabulary tuple
|
|
100
|
-
| `CommentAnchorTypeSchema` | Zod mirror of `COMMENT_ANCHOR_TYPES` — what `anchorType` is checked against
|
|
101
|
-
| `COMMENT_THREAD_STATUSES` | `open` \| `resolved` — there is no third state
|
|
102
|
-
| `CommentThreadStatusSchema` | Zod mirror of `COMMENT_THREAD_STATUSES`
|
|
103
|
-
| `COMMENT_SUBJECT_TYPES` | What a thread can hang off — closed, sized to what admits comments
|
|
104
|
-
| `CommentSubjectTypeSchema` | Zod mirror of `COMMENT_SUBJECT_TYPES`
|
|
105
|
-
| `CommentMentionSchema` | One mention: user id, nullable UTF-16 range, read-time display name
|
|
106
|
-
| `CommentSchema` | One comment; `body` null iff soft-deleted (infers `CommentProjection`)
|
|
107
|
-
| `CommentThreadSummarySchema` | A thread without its comments — the resolve/reopen response
|
|
108
|
-
| `CommentThreadSchema` | A thread with all its comments, oldest-first, tombstones included
|
|
109
|
-
| `CommentThreadListResponseSchema` | `GET /api/comments`
|
|
110
|
-
| `MentionableCandidateSchema` | One offerable mention target — no email, no access level
|
|
111
|
-
| `MentionableResponseSchema` | `GET /api/company-md/docs/{id}/mentionable`
|
|
112
|
-
| `COMMENT_THREAD_KINDS` | `comment` \| `suggestion` — a suggestion IS a comment thread
|
|
113
|
-
| `SUGGESTION_OPS` | `insert` \| `delete` \| `replace` — what a suggestion proposes
|
|
114
|
-
| `SUGGESTION_STATUSES` | `open` \| `accepted` \| `rejected` — the SECOND status level, terminal
|
|
115
|
-
| `SuggestionSchema` | The versioned suggestion payload (`v:1`), strict, `insertedText` optional
|
|
203
|
+
| Export | Description |
|
|
204
|
+
| --------------------------------- | ------------------------------------------------------------------------------ |
|
|
205
|
+
| `CommentAnchorSchema` | `{document,v:1}` \| `{text,v:1,…}` \| `{text-insertion,v:1,…}`, strict |
|
|
206
|
+
| `COMMENT_ANCHOR_TYPES` | The anchor discriminants, as a vocabulary tuple |
|
|
207
|
+
| `CommentAnchorTypeSchema` | Zod mirror of `COMMENT_ANCHOR_TYPES` — what `anchorType` is checked against |
|
|
208
|
+
| `COMMENT_THREAD_STATUSES` | `open` \| `resolved` — there is no third state |
|
|
209
|
+
| `CommentThreadStatusSchema` | Zod mirror of `COMMENT_THREAD_STATUSES` |
|
|
210
|
+
| `COMMENT_SUBJECT_TYPES` | What a thread can hang off — closed, sized to what admits comments |
|
|
211
|
+
| `CommentSubjectTypeSchema` | Zod mirror of `COMMENT_SUBJECT_TYPES` |
|
|
212
|
+
| `CommentMentionSchema` | One mention: user id, nullable UTF-16 range, read-time display name |
|
|
213
|
+
| `CommentSchema` | One comment; `body` null iff soft-deleted (infers `CommentProjection`) |
|
|
214
|
+
| `CommentThreadSummarySchema` | A thread without its comments — the resolve/reopen response |
|
|
215
|
+
| `CommentThreadSchema` | A thread with all its comments, oldest-first, tombstones included |
|
|
216
|
+
| `CommentThreadListResponseSchema` | `GET /api/comments` |
|
|
217
|
+
| `MentionableCandidateSchema` | One offerable mention target — no email, no access level |
|
|
218
|
+
| `MentionableResponseSchema` | `GET /api/company-md/docs/{id}/mentionable` |
|
|
219
|
+
| `COMMENT_THREAD_KINDS` | `comment` \| `suggestion` — a suggestion IS a comment thread |
|
|
220
|
+
| `SUGGESTION_OPS` | `insert` \| `delete` \| `replace` — what a suggestion proposes |
|
|
221
|
+
| `SUGGESTION_STATUSES` | `open` \| `accepted` \| `rejected` — the SECOND status level, terminal |
|
|
222
|
+
| `SuggestionSchema` | The versioned suggestion payload (`v:1`), strict, `insertedText` optional |
|
|
223
|
+
| `ANCHOR_CONTEXT_CHARS` | `32` — how much context a CONSTRUCTOR captures either side of a range |
|
|
224
|
+
| `ANCHOR_AFFIX_MAX_CHARS` | `64` — the largest affix the schema ACCEPTS (prefix/suffix/left/right) |
|
|
225
|
+
| `QUOTE_MAX_CHARS` | `2000` — the largest `quote` a text anchor may carry |
|
|
226
|
+
| `RELATIVE_POSITION_MAX_CHARS` | `512` — cap on one encoded `Y.RelativePosition`, in base64 chars |
|
|
227
|
+
| `resolveAnchorFromText` | Anchor + text → `AnchorResolution`. Total, pure, text-only; never throws |
|
|
228
|
+
| `AnchorResolution` | `resolved{start,end,rung}` \| `document` \| `orphaned` — three outcomes |
|
|
229
|
+
| `findSoleOccurrence` | `absent` \| `unique{index}` \| `ambiguous`; overlaps count, empty is ambiguous |
|
|
230
|
+
| `Occurrence` | The result of `findSoleOccurrence` |
|
|
231
|
+
| `contextsMatchAt` | Do these contexts still flank this point — with the two length guards |
|
|
232
|
+
| `contextBefore` / `contextAfter` | Capture `ANCHOR_CONTEXT_CHARS` of context, never splitting a surrogate pair |
|
|
233
|
+
| `anchorStillReads` | The SECOND assertion: does the resolved place still hold what the anchor says |
|
|
234
|
+
| `plannedEdit` | Suggestion + resolution → the one mutation, or `null` when it would do nothing |
|
|
235
|
+
| `PlannedEdit` | `{at, deleteLength, insert}` — delete then insert, at one point |
|
|
236
|
+
| `createTextAnchorFromRange` | Range → validated text anchor, or `null`; refuses rather than truncates |
|
|
237
|
+
| `createTextInsertionAnchorAt` | Point → validated zero-width anchor, or `null`; no relative position |
|
|
238
|
+
| `TextRange` | `{text, start, end}` — the offsets travel WITH the string they index |
|
|
239
|
+
| `SUGGESTION_RECEIPTS_MAP` | `suggestionReceipts` — the root map key, keyed within by thread id |
|
|
240
|
+
| `SuggestionReceiptSchema` | `{claimToken, appliedAt, byUserId}`, strict; no `v`, and that is deliberate |
|
|
241
|
+
| `SuggestionReceipt` | One applied suggestion's durable proof that its edit landed in the body |
|
|
242
|
+
| `readSuggestionReceipt` | Unknown → receipt or `null`; a malformed row is dropped, never surfaced |
|
|
116
243
|
|
|
117
244
|
## Dependencies
|
|
118
245
|
|
|
119
246
|
- `zod` — schemas are canonical, types are inferred.
|
|
120
|
-
- `./anchor` ← `./schemas`; `./
|
|
121
|
-
|
|
247
|
+
- `./anchor-constants` ← `./anchor` ← `./schemas`; `./anchor-constants` is a
|
|
248
|
+
leaf with no imports at all (four numbers and their rationale), so the bounds
|
|
249
|
+
can be read by a constructor without pulling in the schema. `./resolve` sits
|
|
250
|
+
beside `./schemas` on that chain — it imports `./anchor-constants` for the
|
|
251
|
+
capture width and the anchor union as a TYPE only, so nothing about the
|
|
252
|
+
interpretation can drift from the shape it interprets. `./verify` sits one
|
|
253
|
+
step further out, importing `contextsMatchAt` from `./resolve` at runtime
|
|
254
|
+
rather than re-deriving it — a private copy that forgot a length guard would
|
|
255
|
+
compare against an unrelated substring and answer `true`, which is the one
|
|
256
|
+
wrong answer that mutates the document. `./create` sits beside `./verify`,
|
|
257
|
+
importing `contextBefore`/`contextAfter` from `./resolve` at runtime and
|
|
258
|
+
`CommentAnchorSchema` from `./anchor` — the construction half must capture
|
|
259
|
+
exactly what the resolution half searches for, and validate against exactly
|
|
260
|
+
the schema the boundary applies, so neither is re-derived. `./suggestion` is
|
|
261
|
+
likewise a leaf (zod only) so `./schemas` can import the thread-kind
|
|
262
|
+
projection from it without a cycle. `./receipt` is a leaf for the same
|
|
263
|
+
reason — zod only, no reach for the anchor or the ladder — so anything in this
|
|
264
|
+
directory can import it later without one.
|
|
122
265
|
- Nothing outside this directory is imported: the
|
|
123
266
|
comment vocabulary binds to the existing `commenter` band of
|
|
124
267
|
`../permissions`'s `AccessLevel` and introduces no access level of its own, so
|
|
@@ -33,6 +33,49 @@ text-anchor fields` name the two failure modes the strict variants exist for,
|
|
|
33
33
|
anything the typechecker reaches would fail the build. Reading it keeps the
|
|
34
34
|
fixture inert regardless of which file loads it. `node:fs` is fine in here —
|
|
35
35
|
the vocabulary-guard skips `__tests__/`.
|
|
36
|
+
- **Every `orphaned` expectation in `resolve.test.ts` is a case where a
|
|
37
|
+
FIRST-MATCH resolver returns a well-formed range.** That is the whole point of
|
|
38
|
+
those fixtures: the plausible wrong implementation never throws and never
|
|
39
|
+
looks broken — it produces a confident highlight on a paragraph the comment's
|
|
40
|
+
author never saw. A suite that only asserted the happy path would sit green
|
|
41
|
+
over it, so the refusals are the load-bearing half.
|
|
42
|
+
- **Every `toBeNull()` expectation in `verify.test.ts` is a case where a
|
|
43
|
+
no-op-tolerant `plannedEdit` returns a well-formed edit.** `{ at,
|
|
44
|
+
deleteLength: 0, insert: "" }` is a valid mutation that applies without error
|
|
45
|
+
and changes not one character; its symptom is a thread reported as `accepted`
|
|
46
|
+
over a document that never received the proposal. Same shape of argument as
|
|
47
|
+
the `orphaned` expectations above: the refusals are the load-bearing half.
|
|
48
|
+
- **`verify.test.ts` builds the `document`-anchor resolution BY HAND, and that
|
|
49
|
+
is not a smell.** The shipped ladder answers `{ status: "document" }` for that
|
|
50
|
+
variant and never a `resolved`, so the case is unreachable through
|
|
51
|
+
`resolveAnchorFromText` — but a consumer's own Yjs rung can hand one in, and
|
|
52
|
+
the refusal is what makes that harmless.
|
|
53
|
+
- **`create.test.ts` asserts against the SHIPPED resolver, not against the
|
|
54
|
+
anchor's fields.** A suite that only checked `prefix`/`quote`/`suffix` would
|
|
55
|
+
sit green over a capture width that disagreed with what the resolver searches
|
|
56
|
+
for — the two halves have to agree about the same geometry, and the round-trip
|
|
57
|
+
is the only assertion that says so. Its `toBeNull()` cases are the same shape
|
|
58
|
+
of argument as the `orphaned` ones: the truncating constructor returns a
|
|
59
|
+
well-formed anchor that parses, passes the boundary and resolves onto a range
|
|
60
|
+
the caller never selected.
|
|
61
|
+
- **The surrogate round-trip is built so BOTH context windows land inside an
|
|
62
|
+
astral character.** The prefix window opens on the low half of one emoji and
|
|
63
|
+
the suffix window closes on the high half of another, because a blind slice
|
|
64
|
+
only emits a lone surrogate when the boundary actually falls there — a fixture
|
|
65
|
+
with emoji merely present would pass under the broken implementation.
|
|
66
|
+
- **Every `toBeNull()` in `receipt.test.ts` is a row the PASS-THROUGH parser
|
|
67
|
+
hands back as an object.** `(v) => v as SuggestionReceipt` compiles, is green
|
|
68
|
+
on every well-formed row and never throws; it fails only when a peer writes
|
|
69
|
+
something else into the map, and then a row whose `claimToken` is `undefined`
|
|
70
|
+
reads as "this edit already landed" — the accept control is withdrawn from a
|
|
71
|
+
live suggestion with no way back. The `NaN`/`Infinity` cases are not
|
|
72
|
+
theoretical: a CRDT map replicates raw JavaScript values with no schema of its
|
|
73
|
+
own, so a peer can put a literal `NaN` in `appliedAt`.
|
|
74
|
+
- **`SUGGESTION_RECEIPTS_MAP` is tested by restating its wire literal.** It
|
|
75
|
+
names a root map, so a rename is silent on both ends — the writer's receipt
|
|
76
|
+
lands where nobody reads, the reader watches where nobody writes. Asserting
|
|
77
|
+
`toBe("suggestionReceipts")` makes a rename a red test rather than a second
|
|
78
|
+
name. Same convention as `COMPANY_MD_COLLAB_TEXT_KEY` in `../../org`.
|
|
36
79
|
- **Negative tests mutate ONE field of a well-formed factory result.** A
|
|
37
80
|
hand-built broken object can pass for the wrong reason: it fails because of
|
|
38
81
|
the field nobody was testing.
|
|
@@ -46,4 +89,20 @@ None — test-only.
|
|
|
46
89
|
- `vitest` — `describe` / `it` / `expect`.
|
|
47
90
|
- `node:fs`, `node:path`, `node:url` — to read the corpus and its pin from disk.
|
|
48
91
|
- `node:crypto` — to re-derive the corpus digest.
|
|
49
|
-
- `../anchor`, `../schemas` — the schemas
|
|
92
|
+
- `../anchor`, `../anchor-constants`, `../schemas` — the schemas and bounds
|
|
93
|
+
under test.
|
|
94
|
+
- `../resolve` — the published resolver. `resolve.test.ts` drives it over the
|
|
95
|
+
SAME corpus rows `anchor-corpus.test.ts` re-derives by hand, which is what
|
|
96
|
+
makes that hand-rolled seam scan a cross-check of the shipped implementation
|
|
97
|
+
rather than of itself.
|
|
98
|
+
- `../suggestion`, `../verify` — the canonical payload and the two total
|
|
99
|
+
functions the acceptance path runs before it writes. `verify.test.ts` takes
|
|
100
|
+
its resolutions from the SHIPPED resolver wherever the ladder can produce one,
|
|
101
|
+
so the geometry under test is the geometry a real applier is handed.
|
|
102
|
+
- `../create` — the construction half. `create.test.ts` feeds every constructed
|
|
103
|
+
anchor back through `../resolve`, so it is a cross-check of the two published
|
|
104
|
+
halves against each other rather than of either against a restatement of
|
|
105
|
+
itself.
|
|
106
|
+
- `../receipt` — the application receipt's key, schema and parser.
|
|
107
|
+
`receipt.test.ts` asserts the parser and the schema agree on the same row, so
|
|
108
|
+
a hand-rolled second set of checks beside the schema cannot drift from it.
|