@sudobility/screenwriter_types 0.1.1 → 0.1.2

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.
@@ -1,466 +0,0 @@
1
- /**
2
- * Reports (spec 09 §10 F-RPT-000..018, spec 05 §6.18), slice B10.
3
- *
4
- * `REPORT_KINDS` belongs in `writing_core` (spec 09 R25) but Tasks 29-35 have not landed there; until they do the
5
- * vocabulary, the options and the columns live here so the API, the client and MCP share one declaration.
6
- * A report is a list of tables; CSV, HTML and PDF are all rendered from those same tables, which is what
7
- * F-RPT-000's acceptance ("CSV and PDF contain the same rows") rests on.
8
- */
9
- import { z } from 'zod';
10
- import { sourceParamSchema, docSourceSchema } from '../reads/index.js';
11
- export const REPORT_KINDS = [
12
- 'scene',
13
- 'location',
14
- 'character',
15
- 'cast',
16
- 'dialogue',
17
- 'script',
18
- 'text',
19
- 'notes',
20
- 'synopsis',
21
- 'revisions',
22
- 'tags',
23
- 'statistics',
24
- 'inclusivity',
25
- 'presentProgressive',
26
- 'dayOutOfDays',
27
- 'shotList',
28
- 'structure',
29
- 'characterArcs',
30
- ];
31
- export const REPORT_FORMATS = ['json', 'csv', 'pdf', 'html'];
32
- /** Documents up to this many pages run a report inline; larger ones answer with a `report.render` job (spec 05 §6.18). */
33
- export const REPORT_SYNC_MAX_PAGES = 200;
34
- const col = (id, label, align) => ({
35
- id,
36
- label,
37
- ...(align ? { align } : {}),
38
- });
39
- const bool = (name, def, description) => ({
40
- name,
41
- type: 'boolean',
42
- default: def,
43
- ...(description ? { description } : {}),
44
- });
45
- const list = (name, description) => ({
46
- name,
47
- type: 'stringList',
48
- default: [],
49
- ...(description ? { description } : {}),
50
- });
51
- const en = (name, values, def) => ({ name, type: 'enum', values, default: def });
52
- /** Column and option declarations. The tables a report returns use exactly these column ids. */
53
- export const REPORT_DEFS = {
54
- scene: {
55
- featureId: 'F-RPT-001',
56
- title: 'Scene report',
57
- options: [
58
- en('sort', ['scriptOrder', 'sceneNumber', 'location', 'scriptDay'], 'scriptOrder'),
59
- bool('includeOmitted', false),
60
- ],
61
- columns: [
62
- col('number', 'Scene'),
63
- col('intro', 'Intro'),
64
- col('location', 'Location'),
65
- col('time', 'Time'),
66
- col('heading', 'Heading'),
67
- col('page', 'Page'),
68
- col('length', 'Length'),
69
- col('estTime', 'Est. time', 'right'),
70
- col('synopsis', 'Synopsis'),
71
- col('colour', 'Colour'),
72
- col('speaking', 'Speaking'),
73
- col('nonSpeaking', 'Non-speaking'),
74
- col('scriptDay', 'Script day'),
75
- ],
76
- },
77
- location: {
78
- featureId: 'F-RPT-002',
79
- title: 'Location report',
80
- options: [en('sort', ['name', 'firstAppearance', 'length'], 'name')],
81
- columns: [
82
- col('location', 'Location'),
83
- col('intExt', 'INT/EXT'),
84
- col('times', 'Times of day'),
85
- col('scenes', 'Scenes'),
86
- col('pages', 'Pages'),
87
- col('eighths', 'Length'),
88
- col('sceneCount', 'Scene count', 'right'),
89
- ],
90
- },
91
- character: {
92
- featureId: 'F-RPT-003',
93
- title: 'Character report',
94
- options: [
95
- list('characters', 'Entity ids or names; empty means every character'),
96
- bool('includeNonSpeaking', false),
97
- {
98
- name: 'monologueWords',
99
- type: 'number',
100
- default: 100,
101
- min: 1,
102
- max: 10000,
103
- },
104
- bool('includeDialogue', false),
105
- ],
106
- columns: [
107
- col('character', 'Character'),
108
- col('scenes', 'Speaking scenes'),
109
- col('nonSpeakingScenes', 'Non-speaking scenes'),
110
- col('extensions', 'Extensions'),
111
- col('parentheticals', 'Parentheticals', 'right'),
112
- col('monologues', 'Monologues', 'right'),
113
- col('dialogue', 'Dialogue'),
114
- ],
115
- },
116
- cast: {
117
- featureId: 'F-RPT-004',
118
- title: 'Cast report',
119
- options: [
120
- en('sort', ['alphabetical', 'scriptOrder', 'dialogueCount', 'words'], 'alphabetical'),
121
- ],
122
- columns: [
123
- col('character', 'Character'),
124
- col('dialogues', 'Dialogues', 'right'),
125
- col('words', 'Words', 'right'),
126
- col('lines', 'Lines', 'right'),
127
- col('speakingScenes', 'Speaking scenes', 'right'),
128
- col('totalScenes', 'Total scenes', 'right'),
129
- col('pagesPresent', 'Pages present'),
130
- col('firstAppearance', 'First appearance'),
131
- col('gender', 'Gender'),
132
- col('role', 'Role'),
133
- col('age', 'Age'),
134
- ],
135
- },
136
- dialogue: {
137
- featureId: 'F-RPT-005',
138
- title: 'Dialogue report',
139
- options: [
140
- list('characters', 'Entity ids or names; empty means every character'),
141
- bool('includeParentheticals', false),
142
- ],
143
- columns: [
144
- col('number', '#', 'right'),
145
- col('scene', 'Scene'),
146
- col('page', 'Page'),
147
- col('character', 'Character'),
148
- col('text', 'Dialogue'),
149
- ],
150
- },
151
- script: {
152
- featureId: 'F-RPT-006',
153
- title: 'Script report',
154
- options: [list('styles', 'Style ids or roles; empty means every element')],
155
- columns: [
156
- col('page', 'Page'),
157
- col('scene', 'Scene'),
158
- col('style', 'Style'),
159
- col('text', 'Text'),
160
- ],
161
- },
162
- text: {
163
- featureId: 'F-RPT-007',
164
- title: 'Text report',
165
- options: [
166
- {
167
- name: 'q',
168
- type: 'string',
169
- required: true,
170
- description: 'Text to find',
171
- },
172
- en('mode', ['text', 'regex'], 'text'),
173
- list('styles', 'Restrict to these style ids'),
174
- ],
175
- columns: [
176
- col('page', 'Page'),
177
- col('scene', 'Scene'),
178
- col('style', 'Style'),
179
- col('snippet', 'Context'),
180
- ],
181
- },
182
- notes: {
183
- featureId: 'F-RPT-008',
184
- title: 'Notes report',
185
- options: [
186
- list('types'),
187
- list('authors'),
188
- en('status', ['any', 'open', 'resolved'], 'any'),
189
- list('colours'),
190
- ],
191
- columns: [
192
- col('order', '#', 'right'),
193
- col('colour', 'Colour'),
194
- col('type', 'Type'),
195
- col('title', 'Title'),
196
- col('body', 'Note'),
197
- col('author', 'Author'),
198
- col('created', 'Created'),
199
- col('modified', 'Modified'),
200
- col('page', 'Page'),
201
- col('scene', 'Scene'),
202
- col('status', 'Status'),
203
- col('replies', 'Replies', 'right'),
204
- ],
205
- },
206
- synopsis: {
207
- featureId: 'F-RPT-009',
208
- title: 'Synopsis report',
209
- options: [bool('includeEmpty', true)],
210
- columns: [
211
- col('number', 'Scene'),
212
- col('heading', 'Heading'),
213
- col('synopsis', 'Synopsis'),
214
- col('colour', 'Colour'),
215
- ],
216
- },
217
- revisions: {
218
- featureId: 'F-RPT-010',
219
- title: 'Revisions report',
220
- options: [list('sets', 'Revision set ids; empty means every set')],
221
- columns: [
222
- col('set', 'Revision'),
223
- col('colour', 'Colour'),
224
- col('mark', 'Mark'),
225
- col('date', 'Date'),
226
- col('pages', 'Pages'),
227
- col('scenes', 'Scenes'),
228
- col('revisedElements', 'Revised elements', 'right'),
229
- ],
230
- },
231
- tags: {
232
- featureId: 'F-RPT-011',
233
- title: 'Tags report',
234
- options: [
235
- en('mode', ['byScene', 'byCategory'], 'byScene'),
236
- list('categories', 'Tag category ids; empty means all'),
237
- ],
238
- columns: [
239
- col('scene', 'Scene'),
240
- col('heading', 'Heading'),
241
- col('page', 'Page'),
242
- col('length', 'Length'),
243
- col('scriptDay', 'Script day'),
244
- col('category', 'Category'),
245
- col('entities', 'Entities'),
246
- ],
247
- },
248
- statistics: {
249
- featureId: 'F-RPT-012',
250
- title: 'Statistics report',
251
- options: [
252
- { name: 'topWords', type: 'number', default: 20, min: 0, max: 200 },
253
- { name: 'longestScenes', type: 'number', default: 5, min: 0, max: 50 },
254
- ],
255
- columns: [col('metric', 'Metric'), col('value', 'Value', 'right')],
256
- },
257
- inclusivity: {
258
- featureId: 'F-RPT-013',
259
- title: 'Inclusivity and gender analysis',
260
- options: [en('by', ['gender', 'role'], 'gender')],
261
- columns: [
262
- col('group', 'Group'),
263
- col('characters', 'Characters', 'right'),
264
- col('dialogues', 'Dialogues', 'right'),
265
- col('words', 'Words', 'right'),
266
- col('scenesSpeaking', 'Scenes speaking', 'right'),
267
- col('wordsPct', 'Words %', 'right'),
268
- ],
269
- },
270
- presentProgressive: {
271
- featureId: 'F-RPT-014',
272
- title: 'Present progressive check',
273
- options: [],
274
- columns: [
275
- col('page', 'Page'),
276
- col('scene', 'Scene'),
277
- col('match', 'Match'),
278
- col('text', 'Action line'),
279
- ],
280
- },
281
- dayOutOfDays: {
282
- featureId: 'F-RPT-015',
283
- title: 'Day out of days',
284
- options: [],
285
- columns: [
286
- col('character', 'Character'),
287
- col('workDays', 'Work days', 'right'),
288
- col('holds', 'Holds', 'right'),
289
- ],
290
- },
291
- shotList: {
292
- featureId: 'F-RPT-016',
293
- title: 'Shot list',
294
- options: [],
295
- columns: [
296
- col('scene', 'Scene'),
297
- col('shot', 'Shot'),
298
- col('label', 'Label'),
299
- col('description', 'Description'),
300
- col('size', 'Size'),
301
- col('angle', 'Angle'),
302
- col('movement', 'Movement'),
303
- col('lens', 'Lens'),
304
- col('durationSec', 'Duration (s)', 'right'),
305
- ],
306
- },
307
- structure: {
308
- featureId: 'F-RPT-017',
309
- title: 'Structure report',
310
- options: [],
311
- columns: [
312
- col('node', 'Act / sequence / beat'),
313
- col('goal', 'Goal pages'),
314
- col('actual', 'Actual pages'),
315
- col('delta', 'Delta', 'right'),
316
- ],
317
- unavailable: 'Outline page goals are not modelled in writing_core yet (spec 01 §5.13 goals), so there is nothing to compare actual pages with',
318
- },
319
- characterArcs: {
320
- featureId: 'F-RPT-018',
321
- title: 'Character arcs',
322
- options: [
323
- list('characters', 'Entity ids or names; empty means every character'),
324
- ],
325
- columns: [
326
- col('character', 'Character'),
327
- col('scene', 'Scene'),
328
- col('heading', 'Heading'),
329
- col('note', 'Arc note'),
330
- ],
331
- },
332
- };
333
- export function reportKindInfos() {
334
- return REPORT_KINDS.map((kind) => {
335
- const d = REPORT_DEFS[kind];
336
- return {
337
- kind,
338
- featureId: d.featureId,
339
- title: d.title,
340
- available: !d.unavailable,
341
- ...(d.unavailable ? { unavailableReason: d.unavailable } : {}),
342
- options: d.options,
343
- columns: d.columns,
344
- formats: REPORT_FORMATS,
345
- };
346
- });
347
- }
348
- export const isReportKind = (v) => typeof v === 'string' && REPORT_KINDS.includes(v);
349
- /** Validates and defaults `raw` against the kind's option declarations (unknown keys are refused). */
350
- export function parseReportOptions(kind, raw) {
351
- const defs = REPORT_DEFS[kind].options;
352
- const issues = [];
353
- if (raw === undefined || raw === null)
354
- raw = {};
355
- if (typeof raw !== 'object' || Array.isArray(raw))
356
- return {
357
- ok: false,
358
- issues: [{ path: '', message: 'options must be an object' }],
359
- };
360
- const input = raw;
361
- const known = new Set(defs.map((d) => d.name));
362
- for (const k of Object.keys(input))
363
- if (!known.has(k))
364
- issues.push({ path: k, message: 'unknown option' });
365
- const out = {};
366
- for (const d of defs) {
367
- const v = input[d.name];
368
- if (v === undefined) {
369
- if (d.required)
370
- issues.push({ path: d.name, message: 'required' });
371
- else if (d.default !== undefined)
372
- out[d.name] = Array.isArray(d.default) ? [...d.default] : d.default;
373
- continue;
374
- }
375
- switch (d.type) {
376
- case 'boolean':
377
- if (typeof v !== 'boolean')
378
- issues.push({ path: d.name, message: 'must be a boolean' });
379
- else
380
- out[d.name] = v;
381
- break;
382
- case 'number':
383
- if (typeof v !== 'number' ||
384
- !Number.isFinite(v) ||
385
- (d.min !== undefined && v < d.min) ||
386
- (d.max !== undefined && v > d.max)) {
387
- issues.push({
388
- path: d.name,
389
- message: `must be a number${d.min !== undefined ? ` from ${d.min}` : ''}${d.max !== undefined ? ` to ${d.max}` : ''}`,
390
- });
391
- }
392
- else
393
- out[d.name] = v;
394
- break;
395
- case 'string':
396
- if (typeof v !== 'string' ||
397
- (d.required && v.length === 0) ||
398
- v.length > 500)
399
- issues.push({
400
- path: d.name,
401
- message: 'must be a non-empty string of at most 500 characters',
402
- });
403
- else
404
- out[d.name] = v;
405
- break;
406
- case 'enum':
407
- if (typeof v !== 'string' || !d.values.includes(v))
408
- issues.push({
409
- path: d.name,
410
- message: `must be one of ${d.values.join(', ')}`,
411
- });
412
- else
413
- out[d.name] = v;
414
- break;
415
- case 'stringList':
416
- if (!Array.isArray(v) ||
417
- v.length > 500 ||
418
- v.some((x) => typeof x !== 'string'))
419
- issues.push({ path: d.name, message: 'must be an array of strings' });
420
- else
421
- out[d.name] = v;
422
- break;
423
- }
424
- }
425
- return issues.length > 0 ? { ok: false, issues } : { ok: true, options: out };
426
- }
427
- // ─── Requests ───────────────────────────────────────────────────────────────
428
- /** `GET /documents/:did/reports/:kind?source=&options=<json>`. */
429
- export const reportGetQuerySchema = z.object({
430
- source: sourceParamSchema.optional(),
431
- options: z.string().max(20_000).optional(),
432
- });
433
- /** `POST /documents/:did/reports`. `json` answers the result; the other formats a `report.render` job. */
434
- export const reportCreateSchema = z.object({
435
- kind: z.enum(REPORT_KINDS),
436
- source: docSourceSchema.optional(),
437
- options: z.record(z.string(), z.unknown()).default({}),
438
- format: z.enum(REPORT_FORMATS).default('json'),
439
- });
440
- /** The `input` of a `report.render` job (`sources[0]` is the document). */
441
- export const reportRenderInputSchema = z.object({
442
- kind: z.enum(REPORT_KINDS),
443
- source: z.string().default('live'),
444
- options: z.record(z.string(), z.unknown()).default({}),
445
- format: z.enum(REPORT_FORMATS).default('csv'),
446
- });
447
- // ─── Rendering (shared by the API's CSV/HTML/PDF and by any client that wants the same text) ─────────────
448
- export const cellText = (v) => v === null || v === undefined ? '' : String(v);
449
- const csvField = (s) => /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
450
- /** CSV of every table (a title line and a blank line between tables when there is more than one). RFC 4180 quoting, CRLF. */
451
- export function reportToCsv(result) {
452
- const blocks = result.tables.map((t) => {
453
- const lines = [];
454
- if (result.tables.length > 1 && t.title)
455
- lines.push(csvField(t.title));
456
- lines.push(t.columns.map((c) => csvField(c.label)).join(','));
457
- for (const r of t.rows)
458
- lines.push(t.columns.map((c) => csvField(cellText(r[c.id] ?? null))).join(','));
459
- if (t.footer)
460
- lines.push(t.columns
461
- .map((c) => csvField(cellText(t.footer[c.id] ?? null)))
462
- .join(','));
463
- return lines.join('\r\n');
464
- });
465
- return blocks.join('\r\n\r\n') + '\r\n';
466
- }
@@ -1,91 +0,0 @@
1
- /**
2
- * Tenancy management and sharing DTOs (spec 05 §5.3, §5.4, §6.2-6.4, §6.7, §6.21, §8.3), slice B8.
3
- */
4
- import { z } from 'zod';
5
- import { roleSchema } from '../tenancy/index.js';
6
- // ─── Workspaces ─────────────────────────────────────────────────────────────
7
- export const TEAM_WORKSPACE_LIMIT = 50;
8
- export const workspaceCreateSchema = z.object({
9
- name: z.string().trim().min(1).max(80),
10
- avatarAssetId: z.string().min(1).nullable().optional(),
11
- });
12
- export const workspaceUpdateSchema = z.object({
13
- name: z.string().trim().min(1).max(80).optional(),
14
- avatarAssetId: z.string().min(1).nullable().optional(),
15
- defaultTemplateId: z.string().min(1).nullable().optional(),
16
- defaultLanguage: z.string().min(2).max(35).optional(),
17
- aiEnabled: z.boolean().optional(),
18
- allowPublicLinks: z.boolean().optional(),
19
- });
20
- export const workspaceDeleteSchema = z.object({ confirmName: z.string() });
21
- export const workspaceTransferSchema = z.object({
22
- toUserId: z.string().min(1),
23
- });
24
- /** `GET /workspaces/:wid/audit.csv` query: an ISO-8601 range of at most one year. */
25
- export const workspaceAuditQuerySchema = z.object({
26
- from: z.iso.datetime({ offset: true }).optional(),
27
- to: z.iso.datetime({ offset: true }).optional(),
28
- });
29
- export const AUDIT_CSV_COLUMNS = [
30
- 'created_at',
31
- 'actor',
32
- 'action',
33
- 'target_type',
34
- 'target_id',
35
- 'ip',
36
- ];
37
- export const AUDIT_MAX_RANGE_DAYS = 366;
38
- export const memberUpdateSchema = z.object({ role: roleSchema });
39
- // ─── Invitations ────────────────────────────────────────────────────────────
40
- export const INVITATION_TARGETS = ['workspace', 'project', 'document'];
41
- export const INVITATION_TTL_DAYS = 14;
42
- export const INVITATION_STATUSES = [
43
- 'pending',
44
- 'accepted',
45
- 'declined',
46
- 'cancelled',
47
- 'expired',
48
- ];
49
- /** `role` for a workspace invitation is `admin|writer|commenter|viewer` (never `owner`); for a project or document it is a grant role. */
50
- export const invitationCreateSchema = z.object({
51
- email: z.email().max(254),
52
- role: roleSchema,
53
- });
54
- export const invitationAcceptSchema = z.object({ token: z.string().min(1) });
55
- export const grantUpdateSchema = z.object({ role: roleSchema });
56
- // ─── Share links ────────────────────────────────────────────────────────────
57
- export const SHARE_LINK_ACCESS = ['view', 'comment'];
58
- /** `anyone`: the URL alone opens it at `access`. `restricted`: only people on the access list (grants/membership). */
59
- export const SHARE_LINK_GENERAL_ACCESS = ['anyone', 'restricted'];
60
- export const SHARE_LINK_TARGETS = ['project', 'document', 'snapshot'];
61
- export const SHARE_LINK_TOKEN_PREFIX = 'fws_';
62
- /** Link session (`fls_...`) lifetime, seconds (spec 05 §5.4). */
63
- export const LINK_SESSION_TTL_S = 900;
64
- export const LINK_SESSION_PREFIX = 'fls_';
65
- export const shareLinkCreateSchema = z.object({
66
- access: z.enum(SHARE_LINK_ACCESS),
67
- generalAccess: z.enum(SHARE_LINK_GENERAL_ACCESS).default('anyone'),
68
- expiresAt: z.iso.datetime({ offset: true }).nullable().optional(),
69
- password: z.string().min(1).max(200).optional(),
70
- allowDownload: z.boolean().default(true),
71
- watermarkText: z.string().max(200).nullable().optional(),
72
- });
73
- export const shareLinkUpdateSchema = z
74
- .object({
75
- access: z.enum(SHARE_LINK_ACCESS).optional(),
76
- generalAccess: z.enum(SHARE_LINK_GENERAL_ACCESS).optional(),
77
- expiresAt: z.iso.datetime({ offset: true }).nullable().optional(),
78
- password: z.string().min(1).max(200).optional(),
79
- clearPassword: z.boolean().optional(),
80
- allowDownload: z.boolean().optional(),
81
- watermarkText: z.string().max(200).nullable().optional(),
82
- })
83
- .refine((v) => !(v.password !== undefined && v.clearPassword), {
84
- message: 'password and clearPassword are exclusive',
85
- });
86
- export const shareUnlockSchema = z.object({
87
- password: z.string().max(200).optional(),
88
- });
89
- // ─── Document lock ──────────────────────────────────────────────────────────
90
- export const UNLOCK_SESSION_TTL_S = 1800;
91
- export const REAUTH_MAX_AGE_S = 300;
@@ -1,91 +0,0 @@
1
- /**
2
- * Binary wire codec for sync frames (spec 03 §2.2). Shared by the API and the frontend client.
3
- * Platform-free: only `lib0` (pure JS), no Node built-ins, no DOM.
4
- *
5
- * frame := channelId:varuint messageType:varuint payload
6
- *
7
- * Payload by type:
8
- * - JSON UTF-8 (the JSON text fills the rest of the frame): auth, authOk, authError, reauth, ping/pong, subscribe, subscribed, subscribeError,
9
- * unsubscribe, ack, reject, epochChanged, roleChanged, documentDeleted, normalized,
10
- * serverShutdown.
11
- * - syncStep1 / syncStep2 / awareness: `varuint8array` (the y-protocols payload, which is
12
- * exactly what follows the message-type tag in y-protocols/sync and /awareness).
13
- * - update: `epoch:varuint clientSeq:varuint update:varuint8array`.
14
- *
15
- * ping and pong share code 4. `decodeFrame` returns type `ping` for it; a heartbeat frame is a
16
- * liveness signal only and must never be answered (otherwise the two sides would echo forever).
17
- */
18
- import * as decoding from 'lib0/decoding';
19
- import * as encoding from 'lib0/encoding';
20
- import * as string from 'lib0/string';
21
- import { SYNC_MESSAGE_TYPES, syncFrameSchema, } from './index.js';
22
- export class SyncCodecError extends Error {
23
- constructor(message) {
24
- super(message);
25
- this.name = 'SyncCodecError';
26
- }
27
- }
28
- const BINARY_TYPES = new Set(['syncStep1', 'syncStep2', 'awareness']);
29
- const NAME_BY_CODE = new Map();
30
- for (const [name, code] of Object.entries(SYNC_MESSAGE_TYPES)) {
31
- // ping and pong share a code: the first registration (ping) wins on decode.
32
- if (!NAME_BY_CODE.has(code))
33
- NAME_BY_CODE.set(code, name);
34
- }
35
- export function encodeFrame(frame) {
36
- const enc = encoding.createEncoder();
37
- encoding.writeVarUint(enc, frame.channelId);
38
- encoding.writeVarUint(enc, SYNC_MESSAGE_TYPES[frame.type]);
39
- if (BINARY_TYPES.has(frame.type)) {
40
- encoding.writeVarUint8Array(enc, frame.payload);
41
- }
42
- else if (frame.type === 'update') {
43
- encoding.writeVarUint(enc, frame.payload.epoch);
44
- encoding.writeVarUint(enc, frame.payload.clientSeq);
45
- encoding.writeVarUint8Array(enc, frame.payload.update);
46
- }
47
- else {
48
- encoding.writeUint8Array(enc, string.encodeUtf8(JSON.stringify(frame.payload)));
49
- }
50
- return encoding.toUint8Array(enc);
51
- }
52
- /** Throws `SyncCodecError` on any malformed or schema-invalid frame. */
53
- export function decodeFrame(bytes) {
54
- let candidate;
55
- try {
56
- const dec = decoding.createDecoder(bytes);
57
- const channelId = decoding.readVarUint(dec);
58
- const code = decoding.readVarUint(dec);
59
- const type = NAME_BY_CODE.get(code);
60
- if (!type)
61
- throw new SyncCodecError(`unknown message type ${code}`);
62
- let payload;
63
- if (BINARY_TYPES.has(type)) {
64
- payload = decoding.readVarUint8Array(dec);
65
- if (decoding.hasContent(dec))
66
- throw new SyncCodecError('trailing bytes');
67
- }
68
- else if (type === 'update') {
69
- const epoch = decoding.readVarUint(dec);
70
- const clientSeq = decoding.readVarUint(dec);
71
- const update = decoding.readVarUint8Array(dec);
72
- if (decoding.hasContent(dec))
73
- throw new SyncCodecError('trailing bytes');
74
- payload = { epoch, clientSeq, update };
75
- }
76
- else {
77
- const rest = decoding.readTailAsUint8Array(dec);
78
- payload = JSON.parse(string.decodeUtf8(rest));
79
- }
80
- candidate = { channelId, type, payload };
81
- }
82
- catch (e) {
83
- if (e instanceof SyncCodecError)
84
- throw e;
85
- throw new SyncCodecError(`malformed frame: ${e instanceof Error ? e.message : String(e)}`);
86
- }
87
- const parsed = syncFrameSchema.safeParse(candidate);
88
- if (!parsed.success)
89
- throw new SyncCodecError(`invalid frame: ${parsed.error.message}`);
90
- return parsed.data;
91
- }