@company-semantics/contracts 39.2.0 → 39.4.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 +14 -4
- package/src/action-items/README.md +73 -0
- package/src/action-items/__tests__/README.md +39 -0
- package/src/action-items/__tests__/vocabulary.test.ts +133 -0
- package/src/action-items/index.ts +33 -0
- package/src/action-items/kinds.ts +82 -0
- package/src/action-items/schemas.ts +87 -0
- package/src/api/README.md +2 -2
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +295 -0
- package/src/generated/openapi-routes.ts +4 -0
- package/src/index.ts +58 -0
- package/src/org/README.md +24 -0
- package/src/org/__tests__/company-md-collab.test.ts +290 -0
- package/src/org/company-md-collab.ts +337 -0
- package/src/org/company-md.ts +12 -0
- package/src/org/index.ts +32 -0
- package/src/resource-keys.ts +9 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
COMPANY_MD_COLLAB_TEXT_KEY,
|
|
5
|
+
COMPANY_MD_COLLAB_MAX_UPDATE_B64_CHARS,
|
|
6
|
+
COMPANY_MD_COLLAB_MAX_DOC_TEXT_BYTES,
|
|
7
|
+
COMPANY_MD_COLLAB_MAX_PRESENCE_POSITION_CHARS,
|
|
8
|
+
CompanyMdCollabSyncResponseSchema,
|
|
9
|
+
CompanyMdCollabUpdateAcceptedSchema,
|
|
10
|
+
CompanyMdCollabSseEventSchema,
|
|
11
|
+
} from "../company-md-collab.js";
|
|
12
|
+
|
|
13
|
+
const DOC_ID = "9f1c7b62-2b3e-4d21-9a11-6a0c0b5e7c31";
|
|
14
|
+
const USER_ID = "3d0a2f18-5c44-4a90-8b7a-1d2e3f4a5b6c";
|
|
15
|
+
const CLIENT_KEY = "7c9e6679-7425-40de-944b-e07fc1f90ae7";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A sequence past 2^53. This is the whole reason seq is a string: as a JSON
|
|
19
|
+
* number it would round, and a rounded cursor resumes at the wrong position
|
|
20
|
+
* without failing.
|
|
21
|
+
*/
|
|
22
|
+
const BIG_SEQ = "9007199254740993";
|
|
23
|
+
|
|
24
|
+
describe("CompanyMdCollabSyncResponseSchema", () => {
|
|
25
|
+
const base = {
|
|
26
|
+
epoch: 3,
|
|
27
|
+
seq: "42",
|
|
28
|
+
update: "AQIDBA==",
|
|
29
|
+
editable: true,
|
|
30
|
+
reset: false,
|
|
31
|
+
limits: { maxUpdateBytes: 262144, maxDocTextBytes: 1000000 },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
it("accepts the sync response the backend emits", () => {
|
|
35
|
+
expect(CompanyMdCollabSyncResponseSchema.parse(base)).toEqual(base);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("accepts a seq beyond the JS safe-integer range", () => {
|
|
39
|
+
const parsed = CompanyMdCollabSyncResponseSchema.parse({
|
|
40
|
+
...base,
|
|
41
|
+
seq: BIG_SEQ,
|
|
42
|
+
});
|
|
43
|
+
expect(parsed.seq).toBe(BIG_SEQ);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("accepts a response without reset (the field is additive-optional)", () => {
|
|
47
|
+
const { reset: _reset, ...withoutReset } = base;
|
|
48
|
+
expect(() =>
|
|
49
|
+
CompanyMdCollabSyncResponseSchema.parse(withoutReset),
|
|
50
|
+
).not.toThrow();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rejects a numeric seq", () => {
|
|
54
|
+
expect(() =>
|
|
55
|
+
CompanyMdCollabSyncResponseSchema.parse({ ...base, seq: 42 }),
|
|
56
|
+
).toThrow();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("rejects a non-digit seq", () => {
|
|
60
|
+
expect(() =>
|
|
61
|
+
CompanyMdCollabSyncResponseSchema.parse({ ...base, seq: "42n" }),
|
|
62
|
+
).toThrow();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("rejects a zero epoch", () => {
|
|
66
|
+
expect(() =>
|
|
67
|
+
CompanyMdCollabSyncResponseSchema.parse({ ...base, epoch: 0 }),
|
|
68
|
+
).toThrow();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("rejects a response missing limits", () => {
|
|
72
|
+
const { limits: _limits, ...withoutLimits } = base;
|
|
73
|
+
expect(() =>
|
|
74
|
+
CompanyMdCollabSyncResponseSchema.parse(withoutLimits),
|
|
75
|
+
).toThrow();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("CompanyMdCollabUpdateAcceptedSchema", () => {
|
|
80
|
+
it("accepts the append response the backend emits", () => {
|
|
81
|
+
const frame = { seq: "1024", duplicate: false };
|
|
82
|
+
expect(CompanyMdCollabUpdateAcceptedSchema.parse(frame)).toEqual(frame);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("rejects a numeric seq", () => {
|
|
86
|
+
expect(() =>
|
|
87
|
+
CompanyMdCollabUpdateAcceptedSchema.parse({
|
|
88
|
+
seq: 1024,
|
|
89
|
+
duplicate: false,
|
|
90
|
+
}),
|
|
91
|
+
).toThrow();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("rejects a missing duplicate flag", () => {
|
|
95
|
+
expect(() =>
|
|
96
|
+
CompanyMdCollabUpdateAcceptedSchema.parse({ seq: "1024" }),
|
|
97
|
+
).toThrow();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The frames below are the exact `data:` bodies the backend writes, with `type`
|
|
103
|
+
* stamped from the SSE `event:` name — which is how a client feeds the union.
|
|
104
|
+
* If one of these stops parsing, the published contract has drifted from the
|
|
105
|
+
* stream.
|
|
106
|
+
*/
|
|
107
|
+
describe("CompanyMdCollabSseEventSchema — raw backend frames", () => {
|
|
108
|
+
it("parses a collab-update frame", () => {
|
|
109
|
+
const parsed = CompanyMdCollabSseEventSchema.parse({
|
|
110
|
+
type: "collab-update",
|
|
111
|
+
v: 1,
|
|
112
|
+
epoch: 3,
|
|
113
|
+
seq: BIG_SEQ,
|
|
114
|
+
update: "AQIDBA==",
|
|
115
|
+
});
|
|
116
|
+
expect(parsed.type).toBe("collab-update");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("parses a collab-presence frame with selection blobs", () => {
|
|
120
|
+
const parsed = CompanyMdCollabSseEventSchema.parse({
|
|
121
|
+
type: "collab-presence",
|
|
122
|
+
v: 1,
|
|
123
|
+
userId: USER_ID,
|
|
124
|
+
clientKey: CLIENT_KEY,
|
|
125
|
+
status: "editing",
|
|
126
|
+
anchor: "AQAB",
|
|
127
|
+
head: "AQAC",
|
|
128
|
+
});
|
|
129
|
+
expect(parsed.type).toBe("collab-presence");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("parses a collab-presence departure frame", () => {
|
|
133
|
+
expect(() =>
|
|
134
|
+
CompanyMdCollabSseEventSchema.parse({
|
|
135
|
+
type: "collab-presence",
|
|
136
|
+
v: 1,
|
|
137
|
+
userId: USER_ID,
|
|
138
|
+
clientKey: CLIENT_KEY,
|
|
139
|
+
status: "viewing",
|
|
140
|
+
gone: true,
|
|
141
|
+
}),
|
|
142
|
+
).not.toThrow();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("parses a collab-reset frame for each reason the backend sends", () => {
|
|
146
|
+
for (const reason of ["epoch-mismatch", "compacted", "cursor-ahead"]) {
|
|
147
|
+
expect(() =>
|
|
148
|
+
CompanyMdCollabSseEventSchema.parse({
|
|
149
|
+
type: "collab-reset",
|
|
150
|
+
v: 1,
|
|
151
|
+
reason,
|
|
152
|
+
epoch: 4,
|
|
153
|
+
resumeSeq: "77",
|
|
154
|
+
}),
|
|
155
|
+
).not.toThrow();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("parses an access-revoked frame with its empty body", () => {
|
|
160
|
+
const parsed = CompanyMdCollabSseEventSchema.parse({
|
|
161
|
+
type: "access-revoked",
|
|
162
|
+
});
|
|
163
|
+
expect(parsed.type).toBe("access-revoked");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("parses a connected frame with its empty body", () => {
|
|
167
|
+
expect(() =>
|
|
168
|
+
CompanyMdCollabSseEventSchema.parse({ type: "connected" }),
|
|
169
|
+
).not.toThrow();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("parses a server_drain frame", () => {
|
|
173
|
+
expect(() =>
|
|
174
|
+
CompanyMdCollabSseEventSchema.parse({
|
|
175
|
+
type: "server_drain",
|
|
176
|
+
reason: "shutting_down",
|
|
177
|
+
}),
|
|
178
|
+
).not.toThrow();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("CompanyMdCollabSseEventSchema — optional attribution fields", () => {
|
|
183
|
+
it("accepts a collab-update carrying docId, author and origin", () => {
|
|
184
|
+
expect(() =>
|
|
185
|
+
CompanyMdCollabSseEventSchema.parse({
|
|
186
|
+
type: "collab-update",
|
|
187
|
+
v: 1,
|
|
188
|
+
epoch: 3,
|
|
189
|
+
seq: "9",
|
|
190
|
+
update: "AQIDBA==",
|
|
191
|
+
docId: DOC_ID,
|
|
192
|
+
authorUserId: USER_ID,
|
|
193
|
+
origin: "client",
|
|
194
|
+
}),
|
|
195
|
+
).not.toThrow();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("accepts a server-originated collab-update with a null author", () => {
|
|
199
|
+
expect(() =>
|
|
200
|
+
CompanyMdCollabSseEventSchema.parse({
|
|
201
|
+
type: "collab-update",
|
|
202
|
+
v: 1,
|
|
203
|
+
epoch: 3,
|
|
204
|
+
seq: "9",
|
|
205
|
+
update: "AQIDBA==",
|
|
206
|
+
authorUserId: null,
|
|
207
|
+
origin: "bridge",
|
|
208
|
+
}),
|
|
209
|
+
).not.toThrow();
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("CompanyMdCollabSseEventSchema — rejections", () => {
|
|
214
|
+
it("rejects an unknown event type", () => {
|
|
215
|
+
expect(() =>
|
|
216
|
+
CompanyMdCollabSseEventSchema.parse({ type: "collab-yolo", v: 1 }),
|
|
217
|
+
).toThrow();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("rejects a frame with no type stamped on it", () => {
|
|
221
|
+
expect(() =>
|
|
222
|
+
CompanyMdCollabSseEventSchema.parse({
|
|
223
|
+
v: 1,
|
|
224
|
+
epoch: 3,
|
|
225
|
+
seq: "9",
|
|
226
|
+
update: "AQIDBA==",
|
|
227
|
+
}),
|
|
228
|
+
).toThrow();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("rejects a numeric seq on collab-update", () => {
|
|
232
|
+
expect(() =>
|
|
233
|
+
CompanyMdCollabSseEventSchema.parse({
|
|
234
|
+
type: "collab-update",
|
|
235
|
+
v: 1,
|
|
236
|
+
epoch: 3,
|
|
237
|
+
seq: 9,
|
|
238
|
+
update: "AQIDBA==",
|
|
239
|
+
}),
|
|
240
|
+
).toThrow();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("rejects a numeric resumeSeq on collab-reset", () => {
|
|
244
|
+
expect(() =>
|
|
245
|
+
CompanyMdCollabSseEventSchema.parse({
|
|
246
|
+
type: "collab-reset",
|
|
247
|
+
v: 1,
|
|
248
|
+
reason: "compacted",
|
|
249
|
+
epoch: 4,
|
|
250
|
+
resumeSeq: 77,
|
|
251
|
+
}),
|
|
252
|
+
).toThrow();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("rejects an unknown presence status", () => {
|
|
256
|
+
expect(() =>
|
|
257
|
+
CompanyMdCollabSseEventSchema.parse({
|
|
258
|
+
type: "collab-presence",
|
|
259
|
+
v: 1,
|
|
260
|
+
userId: USER_ID,
|
|
261
|
+
clientKey: CLIENT_KEY,
|
|
262
|
+
status: "idle",
|
|
263
|
+
}),
|
|
264
|
+
).toThrow();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("rejects a future frame version", () => {
|
|
268
|
+
expect(() =>
|
|
269
|
+
CompanyMdCollabSseEventSchema.parse({
|
|
270
|
+
type: "collab-update",
|
|
271
|
+
v: 2,
|
|
272
|
+
epoch: 3,
|
|
273
|
+
seq: "9",
|
|
274
|
+
update: "AQIDBA==",
|
|
275
|
+
}),
|
|
276
|
+
).toThrow();
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("collaboration protocol constants", () => {
|
|
281
|
+
it("names the Y.Doc root key the backend materializes text from", () => {
|
|
282
|
+
expect(COMPANY_MD_COLLAB_TEXT_KEY).toBe("content");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("publishes the size limits the server enforces", () => {
|
|
286
|
+
expect(COMPANY_MD_COLLAB_MAX_UPDATE_B64_CHARS).toBe(256 * 1024);
|
|
287
|
+
expect(COMPANY_MD_COLLAB_MAX_DOC_TEXT_BYTES).toBe(1_000_000);
|
|
288
|
+
expect(COMPANY_MD_COLLAB_MAX_PRESENCE_POSITION_CHARS).toBe(512);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* company.md real-time collaboration — the published wire contract.
|
|
3
|
+
*
|
|
4
|
+
* The response and event vocabulary of the four `/api/company-md/docs/{id}/collab/*`
|
|
5
|
+
* routes. Promoted here because both the app (the editor and its transport) and
|
|
6
|
+
* the backend (the routes and the Y.Doc bridge) must agree on these shapes, and
|
|
7
|
+
* a client that guesses one of them fails silently rather than loudly. Request
|
|
8
|
+
* bodies deliberately stay backend-side per ADR-CONT-029 — only responses and
|
|
9
|
+
* broadcast events are the published promise.
|
|
10
|
+
*
|
|
11
|
+
* THE ONE RULE — every sequence value is a DECIMAL STRING, never a JSON number.
|
|
12
|
+
* Sequences are Postgres bigints; past 2^53 `JSON.parse` rounds them, and a
|
|
13
|
+
* rounded cursor does not fail, it silently resumes at the wrong position. So
|
|
14
|
+
* `seq` and `resumeSeq` are `z.string().regex(/^[0-9]+$/)` and a numeric value
|
|
15
|
+
* is rejected at the boundary.
|
|
16
|
+
*
|
|
17
|
+
* ON THE SSE UNION'S DISCRIMINATOR — the stream's frames do NOT carry a `type`
|
|
18
|
+
* field. The discriminator is the SSE `event:` NAME, and the `data:` body is the
|
|
19
|
+
* bare payload. A client therefore stamps `type` from the frame's event name
|
|
20
|
+
* before parsing:
|
|
21
|
+
*
|
|
22
|
+
* CompanyMdCollabSseEventSchema.parse({ type: ev.type, ...JSON.parse(ev.data) })
|
|
23
|
+
*
|
|
24
|
+
* The union is modeled on `type` anyway (the ChatSseEvent precedent) because
|
|
25
|
+
* that is the shape a consumer actually switches on. Fields the server does not
|
|
26
|
+
* put on the wire today are optional, so a stamped raw frame parses as-is.
|
|
27
|
+
*
|
|
28
|
+
* @see ADR-CONT-102 for what is promoted here and what deliberately is not.
|
|
29
|
+
* @see the backend protocol ADR (slug `company-md-collab-protocol`) for the
|
|
30
|
+
* authoritative protocol semantics these shapes describe.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { z } from "zod";
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Protocol constants
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The Y.Doc root key holding the document body — a CONTRACT, not a detail.
|
|
41
|
+
*
|
|
42
|
+
* The server materializes text by reading this key and the bridge mutates the
|
|
43
|
+
* same type. A client that writes its body under a different root key gets its
|
|
44
|
+
* updates durably logged and permanently ignored: the log is correct, the
|
|
45
|
+
* stored `content` stays empty, and nothing errors anywhere. Title is
|
|
46
|
+
* deliberately NOT in the CRDT document.
|
|
47
|
+
*/
|
|
48
|
+
export const COMPANY_MD_COLLAB_TEXT_KEY = "content" as const;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Cap on ONE encoded update, measured on the base64 payload as it is sent.
|
|
52
|
+
* Published so an editor can refuse an oversized paste locally instead of
|
|
53
|
+
* discovering the cap on a rejected POST. Prefer the `limits` block on a live
|
|
54
|
+
* {@link CompanyMdCollabSyncResponseSchema} when one is in hand — this constant
|
|
55
|
+
* is the compile-time default for a client that has not synced yet.
|
|
56
|
+
*/
|
|
57
|
+
export const COMPANY_MD_COLLAB_MAX_UPDATE_B64_CHARS = 262144 as const;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Cap on the MATERIALIZED document text, in UTF-8 bytes of the expanded result.
|
|
61
|
+
*
|
|
62
|
+
* Not redundant with {@link COMPANY_MD_COLLAB_MAX_UPDATE_B64_CHARS}: a CRDT
|
|
63
|
+
* update is a DELTA, so a small delta can expand the document arbitrarily and a
|
|
64
|
+
* stream of individually-tiny updates can grow state without any single request
|
|
65
|
+
* looking suspicious. Only a measurement on the expanded text catches that.
|
|
66
|
+
*/
|
|
67
|
+
export const COMPANY_MD_COLLAB_MAX_DOC_TEXT_BYTES = 1_000_000 as const;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Cap on ONE presence position blob (`anchor` or `head`), in base64 chars.
|
|
71
|
+
* These are encoded Y.RelativePosition values — opaque POSITION data, a few
|
|
72
|
+
* dozen bytes in practice; the cap keeps a full presence envelope well under
|
|
73
|
+
* the transport's payload ceiling so an envelope is never truncated.
|
|
74
|
+
*/
|
|
75
|
+
export const COMPANY_MD_COLLAB_MAX_PRESENCE_POSITION_CHARS = 512 as const;
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Sequences
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A sequence number on the wire: decimal digits only, arbitrary precision.
|
|
83
|
+
* Module-private on purpose — consumers reach it through the field that uses
|
|
84
|
+
* it, so there is one place the rule can change.
|
|
85
|
+
*/
|
|
86
|
+
const CollabSeqString = z
|
|
87
|
+
.string()
|
|
88
|
+
.regex(
|
|
89
|
+
/^[0-9]+$/,
|
|
90
|
+
"seq must be a decimal string (bigint values are never JSON numbers)",
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Response shapes
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Protocol limits the client needs BEFORE it sends anything. Published from the
|
|
99
|
+
* same constants the server enforces — a published limit that disagrees with
|
|
100
|
+
* the enforced one is worse than none, because the editor lets the user type
|
|
101
|
+
* and the server then rejects the result.
|
|
102
|
+
*/
|
|
103
|
+
export const CompanyMdCollabSyncLimitsSchema = z.object({
|
|
104
|
+
/** Max size of ONE base64-encoded update the server will accept, in bytes. */
|
|
105
|
+
maxUpdateBytes: z.number().int(),
|
|
106
|
+
/** Max size of the MATERIALIZED document text, in UTF-8 bytes. */
|
|
107
|
+
maxDocTextBytes: z.number().int(),
|
|
108
|
+
});
|
|
109
|
+
export type CompanyMdCollabSyncLimits = z.infer<
|
|
110
|
+
typeof CompanyMdCollabSyncLimitsSchema
|
|
111
|
+
>;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* `GET /collab/sync` response body — merged collaboration state plus the cursor
|
|
115
|
+
* the stream resumes from.
|
|
116
|
+
*
|
|
117
|
+
* `seq` is handed straight back to the stream as `?after=`, which is what closes
|
|
118
|
+
* the race between "fetched state" and "receiving live updates": the server
|
|
119
|
+
* serves everything strictly after that seq, so an update committed inside the
|
|
120
|
+
* window is re-sent rather than lost.
|
|
121
|
+
*/
|
|
122
|
+
export const CompanyMdCollabSyncResponseSchema = z.object({
|
|
123
|
+
/** The document's current collaboration generation. */
|
|
124
|
+
epoch: z.number().int().positive(),
|
|
125
|
+
/** Cursor this payload advances the client to — pass verbatim as `after=`. */
|
|
126
|
+
seq: CollabSeqString,
|
|
127
|
+
/** base64 Yjs bytes: a full snapshot when `reset`, otherwise a delta. */
|
|
128
|
+
update: z.string(),
|
|
129
|
+
/**
|
|
130
|
+
* Whether this actor may edit the body — surfaced so a client can mount a
|
|
131
|
+
* read-only editor instead of walking into a doomed first write. Signal only;
|
|
132
|
+
* it never affects admittance to the stream.
|
|
133
|
+
*/
|
|
134
|
+
editable: z.boolean(),
|
|
135
|
+
/**
|
|
136
|
+
* True when `update` is a full snapshot because the client's cursor was
|
|
137
|
+
* unusable (absent, from another generation, or older than what compaction
|
|
138
|
+
* still holds). Applying it is identical either way — Yjs merges losslessly —
|
|
139
|
+
* but anything the client queued against the old cursor is void.
|
|
140
|
+
*
|
|
141
|
+
* Optional on the published contract though the server always sends it: a
|
|
142
|
+
* reader that treats an absent value as `false` behaves correctly against
|
|
143
|
+
* every server that omits it, and this keeps the field addable to sibling
|
|
144
|
+
* responses later without a major.
|
|
145
|
+
*/
|
|
146
|
+
reset: z.boolean().optional(),
|
|
147
|
+
limits: CompanyMdCollabSyncLimitsSchema,
|
|
148
|
+
});
|
|
149
|
+
export type CompanyMdCollabSyncResponse = z.infer<
|
|
150
|
+
typeof CompanyMdCollabSyncResponseSchema
|
|
151
|
+
>;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `POST /collab/updates` response body.
|
|
155
|
+
*
|
|
156
|
+
* `duplicate` is not decoration: on a retry the server returns the ORIGINAL seq
|
|
157
|
+
* and writes nothing, so a client that treats the response as proof of a fresh
|
|
158
|
+
* append would double-count its own edit.
|
|
159
|
+
*/
|
|
160
|
+
export const CompanyMdCollabUpdateAcceptedSchema = z.object({
|
|
161
|
+
/** The position this update occupies in the doc's log. */
|
|
162
|
+
seq: CollabSeqString,
|
|
163
|
+
/** True when this exact `(clientKey, clientUpdateId)` was already appended. */
|
|
164
|
+
duplicate: z.boolean(),
|
|
165
|
+
});
|
|
166
|
+
export type CompanyMdCollabUpdateAccepted = z.infer<
|
|
167
|
+
typeof CompanyMdCollabUpdateAcceptedSchema
|
|
168
|
+
>;
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// SSE frames
|
|
172
|
+
//
|
|
173
|
+
// Each member's `type` is the SSE `event:` name, stamped by the client onto the
|
|
174
|
+
// parsed `data:` body (see the module note). Fields the server does not
|
|
175
|
+
// currently emit are optional so a stamped raw frame parses unchanged.
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* `event: collab-update` — one durable collaboration update.
|
|
180
|
+
*
|
|
181
|
+
* The only frame that carries an SSE `id:` line (equal to `seq`), which is what
|
|
182
|
+
* makes a browser's `Last-Event-ID` resume land on a durable cursor. Every other
|
|
183
|
+
* frame is deliberately id-less so it can never advance that cursor.
|
|
184
|
+
*/
|
|
185
|
+
export const CompanyMdCollabUpdateEventSchema = z.object({
|
|
186
|
+
type: z.literal("collab-update"),
|
|
187
|
+
/** Frame format version. */
|
|
188
|
+
v: z.literal(1),
|
|
189
|
+
/** The generation this update belongs to. */
|
|
190
|
+
epoch: z.number().int(),
|
|
191
|
+
/** The update's position in the doc's log — mirrors the frame's `id:`. */
|
|
192
|
+
seq: CollabSeqString,
|
|
193
|
+
/** base64 Yjs update bytes. Opaque; the server never interprets them. */
|
|
194
|
+
update: z.string(),
|
|
195
|
+
/** The document. Absent on a single-doc stream, where the URL already names it. */
|
|
196
|
+
docId: z.string().uuid().optional(),
|
|
197
|
+
/** Who produced the update; `null` for server-originated writes. Absent when not attributed. */
|
|
198
|
+
authorUserId: z.string().uuid().nullable().optional(),
|
|
199
|
+
/** Where the update came from. Absent when the server does not attribute origin. */
|
|
200
|
+
origin: z.enum(["client", "bridge", "bootstrap"]).optional(),
|
|
201
|
+
});
|
|
202
|
+
export type CompanyMdCollabUpdateEvent = z.infer<
|
|
203
|
+
typeof CompanyMdCollabUpdateEventSchema
|
|
204
|
+
>;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* `event: collab-presence` — one participant's presence signal, either live or
|
|
208
|
+
* replayed from the roster right after connect (so a joining client's avatar
|
|
209
|
+
* stack fills instantly).
|
|
210
|
+
*
|
|
211
|
+
* `userId` is trustworthy: the server stamps it from the posting session and
|
|
212
|
+
* never takes it from a client body. That is why no client-supplied identity
|
|
213
|
+
* type is published — receiving clients resolve name/avatar/colour from
|
|
214
|
+
* `userId` themselves.
|
|
215
|
+
*/
|
|
216
|
+
export const CompanyMdCollabPresenceEventSchema = z.object({
|
|
217
|
+
type: z.literal("collab-presence"),
|
|
218
|
+
/** Frame format version. */
|
|
219
|
+
v: z.literal(1),
|
|
220
|
+
/** The participant — session-stamped server-side; resolve identity from this. */
|
|
221
|
+
userId: z.string().uuid(),
|
|
222
|
+
/** The participant's editor instance (one user may hold several). */
|
|
223
|
+
clientKey: z.string().uuid(),
|
|
224
|
+
/** What that editor instance is doing. */
|
|
225
|
+
status: z.enum(["editing", "viewing"]),
|
|
226
|
+
/** Selection anchor — an encoded Y.RelativePosition blob. */
|
|
227
|
+
anchor: z.string().optional(),
|
|
228
|
+
/** Selection head — an encoded Y.RelativePosition blob. */
|
|
229
|
+
head: z.string().optional(),
|
|
230
|
+
/** Present (`true`) when this clientKey left — explicitly or by TTL expiry. */
|
|
231
|
+
gone: z.literal(true).optional(),
|
|
232
|
+
/** The document. Absent on a single-doc stream, where the URL already names it. */
|
|
233
|
+
docId: z.string().uuid().optional(),
|
|
234
|
+
/** The generation. Absent because presence is not a log position. */
|
|
235
|
+
epoch: z.number().int().optional(),
|
|
236
|
+
});
|
|
237
|
+
export type CompanyMdCollabPresenceEvent = z.infer<
|
|
238
|
+
typeof CompanyMdCollabPresenceEventSchema
|
|
239
|
+
>;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* `event: collab-reset` — the client's cursor was unusable (a superseded
|
|
243
|
+
* generation, older than what compaction still holds, or ahead of anything this
|
|
244
|
+
* generation ever issued). The stream keeps serving from the current tail; the
|
|
245
|
+
* client refetches `/sync` and its local Y.Doc merges the snapshot losslessly,
|
|
246
|
+
* so nothing typed meanwhile is lost.
|
|
247
|
+
*/
|
|
248
|
+
export const CompanyMdCollabResetEventSchema = z.object({
|
|
249
|
+
type: z.literal("collab-reset"),
|
|
250
|
+
/** Frame format version. */
|
|
251
|
+
v: z.literal(1),
|
|
252
|
+
/**
|
|
253
|
+
* Why the cursor could not be honored. Currently one of `epoch-mismatch`,
|
|
254
|
+
* `compacted`, `cursor-ahead` — published as an open string, not an enum, so
|
|
255
|
+
* a server that grows a fourth reason does not turn every existing client's
|
|
256
|
+
* reset into a parse failure. The client's response is the same regardless:
|
|
257
|
+
* refetch `/sync`.
|
|
258
|
+
*/
|
|
259
|
+
reason: z.string(),
|
|
260
|
+
/** The document's CURRENT generation — what `/sync` will answer from. */
|
|
261
|
+
epoch: z.number().int(),
|
|
262
|
+
/** The tail the stream is serving from while the client re-syncs. */
|
|
263
|
+
resumeSeq: CollabSeqString,
|
|
264
|
+
});
|
|
265
|
+
export type CompanyMdCollabResetEvent = z.infer<
|
|
266
|
+
typeof CompanyMdCollabResetEventSchema
|
|
267
|
+
>;
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* `event: access-revoked` — the actor's read grant went away mid-stream. The
|
|
271
|
+
* server closes immediately after; the client must tear down its editor rather
|
|
272
|
+
* than reconnect.
|
|
273
|
+
*
|
|
274
|
+
* Carries an EMPTY body on the wire, so `v` is optional here even though every
|
|
275
|
+
* other frame requires it. Requiring `v` would reject the real frame.
|
|
276
|
+
*/
|
|
277
|
+
export const CompanyMdCollabAccessRevokedEventSchema = z.object({
|
|
278
|
+
type: z.literal("access-revoked"),
|
|
279
|
+
/** Frame format version; absent on the wire today. */
|
|
280
|
+
v: z.literal(1).optional(),
|
|
281
|
+
});
|
|
282
|
+
export type CompanyMdCollabAccessRevokedEvent = z.infer<
|
|
283
|
+
typeof CompanyMdCollabAccessRevokedEventSchema
|
|
284
|
+
>;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* `event: connected` — the stream is open. Transport-level, with an empty body;
|
|
288
|
+
* modeled so a client that routes EVERY frame through this union does not treat
|
|
289
|
+
* a normal connect as an unknown event.
|
|
290
|
+
*/
|
|
291
|
+
export const CompanyMdCollabConnectedEventSchema = z.object({
|
|
292
|
+
type: z.literal("connected"),
|
|
293
|
+
/** Frame format version; absent on the wire today. */
|
|
294
|
+
v: z.literal(1).optional(),
|
|
295
|
+
});
|
|
296
|
+
export type CompanyMdCollabConnectedEvent = z.infer<
|
|
297
|
+
typeof CompanyMdCollabConnectedEventSchema
|
|
298
|
+
>;
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* `event: server_drain` — the server is shutting the stream down deliberately
|
|
302
|
+
* (a rolling restart, not a permission change). The client should reconnect;
|
|
303
|
+
* unlike `access-revoked` this is not terminal.
|
|
304
|
+
*
|
|
305
|
+
* Modeled for the same reason as `connected`: without it, every rolling restart
|
|
306
|
+
* looks like an unknown event to a client that validates all frames. Note the
|
|
307
|
+
* snake_case name — it predates the kebab-case collab frames.
|
|
308
|
+
*/
|
|
309
|
+
export const CompanyMdCollabServerDrainEventSchema = z.object({
|
|
310
|
+
type: z.literal("server_drain"),
|
|
311
|
+
/** Why the server is draining; `shutting_down` today. Open string for forward compatibility. */
|
|
312
|
+
reason: z.string(),
|
|
313
|
+
/** Frame format version; absent on the wire today. */
|
|
314
|
+
v: z.literal(1).optional(),
|
|
315
|
+
});
|
|
316
|
+
export type CompanyMdCollabServerDrainEvent = z.infer<
|
|
317
|
+
typeof CompanyMdCollabServerDrainEventSchema
|
|
318
|
+
>;
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Every frame the collaboration stream emits, discriminated on the `type` the
|
|
322
|
+
* client stamps from the SSE `event:` name.
|
|
323
|
+
*
|
|
324
|
+
* Registered as the OpenAPI component `CompanyMdCollabSseEvent`, per the
|
|
325
|
+
* `ChatSseEvent` / `ExecutionSseEvent` precedent.
|
|
326
|
+
*/
|
|
327
|
+
export const CompanyMdCollabSseEventSchema = z.discriminatedUnion("type", [
|
|
328
|
+
CompanyMdCollabUpdateEventSchema,
|
|
329
|
+
CompanyMdCollabPresenceEventSchema,
|
|
330
|
+
CompanyMdCollabResetEventSchema,
|
|
331
|
+
CompanyMdCollabAccessRevokedEventSchema,
|
|
332
|
+
CompanyMdCollabConnectedEventSchema,
|
|
333
|
+
CompanyMdCollabServerDrainEventSchema,
|
|
334
|
+
]);
|
|
335
|
+
export type CompanyMdCollabSseEvent = z.infer<
|
|
336
|
+
typeof CompanyMdCollabSseEventSchema
|
|
337
|
+
>;
|
package/src/org/company-md.ts
CHANGED
|
@@ -218,6 +218,18 @@ export interface CompanyMdDocCore extends CompanyMdNodeIdentity {
|
|
|
218
218
|
* recipient to an `owners` node may still be allowed to request).
|
|
219
219
|
*/
|
|
220
220
|
readonly canRequestAccess?: boolean;
|
|
221
|
+
/**
|
|
222
|
+
* Whether this doc is served by the real-time collaboration protocol
|
|
223
|
+
* (ADR-CONT-102). When `true` the client may open `/collab/sync` + the collab
|
|
224
|
+
* stream; otherwise it uses the legacy save path.
|
|
225
|
+
*
|
|
226
|
+
* Optional so the app deploy and the contract bump stay order-independent in
|
|
227
|
+
* BOTH directions: an old client reading a new API ignores the field, and a
|
|
228
|
+
* new client reading an old API sees it absent — and an absent value means
|
|
229
|
+
* "not collaborative", so both degrade to the legacy editing path rather than
|
|
230
|
+
* to a broken one.
|
|
231
|
+
*/
|
|
232
|
+
readonly collabEnabled?: boolean;
|
|
221
233
|
}
|
|
222
234
|
|
|
223
235
|
export interface CompanyMdDocCollaborators {
|
package/src/org/index.ts
CHANGED
|
@@ -227,6 +227,38 @@ export type {
|
|
|
227
227
|
CompanyMdContextBankItem,
|
|
228
228
|
} from "./company-md";
|
|
229
229
|
|
|
230
|
+
// Company.md real-time collaboration wire contract: sync/update-accepted
|
|
231
|
+
// responses, the SSE frame union, the Y.Doc shape constant, and the published
|
|
232
|
+
// size limits. Sequences are ALWAYS decimal strings. (ADR-CONT-102)
|
|
233
|
+
export {
|
|
234
|
+
COMPANY_MD_COLLAB_TEXT_KEY,
|
|
235
|
+
COMPANY_MD_COLLAB_MAX_UPDATE_B64_CHARS,
|
|
236
|
+
COMPANY_MD_COLLAB_MAX_DOC_TEXT_BYTES,
|
|
237
|
+
COMPANY_MD_COLLAB_MAX_PRESENCE_POSITION_CHARS,
|
|
238
|
+
CompanyMdCollabSyncLimitsSchema,
|
|
239
|
+
CompanyMdCollabSyncResponseSchema,
|
|
240
|
+
CompanyMdCollabUpdateAcceptedSchema,
|
|
241
|
+
CompanyMdCollabUpdateEventSchema,
|
|
242
|
+
CompanyMdCollabPresenceEventSchema,
|
|
243
|
+
CompanyMdCollabResetEventSchema,
|
|
244
|
+
CompanyMdCollabAccessRevokedEventSchema,
|
|
245
|
+
CompanyMdCollabConnectedEventSchema,
|
|
246
|
+
CompanyMdCollabServerDrainEventSchema,
|
|
247
|
+
CompanyMdCollabSseEventSchema,
|
|
248
|
+
} from "./company-md-collab";
|
|
249
|
+
export type {
|
|
250
|
+
CompanyMdCollabSyncLimits,
|
|
251
|
+
CompanyMdCollabSyncResponse,
|
|
252
|
+
CompanyMdCollabUpdateAccepted,
|
|
253
|
+
CompanyMdCollabUpdateEvent,
|
|
254
|
+
CompanyMdCollabPresenceEvent,
|
|
255
|
+
CompanyMdCollabResetEvent,
|
|
256
|
+
CompanyMdCollabAccessRevokedEvent,
|
|
257
|
+
CompanyMdCollabConnectedEvent,
|
|
258
|
+
CompanyMdCollabServerDrainEvent,
|
|
259
|
+
CompanyMdCollabSseEvent,
|
|
260
|
+
} from "./company-md-collab";
|
|
261
|
+
|
|
230
262
|
// Sharing and ACL types (PRD-00306). The legacy AccessSource / AccessReason /
|
|
231
263
|
// EffectiveAccess / EvaluationStep / AccessExplanation were removed with
|
|
232
264
|
// CompanyMdAccessEvaluator (ADR-BE-392); ShareState.effectiveAccess now carries
|