@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.
@@ -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
+ >;
@@ -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