@company-semantics/contracts 51.1.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.
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The anchor size bounds, published once.
3
+ *
4
+ * CAPTURE AND ACCEPT ARE DIFFERENT NUMBERS AND MUST STAY THAT WAY. A
5
+ * constructor captures ANCHOR_CONTEXT_CHARS of surrounding text; the schema
6
+ * ACCEPTS up to ANCHOR_AFFIX_MAX_CHARS, because an anchor written by an older
7
+ * client (or a future one with a wider capture) must still parse. Collapsing
8
+ * them would silently reject valid stored anchors.
9
+ *
10
+ * These are the SAME numbers `CommentAnchorSchema` reads — that schema carries
11
+ * no inline size literal, so a bound can no longer be changed in one of the two
12
+ * places. They are published rather than kept file-local because the writing
13
+ * client and the resolving client are independent implementations with no
14
+ * server-side arbiter between them (ADR-CONTRACTS-116): a constructor that
15
+ * captures more than the schema accepts composes an anchor the route boundary
16
+ * then refuses, losing a comment after it was written.
17
+ *
18
+ * Measured in UTF-16 code units (plain JavaScript `String#length`), like every
19
+ * other offset on the comment path. No Unicode normalization happens anywhere
20
+ * here, so a two-unit emoji costs two.
21
+ */
22
+
23
+ /**
24
+ * How much surrounding text a CONSTRUCTOR captures either side of a range —
25
+ * `prefix`/`suffix` on a text anchor, `leftContext`/`rightContext` on a
26
+ * text-insertion point.
27
+ *
28
+ * Deliberately SMALLER than {@link ANCHOR_AFFIX_MAX_CHARS}, and the gap is the
29
+ * point: this is a write-side choice that may change, while the accept bound is
30
+ * a promise made to every anchor already in storage.
31
+ */
32
+ export const ANCHOR_CONTEXT_CHARS = 32;
33
+
34
+ /**
35
+ * The largest affix `CommentAnchorSchema` will ACCEPT — `prefix`, `suffix`,
36
+ * `leftContext` and `rightContext` alike.
37
+ *
38
+ * Not redundant with {@link ANCHOR_CONTEXT_CHARS}: narrowing this to the
39
+ * capture width would refuse anchors written by a client that captured more,
40
+ * and those anchors are already durable jsonb the server never re-validates.
41
+ */
42
+ export const ANCHOR_AFFIX_MAX_CHARS = 64;
43
+
44
+ /**
45
+ * The largest `quote` a text anchor may carry.
46
+ *
47
+ * The quote is the durable fallback the passage is re-found by, so it is bounded
48
+ * generously — but bounded, because an anchor must never become a second copy of
49
+ * the document in a column nothing on the server reads.
50
+ */
51
+ export const QUOTE_MAX_CHARS = 2000;
52
+
53
+ /**
54
+ * Cap on ONE encoded `Y.RelativePosition` payload, in base64 chars — `relStart`,
55
+ * `relEnd` and `relPos` alike.
56
+ *
57
+ * The backend never decodes these; the bound and the base64 charset are the only
58
+ * things anyone checks, which is why both have to be checked at the boundary or
59
+ * a client can write a position no other client can decode.
60
+ */
61
+ export const RELATIVE_POSITION_MAX_CHARS = 512;
@@ -19,7 +19,9 @@
19
19
  * Zod-canonical: the schema is the source of truth, the type is inferred.
20
20
  *
21
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
22
+ * (`src/api/http/routes/comments/comments.schemas.ts`) they are named in
23
+ * `./anchor-constants` and read from there, so this file carries no size
24
+ * literal a constructor could drift away from. The accept/reject
23
25
  * corpus at `./__tests__/fixtures/comment-anchors.json` is copied byte-for-byte
24
26
  * from that suite's fixtures. Editing that copy HERE fails the anchor suite —
25
27
  * its digest is pinned beside it. A change made at the BOUNDARY is not caught
@@ -29,6 +31,12 @@
29
31
  */
30
32
  import { z } from "zod";
31
33
 
34
+ import {
35
+ ANCHOR_AFFIX_MAX_CHARS,
36
+ QUOTE_MAX_CHARS,
37
+ RELATIVE_POSITION_MAX_CHARS,
38
+ } from "./anchor-constants";
39
+
32
40
  /**
33
41
  * A base64 `Y.RelativePosition` payload, encoded by the client.
34
42
  *
@@ -40,7 +48,7 @@ import { z } from "zod";
40
48
  const RelativePositionSchema = z
41
49
  .string()
42
50
  .min(1)
43
- .max(512)
51
+ .max(RELATIVE_POSITION_MAX_CHARS)
44
52
  .regex(/^[A-Za-z0-9+/]+={0,2}$/, "must be base64");
45
53
 
46
54
  /**
@@ -90,9 +98,9 @@ const TextAnchorSchema = z.strictObject({
90
98
  v: z.literal(1),
91
99
  relStart: RelativePositionSchema.optional(),
92
100
  relEnd: RelativePositionSchema.optional(),
93
- quote: z.string().min(1).max(2000),
94
- prefix: z.string().max(64),
95
- suffix: z.string().max(64),
101
+ quote: z.string().min(1).max(QUOTE_MAX_CHARS),
102
+ prefix: z.string().max(ANCHOR_AFFIX_MAX_CHARS),
103
+ suffix: z.string().max(ANCHOR_AFFIX_MAX_CHARS),
96
104
  });
97
105
 
98
106
  /**
@@ -110,10 +118,11 @@ const TextAnchorSchema = z.strictObject({
110
118
  *
111
119
  * `leftContext`/`rightContext` are the durable fallback — the text
112
120
  * immediately before and after the point. They are required-but-may-be-empty
113
- * (max 64 UTF-16 code units each, like `prefix`/`suffix`): either may be
114
- * empty or short at a document edge, and an empty string means "there is
115
- * nothing on that side", which must stay distinguishable from "this client
116
- * forgot to send it". No Unicode normalization happens anywhere on this path.
121
+ * (max {@link ANCHOR_AFFIX_MAX_CHARS} UTF-16 code units each, like
122
+ * `prefix`/`suffix`): either may be empty or short at a document edge, and an
123
+ * empty string means "there is nothing on that side", which must stay
124
+ * distinguishable from "this client forgot to send it". No Unicode
125
+ * normalization happens anywhere on this path.
117
126
  *
118
127
  * RESOLUTION SEMANTICS (implemented client-side in the app; the backend is a
119
128
  * passthrough that interprets nothing):
@@ -144,8 +153,8 @@ const TextInsertionAnchorSchema = z.strictObject({
144
153
  type: z.literal("text-insertion"),
145
154
  v: z.literal(1),
146
155
  relPos: RelativePositionSchema.optional(),
147
- leftContext: z.string().max(64),
148
- rightContext: z.string().max(64),
156
+ leftContext: z.string().max(ANCHOR_AFFIX_MAX_CHARS),
157
+ rightContext: z.string().max(ANCHOR_AFFIX_MAX_CHARS),
149
158
  });
150
159
 
151
160
  /**
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Composing the anchor — the WRITE half of the contract `./resolve` reads
3
+ * (ADR-CONTRACTS-116, ADR-CONTRACTS-127).
4
+ *
5
+ * WHY BOTH DIRECTIONS ARE PUBLISHED, NOT JUST ONE. Publishing anchor → text
6
+ * while leaving text → anchor browser-only would be half an extraction: the
7
+ * side that COMPOSES an anchor is the side that decides how much context to
8
+ * capture and what counts as a quote, and a second description of that is the
9
+ * same silent-drift hazard the resolver was published to end. A constructor
10
+ * that captured more context than {@link CommentAnchorSchema} accepts composes
11
+ * an anchor the route boundary then refuses — a comment lost after it was
12
+ * written — and one that captured a DIFFERENT amount composes an anchor that
13
+ * parses and then resolves somewhere else.
14
+ *
15
+ * REFUSE RATHER THAN GUESS, in the same direction as the ladder. Range → anchor
16
+ * is a write capability and inherits the ladder's discipline exactly: every
17
+ * degenerate input returns `null` rather than a plausible anchor. Truncating an
18
+ * over-long quote is the sharpest case — the resulting anchor is well-formed,
19
+ * parses, resolves, and highlights a range the caller never selected. `null`
20
+ * means "compose no comment", which the caller can report; a truncated quote
21
+ * means "compose a comment about something else", which nobody can.
22
+ *
23
+ * VALIDATED BEFORE IT IS RETURNED. Both constructors run the object they built
24
+ * through the PUBLISHED schema rather than asserting the shape, so the only
25
+ * anchors this module can emit are anchors the route boundary will accept. The
26
+ * bounds come from `./anchor-constants` for the same reason: a literal here
27
+ * would be a fifth copy of a number whose whole point is having one.
28
+ *
29
+ * NO YJS HERE, DELIBERATELY — the same seam `./resolve` holds. `relStart`,
30
+ * `relEnd` and `relPos` are NOT produced: encoding a `Y.RelativePosition` needs
31
+ * `yjs`, which the vocabulary-guard forbids this package from importing, and it
32
+ * needs a live replica besides. A consumer that holds a `Y.Text` merges the
33
+ * encoded pair onto the anchor returned here; the anchor is useful without it,
34
+ * because the quote and the contexts are the durable fallback anyway.
35
+ *
36
+ * All arithmetic is in UTF-16 code units (plain JavaScript string coordinates),
37
+ * like every other offset on the comment path, and no Unicode normalization
38
+ * happens anywhere here.
39
+ *
40
+ * SPELLING DEVIATION FROM THE AUTHORITATIVE REFERENCE (PRD-00941, must_log).
41
+ * Nothing semantic. The reference writes
42
+ * `export function createTextAnchorFromRange(range: TextRange): CommentAnchor | null {`
43
+ * on one line, which is 82 columns against this repo's `printWidth: 80`, so
44
+ * `pnpm format:check` reflows the signature onto three lines. Same precedent as
45
+ * the notes in `./anchor.ts`: a different spelling of the same function, and
46
+ * nothing about what is accepted or refused changed.
47
+ */
48
+ import { CommentAnchorSchema, type CommentAnchor } from "./anchor";
49
+ import { QUOTE_MAX_CHARS } from "./anchor-constants";
50
+ import { contextAfter, contextBefore } from "./resolve";
51
+
52
+ /**
53
+ * A selected range, in the coordinate space of the text it was selected in.
54
+ *
55
+ * `text` travels WITH the offsets rather than being passed separately, because
56
+ * a start/end pair means nothing without the string it indexes — and a caller
57
+ * holding the pair from one revision and the string from another is the bug
58
+ * this shape makes hard to write.
59
+ */
60
+ export interface TextRange {
61
+ readonly text: string;
62
+ readonly start: number;
63
+ readonly end: number;
64
+ }
65
+
66
+ /**
67
+ * range → anchor, headlessly. The mirror of `resolveAnchorFromText`, and a
68
+ * WRITE capability, so it refuses rather than guesses on every degenerate
69
+ * input.
70
+ *
71
+ * Returns `null` for: a non-integer offset, an out-of-order or EMPTY range
72
+ * (`end <= start` — the zero-width case has its own constructor below, and
73
+ * silently promoting one here would produce a text anchor whose quote is the
74
+ * empty string), a range reaching past the end of `text`, or a quote longer
75
+ * than {@link QUOTE_MAX_CHARS}.
76
+ *
77
+ * The relative-position pair is NOT produced here: it needs `yjs`, which this
78
+ * package may not import. A consumer holding a `Y.Text` merges it onto the
79
+ * result.
80
+ */
81
+ export function createTextAnchorFromRange(
82
+ range: TextRange,
83
+ ): CommentAnchor | null {
84
+ const { text, start, end } = range;
85
+ if (!Number.isInteger(start) || !Number.isInteger(end)) return null;
86
+ if (start < 0 || end <= start || end > text.length) return null;
87
+
88
+ const quote = text.slice(start, end);
89
+ // Refusing beats truncating: a truncated quote anchors a range nobody chose.
90
+ if (quote.length === 0 || quote.length > QUOTE_MAX_CHARS) return null;
91
+
92
+ const parsed = CommentAnchorSchema.safeParse({
93
+ type: "text",
94
+ v: 1,
95
+ quote,
96
+ prefix: contextBefore(text, start),
97
+ suffix: contextAfter(text, end),
98
+ });
99
+ return parsed.success ? parsed.data : null;
100
+ }
101
+
102
+ /**
103
+ * The zero-width variant: a point BETWEEN two characters.
104
+ *
105
+ * Refuses two empty contexts on a non-empty document — that anchor identifies
106
+ * nothing, and `resolveInsertionBySeam` would (correctly) orphan it, so
107
+ * composing it at all would only defer the loss until after the suggestion was
108
+ * written. On an EMPTY document both contexts are empty and that is the one
109
+ * point there is, so it is allowed.
110
+ */
111
+ export function createTextInsertionAnchorAt(
112
+ text: string,
113
+ point: number,
114
+ ): CommentAnchor | null {
115
+ if (!Number.isInteger(point) || point < 0 || point > text.length) return null;
116
+ const leftContext = contextBefore(text, point);
117
+ const rightContext = contextAfter(text, point);
118
+ // Two empty contexts identify nothing on a non-empty document.
119
+ if (leftContext === "" && rightContext === "" && text.length > 0) return null;
120
+
121
+ const parsed = CommentAnchorSchema.safeParse({
122
+ type: "text-insertion",
123
+ v: 1,
124
+ leftContext,
125
+ rightContext,
126
+ });
127
+ return parsed.success ? parsed.data : null;
128
+ }
@@ -8,6 +8,13 @@
8
8
  * bodies, per ADR-CONT-029).
9
9
  */
10
10
 
11
+ export {
12
+ ANCHOR_AFFIX_MAX_CHARS,
13
+ ANCHOR_CONTEXT_CHARS,
14
+ QUOTE_MAX_CHARS,
15
+ RELATIVE_POSITION_MAX_CHARS,
16
+ } from "./anchor-constants";
17
+
11
18
  export {
12
19
  COMMENT_ANCHOR_TYPES,
13
20
  CommentAnchorTypeSchema,
@@ -16,6 +23,16 @@ export {
16
23
 
17
24
  export type { CommentAnchor, CommentAnchorType } from "./anchor";
18
25
 
26
+ export {
27
+ contextAfter,
28
+ contextBefore,
29
+ contextsMatchAt,
30
+ findSoleOccurrence,
31
+ resolveAnchorFromText,
32
+ } from "./resolve";
33
+
34
+ export type { AnchorResolution, Occurrence } from "./resolve";
35
+
19
36
  export {
20
37
  COMMENT_SUBJECT_TYPES,
21
38
  COMMENT_THREAD_STATUSES,
@@ -55,3 +72,22 @@ export type {
55
72
  SuggestionOp,
56
73
  SuggestionStatus,
57
74
  } from "./suggestion";
75
+
76
+ export { anchorStillReads, plannedEdit } from "./verify";
77
+
78
+ export type { PlannedEdit } from "./verify";
79
+
80
+ export {
81
+ createTextAnchorFromRange,
82
+ createTextInsertionAnchorAt,
83
+ } from "./create";
84
+
85
+ export type { TextRange } from "./create";
86
+
87
+ export {
88
+ SUGGESTION_RECEIPTS_MAP,
89
+ SuggestionReceiptSchema,
90
+ readSuggestionReceipt,
91
+ } from "./receipt";
92
+
93
+ export type { SuggestionReceipt } from "./receipt";
@@ -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
+ }