@company-semantics/contracts 45.2.0 → 45.3.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 +9 -2
- package/src/__tests__/resource-keys.test.ts +59 -0
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +93 -2
- package/src/comments/README.md +115 -0
- package/src/comments/__tests__/README.md +49 -0
- package/src/comments/__tests__/anchor-corpus.test.ts +202 -0
- package/src/comments/__tests__/fixtures/README.md +58 -0
- package/src/comments/__tests__/fixtures/comment-anchors.json +94 -0
- package/src/comments/__tests__/fixtures/comment-anchors.provenance.json +9 -0
- package/src/comments/__tests__/schemas.test.ts +274 -0
- package/src/comments/anchor.ts +120 -0
- package/src/comments/index.ts +43 -0
- package/src/comments/schemas.ts +227 -0
- package/src/generated/openapi-routes.ts +1 -0
- package/src/index.ts +42 -0
- package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +4 -0
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +741 -0
- package/src/notifications/__tests__/definition.test.ts +4 -3
- package/src/notifications/__tests__/fixtures.ts +34 -0
- package/src/notifications/__tests__/kinds.test.ts +11 -4
- package/src/notifications/__tests__/registry.test.ts +7 -5
- package/src/notifications/__tests__/render-snapshot.test.ts +36 -0
- package/src/notifications/kinds/comment-mention.ts +82 -0
- package/src/notifications/kinds/comment-reply.ts +79 -0
- package/src/notifications/kinds/index.ts +2 -0
- package/src/notifications/kinds.ts +12 -3
- package/src/notifications/payloads.ts +49 -0
- package/src/notifications/registry.ts +6 -2
- package/src/resource-keys.ts +68 -0
- package/src/user-notifications/kinds.ts +13 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# comments/\_\_tests\_\_/fixtures/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Holds `comment-anchors.json`, the **stable contract corpus** for the comment
|
|
6
|
+
anchor — named accept and reject cases that `CommentAnchorSchema` is run over by
|
|
7
|
+
`../anchor-corpus.test.ts`.
|
|
8
|
+
|
|
9
|
+
The file is a directory of its own rather than a `fixtures.ts` module (the shape
|
|
10
|
+
used elsewhere under `src/`) for one reason: it is **not this repo's file**. It
|
|
11
|
+
is a byte-for-byte copy of
|
|
12
|
+
`company-semantics-backend/tests/fixtures/comment-anchors.json`, and keeping it
|
|
13
|
+
as inert data in a directory of its own is what makes "copied, never edited"
|
|
14
|
+
obvious to the next reader.
|
|
15
|
+
|
|
16
|
+
## Invariants
|
|
17
|
+
|
|
18
|
+
- **The file is copied BYTE-FOR-BYTE from the backend suite.** The backend route
|
|
19
|
+
boundary is the only place a comment anchor is ever validated (it stores
|
|
20
|
+
opaque jsonb and interprets nothing), so the corpus the boundary is tested
|
|
21
|
+
with and the corpus the published contract is tested with must be the same
|
|
22
|
+
bytes. If they were merely "kept in sync by hand", the one risk this whole
|
|
23
|
+
domain exists to manage — silent app/contracts drift on a shape no server
|
|
24
|
+
checks — would be back.
|
|
25
|
+
- **Only ONE direction of that copy is gated, and it is this one.**
|
|
26
|
+
`comment-anchors.provenance.json` pins the copy's sha256 and byte length, and
|
|
27
|
+
`../anchor-corpus.test.ts` re-derives both on every run — so an edit made
|
|
28
|
+
here fails CI. A change made in the BACKEND file is invisible from this repo:
|
|
29
|
+
contracts CI checks out one repo and must not depend on the backend to look.
|
|
30
|
+
`pnpm anchor-corpus:check` diffs the live upstream file when a backend tree is
|
|
31
|
+
reachable and says so when it is not. See ADR-CONTRACTS-116 for where the
|
|
32
|
+
upstream gate belongs (backend CI, via `node_modules`).
|
|
33
|
+
- **Never hand-tune this file.** Change the anchor schema and the corpus
|
|
34
|
+
together, in the backend, then re-copy with `pnpm anchor-corpus:sync`, which
|
|
35
|
+
rewrites the pin in the same step. Editing this side to make a test pass fails
|
|
36
|
+
the pin; editing it and re-pinning by hand defeats the check, and shows up as
|
|
37
|
+
exactly that in the diff.
|
|
38
|
+
- **It is listed in `/.prettierignore`.** Prettier reflows the long anchor
|
|
39
|
+
objects, which would break the byte identity (and fail the pin). The file
|
|
40
|
+
carries the backend's formatting, not this repo's. The pin beside it is NOT
|
|
41
|
+
ignored — it is this repo's file and Prettier owns its formatting.
|
|
42
|
+
- **Cases are addressed by `name`, not by index.** Both tests iterate the
|
|
43
|
+
`accept` and `reject` arrays and use each case's `name` as its test label, so
|
|
44
|
+
a case added upstream is covered here the moment it is copied over, with no
|
|
45
|
+
edit to the test.
|
|
46
|
+
|
|
47
|
+
## Public API
|
|
48
|
+
|
|
49
|
+
None — test-only fixture data. `comment-anchors.provenance.json` is generated;
|
|
50
|
+
regenerate it with `pnpm anchor-corpus:sync` rather than editing it.
|
|
51
|
+
|
|
52
|
+
## Dependencies
|
|
53
|
+
|
|
54
|
+
- Upstream source: `company-semantics-backend/tests/fixtures/comment-anchors.json`
|
|
55
|
+
(authored alongside the anchor boundary; see backend ADR slug
|
|
56
|
+
`polymorphic-comment-threads`).
|
|
57
|
+
- `scripts/anchor-corpus.ts` — writes the pin (`--sync`) and verifies it
|
|
58
|
+
(`--check`, the default).
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "STABLE CONTRACT CORPUS for the comment-anchor route boundary (PRD-00920 / ADR-BE-512 §4). Named accept/reject cases for CommentAnchorSchema (src/api/http/routes/comments/comments.schemas.ts). PRD-00922 copies this file into @company-semantics/contracts and mechanically diffs the two copies, so schema drift between the backend boundary and the published contract is caught by a file comparison. Change cases here only together with the schema; never hand-tune one side.",
|
|
3
|
+
"accept": [
|
|
4
|
+
{
|
|
5
|
+
"name": "document-anchor",
|
|
6
|
+
"anchor": { "type": "document", "v": 1 }
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"name": "text-anchor-with-relative-positions",
|
|
10
|
+
"anchor": {
|
|
11
|
+
"type": "text",
|
|
12
|
+
"v": 1,
|
|
13
|
+
"relStart": "AAECAwQ=",
|
|
14
|
+
"relEnd": "BQYHCAk=",
|
|
15
|
+
"quote": "ship the quarterly goals",
|
|
16
|
+
"prefix": "agreed to ",
|
|
17
|
+
"suffix": " by March"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"name": "text-anchor-quote-only",
|
|
22
|
+
"anchor": {
|
|
23
|
+
"type": "text",
|
|
24
|
+
"v": 1,
|
|
25
|
+
"quote": "ship the quarterly goals",
|
|
26
|
+
"prefix": "",
|
|
27
|
+
"suffix": ""
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"reject": [
|
|
32
|
+
{
|
|
33
|
+
"name": "text-anchor-missing-quote",
|
|
34
|
+
"anchor": { "type": "text", "v": 1, "prefix": "agreed to ", "suffix": " by March" }
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "document-anchor-carrying-text-fields",
|
|
38
|
+
"anchor": {
|
|
39
|
+
"type": "document",
|
|
40
|
+
"v": 1,
|
|
41
|
+
"quote": "stray quote",
|
|
42
|
+
"prefix": "",
|
|
43
|
+
"suffix": ""
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"name": "unknown-version-document",
|
|
48
|
+
"anchor": { "type": "document", "v": 2 }
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "unknown-version-text",
|
|
52
|
+
"anchor": { "type": "text", "v": 2, "quote": "q", "prefix": "", "suffix": "" }
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"name": "unknown-anchor-type",
|
|
56
|
+
"anchor": { "type": "region", "v": 1 }
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"name": "oversized-prefix",
|
|
60
|
+
"anchor": {
|
|
61
|
+
"type": "text",
|
|
62
|
+
"v": 1,
|
|
63
|
+
"quote": "q",
|
|
64
|
+
"prefix": "ppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp",
|
|
65
|
+
"suffix": ""
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"name": "oversized-suffix",
|
|
70
|
+
"anchor": {
|
|
71
|
+
"type": "text",
|
|
72
|
+
"v": 1,
|
|
73
|
+
"quote": "q",
|
|
74
|
+
"prefix": "",
|
|
75
|
+
"suffix": "sssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss"
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"name": "empty-quote",
|
|
80
|
+
"anchor": { "type": "text", "v": 1, "quote": "", "prefix": "", "suffix": "" }
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"name": "relstart-not-base64",
|
|
84
|
+
"anchor": {
|
|
85
|
+
"type": "text",
|
|
86
|
+
"v": 1,
|
|
87
|
+
"relStart": "not base64!!",
|
|
88
|
+
"quote": "q",
|
|
89
|
+
"prefix": "",
|
|
90
|
+
"suffix": ""
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Provenance pin for ./comment-anchors.json, which is a byte-for-byte copy of company-semantics-backend/tests/fixtures/comment-anchors.json (ADR-CONTRACTS-116). anchor-corpus.test.ts re-derives this digest from the file's bytes on every run, so an edit made on THIS side fails CI instead of quietly making the corpus this repo's own. Drift introduced on the BACKEND side is not observable from contracts CI and is not gated here. Regenerate with `pnpm anchor-corpus:sync`; never hand-edit this file or the corpus.",
|
|
3
|
+
"source": {
|
|
4
|
+
"repo": "company-semantics-backend",
|
|
5
|
+
"path": "tests/fixtures/comment-anchors.json"
|
|
6
|
+
},
|
|
7
|
+
"bytes": 2649,
|
|
8
|
+
"sha256": "84fa0040c9acc166a5551185950e5dc8afffcc5fd63a1f17080422318b405c8c"
|
|
9
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The thread / comment / mention projections, held to the invariants their
|
|
3
|
+
* JSDoc claims (ADR-CONTRACTS-116).
|
|
4
|
+
*
|
|
5
|
+
* Every negative test mutates ONE field of a well-formed factory result. A
|
|
6
|
+
* hand-built broken object can pass for the wrong reason — it fails on the
|
|
7
|
+
* field nobody was testing — and these shapes have enough required fields for
|
|
8
|
+
* that to happen easily.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
COMMENT_SUBJECT_TYPES,
|
|
14
|
+
COMMENT_THREAD_STATUSES,
|
|
15
|
+
CommentMentionSchema,
|
|
16
|
+
CommentSchema,
|
|
17
|
+
CommentThreadListResponseSchema,
|
|
18
|
+
CommentThreadSchema,
|
|
19
|
+
CommentThreadStatusSchema,
|
|
20
|
+
MentionableResponseSchema,
|
|
21
|
+
} from "../schemas.js";
|
|
22
|
+
|
|
23
|
+
const THREAD_ID = "11111111-1111-4111-8111-111111111111";
|
|
24
|
+
const COMMENT_ID = "22222222-2222-4222-8222-222222222222";
|
|
25
|
+
const USER_ID = "33333333-3333-4333-8333-333333333333";
|
|
26
|
+
const DOC_ID = "44444444-4444-4444-8444-444444444444";
|
|
27
|
+
|
|
28
|
+
function makeMention(over: Record<string, unknown> = {}) {
|
|
29
|
+
return {
|
|
30
|
+
userId: USER_ID,
|
|
31
|
+
startOffset: 0,
|
|
32
|
+
endOffset: 9,
|
|
33
|
+
displayName: "Sam Chen",
|
|
34
|
+
...over,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeComment(over: Record<string, unknown> = {}) {
|
|
39
|
+
return {
|
|
40
|
+
id: COMMENT_ID,
|
|
41
|
+
threadId: THREAD_ID,
|
|
42
|
+
authorUserId: USER_ID,
|
|
43
|
+
body: "Should this say Q3 or Q4?",
|
|
44
|
+
editedAt: null,
|
|
45
|
+
deletedAt: null,
|
|
46
|
+
createdAt: "2026-08-01T12:00:00.000Z",
|
|
47
|
+
mentions: [],
|
|
48
|
+
...over,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeThread(over: Record<string, unknown> = {}) {
|
|
53
|
+
return {
|
|
54
|
+
id: THREAD_ID,
|
|
55
|
+
subjectType: "company_md",
|
|
56
|
+
subjectId: DOC_ID,
|
|
57
|
+
anchorType: "text",
|
|
58
|
+
anchor: {
|
|
59
|
+
type: "text",
|
|
60
|
+
v: 1,
|
|
61
|
+
quote: "ship the quarterly goals",
|
|
62
|
+
prefix: "agreed to ",
|
|
63
|
+
suffix: " by March",
|
|
64
|
+
},
|
|
65
|
+
status: "open",
|
|
66
|
+
createdByUserId: USER_ID,
|
|
67
|
+
resolvedByUserId: null,
|
|
68
|
+
resolvedAt: null,
|
|
69
|
+
createdAt: "2026-08-01T12:00:00.000Z",
|
|
70
|
+
updatedAt: "2026-08-01T12:00:00.000Z",
|
|
71
|
+
comments: [makeComment()],
|
|
72
|
+
...over,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("CommentThreadStatus", () => {
|
|
77
|
+
it("is exactly open and resolved", () => {
|
|
78
|
+
expect([...COMMENT_THREAD_STATUSES]).toEqual(["open", "resolved"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("rejects a thread status outside the enum", () => {
|
|
82
|
+
// Notably `deleted`: a thread whose comments are all soft-deleted is still
|
|
83
|
+
// an ordinary resolvable thread, and inventing a third state client-side
|
|
84
|
+
// would give the resolve/reopen transition somewhere it can never return
|
|
85
|
+
// from.
|
|
86
|
+
expect(CommentThreadStatusSchema.safeParse("deleted").success).toBe(false);
|
|
87
|
+
expect(CommentThreadStatusSchema.safeParse("").success).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("rejects a thread carrying a status outside the enum", () => {
|
|
91
|
+
expect(
|
|
92
|
+
CommentThreadSchema.safeParse(makeThread({ status: "archived" })).success,
|
|
93
|
+
).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("CommentSubjectType", () => {
|
|
98
|
+
it("is closed, not a free string", () => {
|
|
99
|
+
// Sized to the ACL entity types that admit comments. A free string here
|
|
100
|
+
// would let a client address a subject class the server has no policy for
|
|
101
|
+
// and read the 404 as "not found" rather than "never supported".
|
|
102
|
+
expect([...COMMENT_SUBJECT_TYPES]).toEqual([
|
|
103
|
+
"company_md",
|
|
104
|
+
"strategy_doc",
|
|
105
|
+
"work_item",
|
|
106
|
+
"meeting_recording",
|
|
107
|
+
]);
|
|
108
|
+
expect(
|
|
109
|
+
CommentThreadSchema.safeParse(makeThread({ subjectType: "chat" }))
|
|
110
|
+
.success,
|
|
111
|
+
).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("CommentSchema and the redaction invariant", () => {
|
|
116
|
+
it("parses a well-formed comment", () => {
|
|
117
|
+
expect(CommentSchema.safeParse(makeComment()).success).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("parses a soft-deleted comment projection with a null body", () => {
|
|
121
|
+
// THE TOMBSTONE. A soft-deleted comment is a REDACTED PROJECTION, not a
|
|
122
|
+
// removed row: body null, mentions empty, everything else retained. If this
|
|
123
|
+
// stops parsing, a single deleted comment fails the whole thread load —
|
|
124
|
+
// and the tombstone is what keeps the surrounding replies in order and
|
|
125
|
+
// keeps an all-deleted thread distinguishable from an empty one.
|
|
126
|
+
const parsed = CommentSchema.safeParse(
|
|
127
|
+
makeComment({
|
|
128
|
+
body: null,
|
|
129
|
+
deletedAt: "2026-08-02T09:00:00.000Z",
|
|
130
|
+
mentions: [],
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
expect(parsed.success).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("parses an unattributed comment — a tombstoned author is not an error", () => {
|
|
137
|
+
expect(
|
|
138
|
+
CommentSchema.safeParse(makeComment({ authorUserId: null })).success,
|
|
139
|
+
).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("requires the mentions array — absent is not the same as empty", () => {
|
|
143
|
+
const { mentions: _dropped, ...withoutMentions } = makeComment();
|
|
144
|
+
expect(CommentSchema.safeParse(withoutMentions).success).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("CommentMentionSchema offsets", () => {
|
|
149
|
+
it("parses a mention whose range is null", () => {
|
|
150
|
+
// Null on every row today — `comment_mentions` has no offset columns yet.
|
|
151
|
+
// A client must render these as "highlight nothing", so they must parse.
|
|
152
|
+
expect(
|
|
153
|
+
CommentMentionSchema.safeParse(
|
|
154
|
+
makeMention({ startOffset: null, endOffset: null }),
|
|
155
|
+
).success,
|
|
156
|
+
).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("measures in UTF-16 CODE UNITS, so an emoji costs two", () => {
|
|
160
|
+
// The convention, pinned by arithmetic rather than by comment. `"🎉 hi "`
|
|
161
|
+
// is SIX code units — the emoji is a surrogate pair — so the mention that
|
|
162
|
+
// follows it starts at 6. A client measuring in code points would compute 5
|
|
163
|
+
// and highlight one character to the left, and would keep sliding by one
|
|
164
|
+
// per preceding emoji. Nothing normalizes the body on this path, which is
|
|
165
|
+
// what makes the count on the wire the same count a client can reproduce.
|
|
166
|
+
const body = "🎉 hi @Sam Chen";
|
|
167
|
+
expect(body.length).toBe(15);
|
|
168
|
+
const start = body.indexOf("@");
|
|
169
|
+
expect(start).toBe(6);
|
|
170
|
+
|
|
171
|
+
const comment = CommentSchema.safeParse(
|
|
172
|
+
makeComment({
|
|
173
|
+
body,
|
|
174
|
+
mentions: [makeMention({ startOffset: start, endOffset: body.length })],
|
|
175
|
+
}),
|
|
176
|
+
);
|
|
177
|
+
expect(comment.success).toBe(true);
|
|
178
|
+
// endOffset is EXCLUSIVE: slicing [start, end) yields exactly the mention.
|
|
179
|
+
expect(body.slice(start, body.length)).toBe("@Sam Chen");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("rejects a negative offset", () => {
|
|
183
|
+
expect(
|
|
184
|
+
CommentMentionSchema.safeParse(makeMention({ startOffset: -1 })).success,
|
|
185
|
+
).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("rejects a fractional offset", () => {
|
|
189
|
+
// A code-unit index is an integer by construction; a fraction means someone
|
|
190
|
+
// measured in something else.
|
|
191
|
+
expect(
|
|
192
|
+
CommentMentionSchema.safeParse(makeMention({ endOffset: 2.5 })).success,
|
|
193
|
+
).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("CommentThreadSchema", () => {
|
|
198
|
+
it("parses a well-formed thread with its comments", () => {
|
|
199
|
+
expect(CommentThreadSchema.safeParse(makeThread()).success).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("parses a document-anchored thread", () => {
|
|
203
|
+
expect(
|
|
204
|
+
CommentThreadSchema.safeParse(
|
|
205
|
+
makeThread({
|
|
206
|
+
anchorType: "document",
|
|
207
|
+
anchor: { type: "document", v: 1 },
|
|
208
|
+
}),
|
|
209
|
+
).success,
|
|
210
|
+
).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("rejects a thread whose anchor is invalid", () => {
|
|
214
|
+
// The thread is the only place an anchor reaches a client, so a thread must
|
|
215
|
+
// not parse around a broken one — that would hand the app an anchor it
|
|
216
|
+
// cannot resolve while telling it the response was fine.
|
|
217
|
+
expect(
|
|
218
|
+
CommentThreadSchema.safeParse(
|
|
219
|
+
makeThread({ anchor: { type: "text", v: 1, prefix: "", suffix: "" } }),
|
|
220
|
+
).success,
|
|
221
|
+
).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("keeps tombstones in the comment list rather than filtering them", () => {
|
|
225
|
+
const thread = CommentThreadSchema.parse(
|
|
226
|
+
makeThread({
|
|
227
|
+
comments: [
|
|
228
|
+
makeComment({
|
|
229
|
+
id: "55555555-5555-4555-8555-555555555555",
|
|
230
|
+
body: null,
|
|
231
|
+
deletedAt: "2026-08-02T09:00:00.000Z",
|
|
232
|
+
}),
|
|
233
|
+
makeComment(),
|
|
234
|
+
],
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
expect(thread.comments).toHaveLength(2);
|
|
238
|
+
expect(thread.comments[0].body).toBeNull();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe("CommentThreadListResponseSchema", () => {
|
|
243
|
+
it("parses an empty subject", () => {
|
|
244
|
+
expect(
|
|
245
|
+
CommentThreadListResponseSchema.safeParse({ threads: [] }).success,
|
|
246
|
+
).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("parses a subject with threads", () => {
|
|
250
|
+
expect(
|
|
251
|
+
CommentThreadListResponseSchema.safeParse({ threads: [makeThread()] })
|
|
252
|
+
.success,
|
|
253
|
+
).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("MentionableResponseSchema", () => {
|
|
258
|
+
it("parses a candidate list", () => {
|
|
259
|
+
expect(
|
|
260
|
+
MentionableResponseSchema.safeParse({
|
|
261
|
+
items: [{ userId: USER_ID, displayName: "Sam Chen", avatarUrl: null }],
|
|
262
|
+
}).success,
|
|
263
|
+
).toBe(true);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("carries no access level — a picker is not an access-inspection surface", () => {
|
|
267
|
+
// The candidates ARE the document's readers. Returning the band each one
|
|
268
|
+
// holds would turn an autocomplete into a way to enumerate who can do what,
|
|
269
|
+
// so the field must stay absent from the shape rather than merely unset.
|
|
270
|
+
expect(
|
|
271
|
+
Object.keys(MentionableResponseSchema.shape.items.element.shape).sort(),
|
|
272
|
+
).toEqual(["avatarUrl", "displayName", "userId"]);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The comment anchor — where on a subject a thread hangs (ADR-CONTRACTS-116).
|
|
3
|
+
*
|
|
4
|
+
* THE ANCHOR IS THE CONTRACT BETWEEN THE CLIENT THAT WRITES IT AND THE CLIENT
|
|
5
|
+
* THAT LATER RESOLVES IT. The backend is a PASSTHROUGH: it validates this shape
|
|
6
|
+
* at the route boundary, stores the result as opaque jsonb, and interprets
|
|
7
|
+
* nothing (backend ADR slug polymorphic-comment-threads §4). So a disagreement
|
|
8
|
+
* between the writing client and the resolving client about what these fields
|
|
9
|
+
* MEAN produces silently mis-placed comments with no server-side tripwire — the
|
|
10
|
+
* write succeeds, the read succeeds, and the highlight lands on the wrong text.
|
|
11
|
+
* That is why the shape is published here rather than declared app-locally.
|
|
12
|
+
*
|
|
13
|
+
* `v` is the evolution hatch and the only thing that bounds the risk above: a
|
|
14
|
+
* client that meets an anchor it cannot resolve sees an unfamiliar `v` and can
|
|
15
|
+
* degrade deliberately, instead of guessing at fields it half-recognises. New
|
|
16
|
+
* anchor shapes therefore arrive as new `v` variants, never as a migration of an
|
|
17
|
+
* existing one.
|
|
18
|
+
*
|
|
19
|
+
* Zod-canonical: the schema is the source of truth, the type is inferred.
|
|
20
|
+
*
|
|
21
|
+
* MIRRORED, NOT INVENTED. Every bound below is the backend route boundary's
|
|
22
|
+
* (`src/api/http/routes/comments/comments.schemas.ts`), and the accept/reject
|
|
23
|
+
* corpus at `./__tests__/fixtures/comment-anchors.json` is copied byte-for-byte
|
|
24
|
+
* from that suite's fixtures. Editing that copy HERE fails the anchor suite —
|
|
25
|
+
* its digest is pinned beside it. A change made at the BOUNDARY is not caught
|
|
26
|
+
* from this repo; a single-repo CI run cannot see that file, and this package
|
|
27
|
+
* must not depend on the backend to look. ADR-CONTRACTS-116 records which half
|
|
28
|
+
* is gated and where the missing gate belongs.
|
|
29
|
+
*/
|
|
30
|
+
import { z } from "zod";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A base64 `Y.RelativePosition` payload, encoded by the client.
|
|
34
|
+
*
|
|
35
|
+
* Bounded so an anchor can never smuggle document-scale content through a field
|
|
36
|
+
* nothing on the server reads. The backend never decodes it — the bound and the
|
|
37
|
+
* charset are the only things anyone checks, so they have to be checked here
|
|
38
|
+
* too or a client can write a position no other client can decode.
|
|
39
|
+
*/
|
|
40
|
+
const RelativePositionSchema = z
|
|
41
|
+
.string()
|
|
42
|
+
.min(1)
|
|
43
|
+
.max(512)
|
|
44
|
+
.regex(/^[A-Za-z0-9+/]+={0,2}$/, "must be base64");
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The thread hangs off the subject AS A WHOLE — a comment on the document, not
|
|
48
|
+
* on a passage in it.
|
|
49
|
+
*
|
|
50
|
+
* Strict: a document anchor carrying `quote`/`prefix`/`relStart` is refused
|
|
51
|
+
* rather than silently accepted with the extra fields ignored. A client that
|
|
52
|
+
* sends those meant to anchor to text and got the discriminator wrong; storing
|
|
53
|
+
* the mistake would produce a thread that can never be placed.
|
|
54
|
+
*
|
|
55
|
+
* SPELLING DEVIATION FROM THE AUTHORITATIVE REFERENCE (PRD-00922, must_log).
|
|
56
|
+
* The reference block writes this variant as
|
|
57
|
+
* `z.object({ type: z.literal('document'), v: z.literal(1) }).strict()`.
|
|
58
|
+
* What ships below is the same schema in two different spellings, not a
|
|
59
|
+
* different schema: `z.strictObject` is zod v4's idiom for `z.object().strict()`,
|
|
60
|
+
* and the double quotes are this repo's Prettier default (there is no
|
|
61
|
+
* `.prettierrc`, so `singleQuote` is false and `pnpm format:check` rewrites any
|
|
62
|
+
* single-quoted string here). Recorded because the reference is marked
|
|
63
|
+
* authoritative, and a reader diffing the two should be able to tell at a glance
|
|
64
|
+
* that nothing about what is accepted or refused changed.
|
|
65
|
+
*/
|
|
66
|
+
const DocumentAnchorSchema = z.strictObject({
|
|
67
|
+
type: z.literal("document"),
|
|
68
|
+
v: z.literal(1),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A text-anchored thread.
|
|
73
|
+
*
|
|
74
|
+
* `quote` is REQUIRED and is the durable fallback — it is what a resolving
|
|
75
|
+
* client re-finds the passage by when the relative positions no longer resolve,
|
|
76
|
+
* and it is also the composer's `Comment on "…"` label. An anchor with no quote
|
|
77
|
+
* is unplaceable the first time the document is edited, so it is refused.
|
|
78
|
+
*
|
|
79
|
+
* `relStart`/`relEnd` are OPTIONAL by design: a comment authored from a card or
|
|
80
|
+
* a list view has no attached collab session to encode a position from, and has
|
|
81
|
+
* only the quote path. Optional here means "this client could not produce one",
|
|
82
|
+
* never "this client chose not to".
|
|
83
|
+
*
|
|
84
|
+
* `prefix`/`suffix` disambiguate a quote that appears more than once. They are
|
|
85
|
+
* required-but-may-be-empty rather than optional, so "there is no surrounding
|
|
86
|
+
* context" and "this client forgot to send it" stay distinguishable.
|
|
87
|
+
*/
|
|
88
|
+
const TextAnchorSchema = z.strictObject({
|
|
89
|
+
type: z.literal("text"),
|
|
90
|
+
v: z.literal(1),
|
|
91
|
+
relStart: RelativePositionSchema.optional(),
|
|
92
|
+
relEnd: RelativePositionSchema.optional(),
|
|
93
|
+
quote: z.string().min(1).max(2000),
|
|
94
|
+
prefix: z.string().max(64),
|
|
95
|
+
suffix: z.string().max(64),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The anchor types, as a vocabulary tuple.
|
|
100
|
+
*
|
|
101
|
+
* Mirrors the union's discriminants, and is what `CommentThreadSummarySchema`'s
|
|
102
|
+
* denormalised `anchorType` column is checked against — the server projects the
|
|
103
|
+
* discriminant out of the jsonb so a client can filter threads without parsing
|
|
104
|
+
* every anchor.
|
|
105
|
+
*/
|
|
106
|
+
export const COMMENT_ANCHOR_TYPES = ["document", "text"] as const;
|
|
107
|
+
export const CommentAnchorTypeSchema = z.enum(COMMENT_ANCHOR_TYPES);
|
|
108
|
+
export type CommentAnchorType = z.infer<typeof CommentAnchorTypeSchema>;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The opaque, versioned anchor payload.
|
|
112
|
+
*
|
|
113
|
+
* Discriminated on `type` with STRICT variants, which is what makes the two
|
|
114
|
+
* failure modes above real refusals rather than silent coercions.
|
|
115
|
+
*/
|
|
116
|
+
export const CommentAnchorSchema = z.discriminatedUnion("type", [
|
|
117
|
+
DocumentAnchorSchema,
|
|
118
|
+
TextAnchorSchema,
|
|
119
|
+
]);
|
|
120
|
+
export type CommentAnchor = z.infer<typeof CommentAnchorSchema>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* comments/ — the published wire contract for comment threads: the anchor
|
|
3
|
+
* union, the thread/comment/mention read projections, and the mention picker's
|
|
4
|
+
* response.
|
|
5
|
+
*
|
|
6
|
+
* See ./README.md for the domain, and ADR-CONTRACTS-116 for why each of these
|
|
7
|
+
* meets the promotion rule — and for what is deliberately NOT here (request
|
|
8
|
+
* bodies, per ADR-CONT-029).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
COMMENT_ANCHOR_TYPES,
|
|
13
|
+
CommentAnchorTypeSchema,
|
|
14
|
+
CommentAnchorSchema,
|
|
15
|
+
} from "./anchor";
|
|
16
|
+
|
|
17
|
+
export type { CommentAnchor, CommentAnchorType } from "./anchor";
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
COMMENT_SUBJECT_TYPES,
|
|
21
|
+
COMMENT_THREAD_STATUSES,
|
|
22
|
+
CommentMentionSchema,
|
|
23
|
+
CommentSchema,
|
|
24
|
+
CommentSubjectTypeSchema,
|
|
25
|
+
CommentThreadListResponseSchema,
|
|
26
|
+
CommentThreadSchema,
|
|
27
|
+
CommentThreadStatusSchema,
|
|
28
|
+
CommentThreadSummarySchema,
|
|
29
|
+
MentionableCandidateSchema,
|
|
30
|
+
MentionableResponseSchema,
|
|
31
|
+
} from "./schemas";
|
|
32
|
+
|
|
33
|
+
export type {
|
|
34
|
+
CommentMention,
|
|
35
|
+
CommentProjection,
|
|
36
|
+
CommentSubjectType,
|
|
37
|
+
CommentThread,
|
|
38
|
+
CommentThreadListResponse,
|
|
39
|
+
CommentThreadStatus,
|
|
40
|
+
CommentThreadSummary,
|
|
41
|
+
MentionableCandidate,
|
|
42
|
+
MentionableResponse,
|
|
43
|
+
} from "./schemas";
|