@listen1954/board 0.1.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/lib/domain.js ADDED
@@ -0,0 +1,669 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ export const SCHEMA_VERSION = 1;
3
+ export class BoardNotFoundError extends Error {
4
+ code = 'BOARD_NOT_FOUND';
5
+ constructor() {
6
+ super('board not found');
7
+ this.name = 'BoardNotFoundError';
8
+ }
9
+ }
10
+ export class BoardOpError extends Error {
11
+ code = 'BOARD_OP';
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = 'BoardOpError';
15
+ }
16
+ }
17
+ export class BoardRevisionError extends Error {
18
+ code = 'BOARD_REVISION';
19
+ constructor(expected, actual) {
20
+ super(`board revision mismatch (got ${String(actual)}, expected ${String(expected)})`);
21
+ this.name = 'BoardRevisionError';
22
+ }
23
+ }
24
+ const SESSION_ID_RE = /^[A-Za-z0-9._-]+$/;
25
+ const REMINDER_STATUSES = new Set(['scheduled', 'fired', 'cancelled']);
26
+ export function isSessionId(value) {
27
+ return SESSION_ID_RE.test(value);
28
+ }
29
+ export function assertSessionId(sessionId) {
30
+ if (!isSessionId(sessionId))
31
+ throw new BoardNotFoundError();
32
+ }
33
+ export function newId(kind) {
34
+ return `${kind}_${randomBytes(6).toString('hex')}`;
35
+ }
36
+ export function emptyBoard(scope, sessionId, now = new Date()) {
37
+ const updatedAt = now.toISOString();
38
+ if (scope === 'session') {
39
+ if (sessionId === undefined)
40
+ throw new BoardOpError('session board requires a session id');
41
+ assertSessionId(sessionId);
42
+ return {
43
+ schemaVersion: SCHEMA_VERSION,
44
+ id: `session:${sessionId}`,
45
+ scope: 'session',
46
+ sessionId,
47
+ title: 'Session',
48
+ columns: [],
49
+ cards: [],
50
+ revision: 0,
51
+ updatedAt,
52
+ sessionColumnsMigrated: false,
53
+ };
54
+ }
55
+ return {
56
+ schemaVersion: SCHEMA_VERSION,
57
+ id: 'global',
58
+ scope: 'global',
59
+ title: 'Global',
60
+ columns: [],
61
+ cards: [],
62
+ revision: 0,
63
+ updatedAt,
64
+ sessionColumnsMigrated: false,
65
+ };
66
+ }
67
+ export function isEmptyBoard(board) {
68
+ return board.columns.length === 0;
69
+ }
70
+ export function assertReadable(callerSessionId, board) {
71
+ if (board.scope === 'global')
72
+ return;
73
+ if (board.sessionId === callerSessionId)
74
+ return;
75
+ throw new BoardNotFoundError();
76
+ }
77
+ export function summarizeBoard(board) {
78
+ const columns = [...board.columns]
79
+ .sort((a, b) => a.order - b.order)
80
+ .map((column) => ({
81
+ id: column.id,
82
+ title: column.title,
83
+ ...(column.sessionId === undefined ? {} : { sessionId: column.sessionId }),
84
+ cards: board.cards
85
+ .filter((card) => card.columnId === column.id && card.archivedAt === null)
86
+ .sort((a, b) => a.order - b.order)
87
+ .map((card) => ({
88
+ id: card.id,
89
+ title: card.title,
90
+ reminderStatus: card.reminder?.status ?? null,
91
+ })),
92
+ }));
93
+ return {
94
+ scope: board.scope,
95
+ id: board.id,
96
+ title: board.title,
97
+ revision: board.revision,
98
+ updatedAt: board.updatedAt,
99
+ empty: isEmptyBoard(board),
100
+ reminderFiredCount: board.cards.filter((card) => card.archivedAt === null && card.reminder?.status === 'fired').length,
101
+ archivedCount: board.cards.filter((card) => card.archivedAt !== null).length,
102
+ columns,
103
+ };
104
+ }
105
+ export function parseBoard(raw) {
106
+ if (!isRecord(raw))
107
+ throw new BoardOpError('invalid board: expected an object');
108
+ if (raw.schemaVersion !== SCHEMA_VERSION) {
109
+ throw new BoardOpError(`unsupported board schemaVersion: ${String(raw.schemaVersion)}`);
110
+ }
111
+ if (raw.scope !== 'global' && raw.scope !== 'session') {
112
+ throw new BoardOpError('invalid board scope');
113
+ }
114
+ if (typeof raw.id !== 'string' || raw.id.length === 0) {
115
+ throw new BoardOpError('invalid board id');
116
+ }
117
+ if (typeof raw.title !== 'string')
118
+ throw new BoardOpError('invalid board title');
119
+ const revision = asSafeInt(raw.revision, 'invalid board revision');
120
+ if (revision < 0)
121
+ throw new BoardOpError('invalid board revision');
122
+ if (typeof raw.updatedAt !== 'string' || Number.isNaN(Date.parse(raw.updatedAt))) {
123
+ throw new BoardOpError('invalid board updatedAt');
124
+ }
125
+ if (!Array.isArray(raw.columns))
126
+ throw new BoardOpError('invalid board columns');
127
+ if (!Array.isArray(raw.cards))
128
+ throw new BoardOpError('invalid board cards');
129
+ if (raw.sessionColumnsMigrated !== undefined && typeof raw.sessionColumnsMigrated !== 'boolean') {
130
+ throw new BoardOpError('invalid board sessionColumnsMigrated');
131
+ }
132
+ const columns = raw.columns.map((item, index) => parseColumn(item, index));
133
+ const columnIds = new Set(columns.map((column) => column.id));
134
+ if (columnIds.size !== columns.length)
135
+ throw new BoardOpError('duplicate column id');
136
+ const linkedSessionIds = columns.flatMap((column) => column.sessionId === undefined ? [] : [column.sessionId]);
137
+ if (new Set(linkedSessionIds).size !== linkedSessionIds.length)
138
+ throw new BoardOpError('duplicate session task column');
139
+ const cards = raw.cards.map((item, index) => parseCard(item, index, new Map(columns.map((column) => [column.id, column]))));
140
+ const cardIds = new Set(cards.map((card) => card.id));
141
+ if (cardIds.size !== cards.length)
142
+ throw new BoardOpError('duplicate card id');
143
+ if (raw.scope === 'global') {
144
+ if (raw.id !== 'global')
145
+ throw new BoardOpError('global board id must be "global"');
146
+ if (raw.sessionId !== undefined)
147
+ throw new BoardOpError('global board must not have sessionId');
148
+ return {
149
+ schemaVersion: SCHEMA_VERSION,
150
+ id: 'global',
151
+ scope: 'global',
152
+ title: raw.title,
153
+ columns,
154
+ cards,
155
+ revision,
156
+ updatedAt: raw.updatedAt,
157
+ sessionColumnsMigrated: raw.sessionColumnsMigrated === true,
158
+ };
159
+ }
160
+ if (typeof raw.sessionId !== 'string' || !isSessionId(raw.sessionId)) {
161
+ throw new BoardOpError('invalid sessionId');
162
+ }
163
+ if (raw.id !== `session:${raw.sessionId}`) {
164
+ throw new BoardOpError('session board id must be session:<sessionId>');
165
+ }
166
+ return {
167
+ schemaVersion: SCHEMA_VERSION,
168
+ id: raw.id,
169
+ scope: 'session',
170
+ sessionId: raw.sessionId,
171
+ title: raw.title,
172
+ columns,
173
+ cards,
174
+ revision,
175
+ updatedAt: raw.updatedAt,
176
+ sessionColumnsMigrated: false,
177
+ };
178
+ }
179
+ export function applyMutate(board, op, now = new Date(), createId = newId) {
180
+ const next = structuredClone(board);
181
+ const at = now.toISOString();
182
+ switch (op.type) {
183
+ case 'addColumn': {
184
+ const title = requireTitle(op.title, 'column');
185
+ next.columns.push({ id: createId('col'), title, order: nextOrder(next.columns) });
186
+ break;
187
+ }
188
+ case 'renameColumn': {
189
+ const column = requireColumn(next, op.columnId);
190
+ column.title = requireTitle(op.title, 'column');
191
+ break;
192
+ }
193
+ case 'reorderColumns': {
194
+ reorderColumns(next, op.columnIds);
195
+ break;
196
+ }
197
+ case 'deleteColumn': {
198
+ if (op.confirm !== true)
199
+ throw new BoardOpError('deleteColumn requires confirm: true');
200
+ requireColumn(next, op.columnId);
201
+ next.columns = next.columns.filter((column) => column.id !== op.columnId);
202
+ next.cards = next.cards.filter((card) => card.columnId !== op.columnId || card.archivedAt !== null);
203
+ reindex(next.columns);
204
+ break;
205
+ }
206
+ case 'addCard': {
207
+ requireColumn(next, op.columnId);
208
+ const title = requireTitle(op.title, 'card');
209
+ const siblings = cardsIn(next, op.columnId);
210
+ const startAt = op.startAt === undefined ? null : requireIso(op.startAt);
211
+ const endAt = op.endAt === undefined ? null : requireIso(op.endAt);
212
+ requireValidTimeRange(startAt, endAt);
213
+ next.cards.push({
214
+ id: createId('card'),
215
+ columnId: op.columnId,
216
+ title,
217
+ body: op.body ?? '',
218
+ order: nextOrder(siblings),
219
+ startAt,
220
+ endAt,
221
+ reminder: op.reminder === undefined ? null : {
222
+ at: requireIso(op.reminder.at),
223
+ text: op.reminder.text ?? '',
224
+ status: 'scheduled',
225
+ },
226
+ timeReminder: createTimeReminder(op.timeReminder === true, startAt, endAt, at),
227
+ logs: [],
228
+ archivedAt: null,
229
+ archivedColumn: null,
230
+ createdAt: at,
231
+ updatedAt: at,
232
+ });
233
+ break;
234
+ }
235
+ case 'updateCard': {
236
+ const card = requireCard(next, op.cardId);
237
+ if (op.title === undefined && op.body === undefined && op.startAt === undefined && op.endAt === undefined && op.timeReminder === undefined && op.reminder === undefined) {
238
+ throw new BoardOpError('updateCard requires title, body, time range, or reminder');
239
+ }
240
+ const previousStartAt = card.startAt;
241
+ const previousEndAt = card.endAt;
242
+ if (op.title !== undefined)
243
+ card.title = requireTitle(op.title, 'card');
244
+ if (op.body !== undefined)
245
+ card.body = op.body;
246
+ if (op.startAt !== undefined)
247
+ card.startAt = op.startAt === null ? null : requireIso(op.startAt);
248
+ if (op.endAt !== undefined)
249
+ card.endAt = op.endAt === null ? null : requireIso(op.endAt);
250
+ requireValidTimeRange(card.startAt, card.endAt);
251
+ if (op.timeReminder !== undefined || card.startAt !== previousStartAt || card.endAt !== previousEndAt) {
252
+ card.timeReminder = updateTimeReminder(card.timeReminder, op.timeReminder ?? card.timeReminder.enabled, previousStartAt, previousEndAt, card.startAt, card.endAt, at);
253
+ }
254
+ if (op.reminder !== undefined) {
255
+ if (op.reminder === null) {
256
+ card.reminder = null;
257
+ }
258
+ else {
259
+ const at = requireIso(op.reminder.at);
260
+ const text = op.reminder.text ?? '';
261
+ if (card.reminder?.at !== at || card.reminder.text !== text) {
262
+ card.reminder = { at, text, status: 'scheduled' };
263
+ }
264
+ }
265
+ }
266
+ card.updatedAt = at;
267
+ break;
268
+ }
269
+ case 'addLog': {
270
+ const card = requireCard(next, op.cardId);
271
+ const text = op.text.trim();
272
+ const attachments = (op.attachments ?? []).map(validateLogAttachment);
273
+ if (text.length === 0 && attachments.length === 0)
274
+ throw new BoardOpError('log requires text or attachments');
275
+ card.logs.push({ id: createId('log'), text, createdAt: at, attachments });
276
+ card.updatedAt = at;
277
+ break;
278
+ }
279
+ case 'moveCard': {
280
+ moveCard(next, op.cardId, op.columnId, op.index);
281
+ requireCard(next, op.cardId).updatedAt = at;
282
+ break;
283
+ }
284
+ case 'archiveCard': {
285
+ const card = requireCard(next, op.cardId);
286
+ if (card.archivedAt !== null)
287
+ throw new BoardOpError('card is already archived');
288
+ const column = requireColumn(next, card.columnId);
289
+ const columnId = card.columnId;
290
+ card.archivedAt = at;
291
+ card.archivedColumn = { id: column.id, title: column.title, ...(column.sessionId === undefined ? {} : { sessionId: column.sessionId }) };
292
+ card.updatedAt = at;
293
+ reindex(cardsIn(next, columnId));
294
+ break;
295
+ }
296
+ case 'restoreCard': {
297
+ const card = requireCard(next, op.cardId);
298
+ requireColumn(next, op.columnId);
299
+ if (card.archivedAt === null)
300
+ throw new BoardOpError('card is not archived');
301
+ card.columnId = op.columnId;
302
+ card.order = nextOrder(cardsIn(next, op.columnId));
303
+ card.archivedAt = null;
304
+ card.archivedColumn = null;
305
+ card.updatedAt = at;
306
+ break;
307
+ }
308
+ case 'restoreCards': {
309
+ if (op.cards.length === 0)
310
+ throw new BoardOpError('cards must not be empty');
311
+ const seen = new Set();
312
+ for (const item of op.cards) {
313
+ if (seen.has(item.cardId))
314
+ throw new BoardOpError('card ids must be unique');
315
+ seen.add(item.cardId);
316
+ const card = requireCard(next, item.cardId);
317
+ requireColumn(next, item.columnId);
318
+ if (card.archivedAt === null)
319
+ throw new BoardOpError('card is not archived');
320
+ }
321
+ for (const item of op.cards) {
322
+ const card = requireCard(next, item.cardId);
323
+ card.columnId = item.columnId;
324
+ card.order = nextOrder(cardsIn(next, item.columnId));
325
+ card.archivedAt = null;
326
+ card.archivedColumn = null;
327
+ card.updatedAt = at;
328
+ }
329
+ break;
330
+ }
331
+ case 'deleteCard': {
332
+ requireCard(next, op.cardId);
333
+ const columnId = requireCard(next, op.cardId).columnId;
334
+ next.cards = next.cards.filter((card) => card.id !== op.cardId);
335
+ reindex(cardsIn(next, columnId));
336
+ break;
337
+ }
338
+ case 'deleteCards': {
339
+ if (op.cardIds.length === 0)
340
+ throw new BoardOpError('cardIds must not be empty');
341
+ const ids = new Set(op.cardIds);
342
+ if (ids.size !== op.cardIds.length)
343
+ throw new BoardOpError('card ids must be unique');
344
+ const columnIds = new Set(op.cardIds.map((cardId) => requireCard(next, cardId).columnId));
345
+ next.cards = next.cards.filter((card) => !ids.has(card.id));
346
+ for (const columnId of columnIds)
347
+ reindex(cardsIn(next, columnId));
348
+ break;
349
+ }
350
+ case 'setReminder': {
351
+ const card = requireCard(next, op.cardId);
352
+ card.reminder = {
353
+ at: requireIso(op.at),
354
+ text: op.text ?? '',
355
+ status: 'scheduled',
356
+ };
357
+ card.updatedAt = at;
358
+ break;
359
+ }
360
+ case 'clearReminder': {
361
+ const card = requireCard(next, op.cardId);
362
+ card.reminder = null;
363
+ card.updatedAt = at;
364
+ break;
365
+ }
366
+ default: {
367
+ const neverOp = op;
368
+ throw new BoardOpError(`unknown mutate op: ${JSON.stringify(neverOp)}`);
369
+ }
370
+ }
371
+ next.revision += 1;
372
+ next.updatedAt = at;
373
+ return next;
374
+ }
375
+ export function fireDueReminders(board, now = new Date()) {
376
+ const at = now.toISOString();
377
+ const nowMs = now.getTime();
378
+ const next = structuredClone(board);
379
+ const fired = [];
380
+ for (const card of next.cards) {
381
+ if (card.archivedAt !== null || !card.timeReminder.enabled)
382
+ continue;
383
+ const previousCount = fired.length;
384
+ if (card.startAt !== null && card.timeReminder.startFiredAt === undefined && Date.parse(card.startAt) <= nowMs) {
385
+ card.timeReminder.startFiredAt = at;
386
+ fired.push({ cardId: card.id, title: card.title, kind: 'start', at: card.startAt, firedAt: at });
387
+ }
388
+ if (card.endAt !== null && card.timeReminder.endFiredAt === undefined && Date.parse(card.endAt) <= nowMs) {
389
+ card.timeReminder.endFiredAt = at;
390
+ fired.push({ cardId: card.id, title: card.title, kind: 'end', at: card.endAt, firedAt: at });
391
+ }
392
+ if (fired.length > previousCount)
393
+ card.updatedAt = at;
394
+ }
395
+ if (fired.length > 0) {
396
+ next.revision += 1;
397
+ next.updatedAt = at;
398
+ }
399
+ return { board: next, fired };
400
+ }
401
+ function parseColumn(raw, index) {
402
+ if (!isRecord(raw))
403
+ throw new BoardOpError(`invalid column at ${String(index)}`);
404
+ if (typeof raw.id !== 'string' || !raw.id.startsWith('col_')) {
405
+ throw new BoardOpError(`invalid column id at ${String(index)}`);
406
+ }
407
+ if (typeof raw.title !== 'string')
408
+ throw new BoardOpError(`invalid column title at ${String(index)}`);
409
+ if (raw.sessionId !== undefined && (typeof raw.sessionId !== 'string' || !isSessionId(raw.sessionId))) {
410
+ throw new BoardOpError(`invalid column sessionId at ${String(index)}`);
411
+ }
412
+ return { id: raw.id, title: raw.title, order: asSafeInt(raw.order, `invalid column order at ${String(index)}`), ...(raw.sessionId === undefined ? {} : { sessionId: raw.sessionId }) };
413
+ }
414
+ function parseCard(raw, index, columns) {
415
+ if (!isRecord(raw))
416
+ throw new BoardOpError(`invalid card at ${String(index)}`);
417
+ if (typeof raw.id !== 'string' || !raw.id.startsWith('card_')) {
418
+ throw new BoardOpError(`invalid card id at ${String(index)}`);
419
+ }
420
+ if (typeof raw.columnId !== 'string')
421
+ throw new BoardOpError(`invalid card columnId at ${String(index)}`);
422
+ if (typeof raw.title !== 'string' || raw.title.trim().length === 0) {
423
+ throw new BoardOpError(`invalid card title at ${String(index)}`);
424
+ }
425
+ if (typeof raw.body !== 'string')
426
+ throw new BoardOpError(`invalid card body at ${String(index)}`);
427
+ const order = asSafeInt(raw.order, `invalid card order at ${String(index)}`);
428
+ if (typeof raw.createdAt !== 'string' || Number.isNaN(Date.parse(raw.createdAt))) {
429
+ throw new BoardOpError(`invalid card createdAt at ${String(index)}`);
430
+ }
431
+ if (typeof raw.updatedAt !== 'string' || Number.isNaN(Date.parse(raw.updatedAt))) {
432
+ throw new BoardOpError(`invalid card updatedAt at ${String(index)}`);
433
+ }
434
+ let archivedAt = null;
435
+ if (raw.archivedAt !== undefined && raw.archivedAt !== null) {
436
+ if (typeof raw.archivedAt !== 'string' || Number.isNaN(Date.parse(raw.archivedAt))) {
437
+ throw new BoardOpError(`invalid card archivedAt at ${String(index)}`);
438
+ }
439
+ archivedAt = new Date(raw.archivedAt).toISOString();
440
+ }
441
+ const currentColumn = columns.get(raw.columnId);
442
+ if (archivedAt === null && currentColumn === undefined)
443
+ throw new BoardOpError(`card ${raw.id} references a missing column`);
444
+ const archivedColumn = archivedAt === null ? null : parseArchivedColumn(raw.archivedColumn, raw.columnId, currentColumn, raw.id);
445
+ const startAt = parseOptionalIso(raw.startAt, `invalid card startAt at ${String(index)}`);
446
+ const endAt = parseOptionalIso(raw.endAt, `invalid card endAt at ${String(index)}`);
447
+ requireValidTimeRange(startAt, endAt);
448
+ return {
449
+ id: raw.id,
450
+ columnId: raw.columnId,
451
+ title: raw.title,
452
+ body: raw.body,
453
+ order,
454
+ startAt,
455
+ endAt,
456
+ reminder: parseReminder(raw.reminder, raw.id),
457
+ timeReminder: parseTimeReminder(raw.timeReminder, raw.id),
458
+ logs: raw.logs === undefined ? [] : parseLogs(raw.logs, raw.id),
459
+ archivedAt,
460
+ archivedColumn,
461
+ createdAt: raw.createdAt,
462
+ updatedAt: raw.updatedAt,
463
+ };
464
+ }
465
+ function parseArchivedColumn(raw, columnId, current, cardId) {
466
+ if (raw === undefined || raw === null) {
467
+ if (current === undefined)
468
+ throw new BoardOpError(`archived card ${cardId} has no column snapshot`);
469
+ return { id: current.id, title: current.title, ...(current.sessionId === undefined ? {} : { sessionId: current.sessionId }) };
470
+ }
471
+ if (!isRecord(raw) || raw.id !== columnId || typeof raw.title !== 'string') {
472
+ throw new BoardOpError(`invalid archived column on ${cardId}`);
473
+ }
474
+ if (raw.sessionId !== undefined && (typeof raw.sessionId !== 'string' || !isSessionId(raw.sessionId))) {
475
+ throw new BoardOpError(`invalid archived column sessionId on ${cardId}`);
476
+ }
477
+ const sessionId = raw.sessionId ?? current?.sessionId;
478
+ return { id: columnId, title: raw.title, ...(sessionId === undefined ? {} : { sessionId }) };
479
+ }
480
+ function parseTimeReminder(raw, cardId) {
481
+ if (raw === undefined)
482
+ return { enabled: false };
483
+ if (!isRecord(raw) || typeof raw.enabled !== 'boolean')
484
+ throw new BoardOpError(`invalid timeReminder on ${cardId}`);
485
+ const value = { enabled: raw.enabled };
486
+ for (const key of ['startFiredAt', 'endFiredAt']) {
487
+ const item = raw[key];
488
+ if (item === undefined)
489
+ continue;
490
+ if (typeof item !== 'string' || Number.isNaN(Date.parse(item)))
491
+ throw new BoardOpError(`invalid timeReminder.${key} on ${cardId}`);
492
+ value[key] = new Date(item).toISOString();
493
+ }
494
+ return value;
495
+ }
496
+ function createTimeReminder(enabled, startAt, endAt, now) {
497
+ const value = { enabled };
498
+ if (!enabled)
499
+ return value;
500
+ if (startAt !== null && Date.parse(startAt) <= Date.parse(now))
501
+ value.startFiredAt = now;
502
+ if (endAt !== null && Date.parse(endAt) <= Date.parse(now))
503
+ value.endFiredAt = now;
504
+ return value;
505
+ }
506
+ function updateTimeReminder(current, enabled, previousStartAt, previousEndAt, startAt, endAt, now) {
507
+ const value = createTimeReminder(enabled, startAt, endAt, now);
508
+ if (!enabled)
509
+ return value;
510
+ if (startAt !== null && startAt === previousStartAt && current.startFiredAt !== undefined)
511
+ value.startFiredAt = current.startFiredAt;
512
+ if (endAt !== null && endAt === previousEndAt && current.endFiredAt !== undefined)
513
+ value.endFiredAt = current.endFiredAt;
514
+ return value;
515
+ }
516
+ export function defaultGlobalBoard(now = new Date(), createId = newId, titles = ['To do', 'In progress', 'Uncategorized']) {
517
+ const board = emptyBoard('global', undefined, now);
518
+ board.columns = titles.map((title, order) => ({ id: createId('col'), title, order }));
519
+ return board;
520
+ }
521
+ function parseLogs(raw, cardId) {
522
+ if (!Array.isArray(raw))
523
+ throw new BoardOpError(`invalid logs on ${cardId}`);
524
+ return raw.map((item, index) => {
525
+ if (!isRecord(item) || typeof item.id !== 'string' || !item.id.startsWith('log_'))
526
+ throw new BoardOpError(`invalid log at ${String(index)} on ${cardId}`);
527
+ if (typeof item.text !== 'string')
528
+ throw new BoardOpError(`invalid log text at ${String(index)} on ${cardId}`);
529
+ if (typeof item.createdAt !== 'string' || Number.isNaN(Date.parse(item.createdAt)))
530
+ throw new BoardOpError(`invalid log createdAt at ${String(index)} on ${cardId}`);
531
+ if (!Array.isArray(item.attachments))
532
+ throw new BoardOpError(`invalid log attachments at ${String(index)} on ${cardId}`);
533
+ return { id: item.id, text: item.text, createdAt: new Date(item.createdAt).toISOString(), attachments: item.attachments.map(validateLogAttachment) };
534
+ });
535
+ }
536
+ function validateLogAttachment(raw) {
537
+ if (!isRecord(raw) || typeof raw.id !== 'string' || !raw.id.startsWith('attachment_'))
538
+ throw new BoardOpError('invalid log attachment id');
539
+ if (typeof raw.name !== 'string' || raw.name.trim().length === 0)
540
+ throw new BoardOpError('invalid log attachment name');
541
+ if (typeof raw.type !== 'string')
542
+ throw new BoardOpError('invalid log attachment type');
543
+ if (typeof raw.size !== 'number' || !Number.isSafeInteger(raw.size) || raw.size < 0)
544
+ throw new BoardOpError('invalid log attachment size');
545
+ if (raw.preview !== undefined && (typeof raw.preview !== 'string' || !/^data:image\/(?:jpeg|png|webp);base64,/.test(raw.preview)))
546
+ throw new BoardOpError('invalid log attachment preview');
547
+ return { id: raw.id, name: raw.name, type: raw.type, size: raw.size, ...(raw.preview === undefined ? {} : { preview: raw.preview }) };
548
+ }
549
+ function parseReminder(raw, cardId) {
550
+ if (raw === null || raw === undefined)
551
+ return null;
552
+ if (!isRecord(raw))
553
+ throw new BoardOpError(`invalid reminder on ${cardId}`);
554
+ if (typeof raw.at !== 'string' || Number.isNaN(Date.parse(raw.at))) {
555
+ throw new BoardOpError(`invalid reminder.at on ${cardId}`);
556
+ }
557
+ if (typeof raw.text !== 'string')
558
+ throw new BoardOpError(`invalid reminder.text on ${cardId}`);
559
+ if (typeof raw.status !== 'string' || !REMINDER_STATUSES.has(raw.status)) {
560
+ throw new BoardOpError(`invalid reminder.status on ${cardId}`);
561
+ }
562
+ const reminder = {
563
+ at: new Date(raw.at).toISOString(),
564
+ text: raw.text,
565
+ status: raw.status,
566
+ };
567
+ if (raw.firedAt !== undefined) {
568
+ if (typeof raw.firedAt !== 'string' || Number.isNaN(Date.parse(raw.firedAt))) {
569
+ throw new BoardOpError(`invalid reminder.firedAt on ${cardId}`);
570
+ }
571
+ reminder.firedAt = new Date(raw.firedAt).toISOString();
572
+ }
573
+ return reminder;
574
+ }
575
+ function requireTitle(value, kind) {
576
+ const title = value.trim();
577
+ if (title.length === 0)
578
+ throw new BoardOpError(`${kind} title must be non-empty`);
579
+ return title;
580
+ }
581
+ function requireIso(value) {
582
+ const ms = Date.parse(value);
583
+ if (Number.isNaN(ms))
584
+ throw new BoardOpError('reminder.at must be an ISO-8601 datetime');
585
+ return new Date(ms).toISOString();
586
+ }
587
+ function parseOptionalIso(value, message) {
588
+ if (value === undefined || value === null)
589
+ return null;
590
+ if (typeof value !== 'string' || Number.isNaN(Date.parse(value)))
591
+ throw new BoardOpError(message);
592
+ return new Date(value).toISOString();
593
+ }
594
+ function requireValidTimeRange(startAt, endAt) {
595
+ if (startAt !== null && endAt !== null && Date.parse(startAt) >= Date.parse(endAt)) {
596
+ throw new BoardOpError('card startAt must be before endAt');
597
+ }
598
+ }
599
+ function requireColumn(board, columnId) {
600
+ const column = board.columns.find((item) => item.id === columnId);
601
+ if (column === undefined)
602
+ throw new BoardOpError(`column not found: ${columnId}`);
603
+ return column;
604
+ }
605
+ function requireCard(board, cardId) {
606
+ const card = board.cards.find((item) => item.id === cardId);
607
+ if (card === undefined)
608
+ throw new BoardOpError(`card not found: ${cardId}`);
609
+ return card;
610
+ }
611
+ function cardsIn(board, columnId) {
612
+ return board.cards.filter((card) => card.columnId === columnId && card.archivedAt === null);
613
+ }
614
+ function nextOrder(items) {
615
+ if (items.length === 0)
616
+ return 0;
617
+ return Math.max(...items.map((item) => item.order)) + 1;
618
+ }
619
+ function reindex(items) {
620
+ const sorted = [...items].sort((a, b) => a.order - b.order);
621
+ sorted.forEach((item, index) => {
622
+ item.order = index;
623
+ });
624
+ }
625
+ function reorderColumns(board, columnIds) {
626
+ if (columnIds.length !== board.columns.length) {
627
+ throw new BoardOpError('reorderColumns must list every column exactly once');
628
+ }
629
+ const seen = new Set();
630
+ for (const id of columnIds) {
631
+ if (seen.has(id))
632
+ throw new BoardOpError('reorderColumns has a duplicate column id');
633
+ seen.add(id);
634
+ requireColumn(board, id);
635
+ }
636
+ columnIds.forEach((id, index) => {
637
+ requireColumn(board, id).order = index;
638
+ });
639
+ }
640
+ function moveCard(board, cardId, columnId, index) {
641
+ requireColumn(board, columnId);
642
+ const card = requireCard(board, cardId);
643
+ if (card.archivedAt !== null)
644
+ throw new BoardOpError('archived card cannot be moved');
645
+ if (!Number.isSafeInteger(index) || index < 0) {
646
+ throw new BoardOpError('moveCard index must be a non-negative integer');
647
+ }
648
+ const fromId = card.columnId;
649
+ const dest = cardsIn(board, columnId)
650
+ .filter((item) => item.id !== cardId)
651
+ .sort((a, b) => a.order - b.order);
652
+ if (index > dest.length)
653
+ throw new BoardOpError('moveCard index is out of range');
654
+ dest.splice(index, 0, card);
655
+ card.columnId = columnId;
656
+ dest.forEach((item, order) => {
657
+ item.order = order;
658
+ });
659
+ if (fromId !== columnId)
660
+ reindex(cardsIn(board, fromId));
661
+ }
662
+ function isRecord(value) {
663
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
664
+ }
665
+ function asSafeInt(value, message) {
666
+ if (typeof value !== 'number' || !Number.isSafeInteger(value))
667
+ throw new BoardOpError(message);
668
+ return value;
669
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import Schema from '@deepseek-ai/schemastery';
3
+ export declare const name = "board";
4
+ export declare const inject: string[];
5
+ export interface Config {
6
+ wakeOnGlobalReminder: boolean;
7
+ }
8
+ export declare const Config: Schema<Config>;
9
+ export declare function apply(ctx: Context, config?: Config): void;
10
+ export type { AttachmentDownload, AttachmentUpload, ArchivedColumn, Board, BoardScope, CardLog, Card, Column, LogAttachment, MutateOp, Reminder, } from './domain.js';
11
+ export { BoardNotFoundError, BoardOpError, BoardRevisionError } from './domain.js';
12
+ export { BoardModel, BoardService } from './service.js';
13
+ export { BoardStore, resolveBoardRoot, resolveDshHome } from './store.js';