@skillre/dsh-plugin-sticky-wall 0.0.0-stage → 0.2.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.
package/lib/store.d.ts ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Durable note store over the DSH storage domain.
3
+ *
4
+ * The Host owns all data: the Client renders snapshots and submits intents, and
5
+ * every write goes through {@link StickyWallStore} — validate, persist, then
6
+ * hand back the changed records. The storage domain provides a single
7
+ * per-domain write chain, so concurrent writes cannot interleave, and its
8
+ * `per-record` layout keeps one file per note: one damaged record cannot take
9
+ * the whole wall down.
10
+ *
11
+ * The domain spec is mirrored structurally instead of imported: importing
12
+ * `@deepseek-ai/dsh-storage-domain` would install a second copy of the domain
13
+ * package (and its zod) inside the plugin instead of resolving the host's. The
14
+ * mirrored shape is the contract the live Host validates at `open()`:
15
+ * `{ name, version, layout, invalidRecords, tables: { <name>: { valueSchema } } }`
16
+ * with zod record schemas.
17
+ */
18
+ import { z } from 'zod';
19
+ import { type Note, type NoteInput, type NoteLayout, type NotePatch } from './types.js';
20
+ /** Failure carrying a stable code the transport maps onto the RPC envelope. */
21
+ export declare class WallError extends Error {
22
+ readonly code: string;
23
+ readonly details: Record<string, unknown>;
24
+ constructor(code: string, message: string, details?: Record<string, unknown>);
25
+ }
26
+ /** The domain declaration handed to `ctx.storageDomain.open()`. */
27
+ export declare const DOMAIN_SPEC: {
28
+ name: string;
29
+ version: number;
30
+ layout: "per-record";
31
+ invalidRecords: "backup-and-skip";
32
+ tables: {
33
+ notes: {
34
+ valueSchema: z.ZodObject<{
35
+ id: z.ZodString;
36
+ title: z.ZodDefault<z.ZodString>;
37
+ body: z.ZodString;
38
+ tags: z.ZodDefault<z.ZodArray<z.ZodString>>;
39
+ color: z.ZodDefault<z.ZodEnum<{
40
+ yellow: "yellow";
41
+ pink: "pink";
42
+ green: "green";
43
+ blue: "blue";
44
+ purple: "purple";
45
+ orange: "orange";
46
+ }>>;
47
+ pinned: z.ZodDefault<z.ZodBoolean>;
48
+ archived: z.ZodDefault<z.ZodBoolean>;
49
+ createdAt: z.ZodString;
50
+ updatedAt: z.ZodString;
51
+ layout: z.ZodDefault<z.ZodNullable<z.ZodObject<{
52
+ x: z.ZodNumber;
53
+ y: z.ZodNumber;
54
+ rotate: z.ZodNumber;
55
+ width: z.ZodEnum<{
56
+ sm: "sm";
57
+ md: "md";
58
+ lg: "lg";
59
+ }>;
60
+ }, z.core.$strip>>>;
61
+ }, z.core.$loose>;
62
+ };
63
+ };
64
+ };
65
+ /** The minimal domain table face this store uses; the rest is unused here. */
66
+ export interface DomainTable {
67
+ get(key: string): unknown;
68
+ entries(): Iterable<[string, unknown]>;
69
+ keys(): Iterable<string>;
70
+ readonly size: number;
71
+ put(key: string, value: Note): Promise<void>;
72
+ update(key: string, transform: (value: Note) => Note): Promise<Note>;
73
+ delete(key: string): Promise<boolean>;
74
+ }
75
+ /** The opened-domain handle the facility returns. */
76
+ export interface DomainHandle {
77
+ table(name: string): DomainTable;
78
+ close(): Promise<void>;
79
+ }
80
+ /** The store facade the Host endpoint and the agent tool share. */
81
+ export declare class StickyWallStore {
82
+ private readonly domain;
83
+ private readonly maxNotes;
84
+ private readonly now;
85
+ private readonly notes;
86
+ private closed;
87
+ /**
88
+ * Store-level mutation chain.
89
+ *
90
+ * The storage domain serializes its own writes, but a read-then-write pair
91
+ * (the `maxNotes` check followed by the `put`) is two steps: two concurrent
92
+ * creates at the boundary would both read the pre-write count and both
93
+ * succeed. Every mutation therefore runs on this chain, so each check and its
94
+ * write are one indivisible step.
95
+ */
96
+ private chain;
97
+ constructor(domain: DomainHandle, maxNotes: number, now?: () => Date);
98
+ /** Open one domain and wrap it in a store. */
99
+ static open(open: (spec: typeof DOMAIN_SPEC) => Promise<DomainHandle>, options: {
100
+ maxNotes: number;
101
+ seedWelcomeNote: boolean;
102
+ now?: () => Date;
103
+ }): Promise<StickyWallStore>;
104
+ /** Release the domain handle; later writes fail instead of silently dropping. */
105
+ close(): Promise<void>;
106
+ private assertOpen;
107
+ /** Queue one mutation so its checks and writes cannot interleave with another. */
108
+ private mutate;
109
+ private liveCount;
110
+ /**
111
+ * Reject a write that would take the live (non-archived) note count past
112
+ * `maxNotes`.
113
+ *
114
+ * Every path that can make a note live goes through this — `create`, an
115
+ * `update` that unarchives, and a batch restore — because the documented cap
116
+ * is an invariant of the wall, not a hint on the create form.
117
+ */
118
+ private assertRoom;
119
+ /** Write the one-time welcome note on a brand-new wall. */
120
+ private seedWelcomeNote;
121
+ /** Every stored note, newest-relevant first; archived ones only on request. */
122
+ list(includeArchived?: boolean, sort?: 'pinned' | 'created' | 'updated'): Note[];
123
+ /** Create one note from a Client or agent submission. */
124
+ create(input: NoteInput, id?: string): Promise<Note>;
125
+ private createNow;
126
+ /** Apply one patch; `updatedAt` moves only when a field actually changed. */
127
+ update(id: string, patch: NotePatch): Promise<Note>;
128
+ private updateNow;
129
+ /**
130
+ * Flip the archived flag on a batch; returns the notes that actually moved.
131
+ *
132
+ * Every id is resolved BEFORE the first write: a missing id rejects the whole
133
+ * request, so a bad batch cannot leave half the selection archived while the
134
+ * Client still believes the mutation failed.
135
+ */
136
+ setArchived(ids: string[], archived: boolean): Promise<Note[]>;
137
+ private setArchivedNow;
138
+ /**
139
+ * Delete a batch; returns the ids that were removed.
140
+ *
141
+ * The whole selection is resolved before the first delete, so a request
142
+ * naming an absent note changes nothing: a caller is never told the batch
143
+ * failed while part of it was already applied. Batch semantics match
144
+ * {@link setArchived}.
145
+ */
146
+ remove(ids: string[]): Promise<string[]>;
147
+ private removeNow;
148
+ /**
149
+ * Move one card on the free canvas.
150
+ *
151
+ * Moving is its own operation rather than an `update` patch: it deliberately
152
+ * does NOT bump `updatedAt`, because rearranging a wall is not editing a
153
+ * note's content and must not reshuffle a "by updated" sort. The write is
154
+ * still serialised on the mutation chain so two drags cannot race.
155
+ */
156
+ move(id: string, layout: NoteLayout): Promise<Note>;
157
+ private moveNow;
158
+ /**
159
+ * Tidy the wall: repack every note into a deterministic shelf arrangement.
160
+ *
161
+ * Returns only the notes that actually moved, so the Client can reconcile
162
+ * without refetching the whole wall, and pressing tidy on an already-tidy
163
+ * wall is a no-op instead of a write storm.
164
+ */
165
+ arrange(): Promise<Note[]>;
166
+ private arrangeNow;
167
+ /**
168
+ * Permanently remove every archived note.
169
+ *
170
+ * The archived set is read once and deleted in order; a delete failure stops
171
+ * the sweep and surfaces, leaving the remaining archived notes intact rather
172
+ * than reporting a clean sweep that did not happen.
173
+ */
174
+ clearArchived(): Promise<string[]>;
175
+ private clearArchivedNow;
176
+ /** Compact plain-text digest of the wall for the model-facing tool. */
177
+ summary(limit?: number): {
178
+ total: number;
179
+ archived: number;
180
+ notes: Note[];
181
+ };
182
+ }
package/lib/store.js ADDED
@@ -0,0 +1,452 @@
1
+ /**
2
+ * Durable note store over the DSH storage domain.
3
+ *
4
+ * The Host owns all data: the Client renders snapshots and submits intents, and
5
+ * every write goes through {@link StickyWallStore} — validate, persist, then
6
+ * hand back the changed records. The storage domain provides a single
7
+ * per-domain write chain, so concurrent writes cannot interleave, and its
8
+ * `per-record` layout keeps one file per note: one damaged record cannot take
9
+ * the whole wall down.
10
+ *
11
+ * The domain spec is mirrored structurally instead of imported: importing
12
+ * `@deepseek-ai/dsh-storage-domain` would install a second copy of the domain
13
+ * package (and its zod) inside the plugin instead of resolving the host's. The
14
+ * mirrored shape is the contract the live Host validates at `open()`:
15
+ * `{ name, version, layout, invalidRecords, tables: { <name>: { valueSchema } } }`
16
+ * with zod record schemas.
17
+ */
18
+ import { z } from 'zod';
19
+ import { arrangeLayouts, clampLayout, normalizeWidth, sameLayout } from './layout.js';
20
+ import { COLORS, WIDTHS, DOMAIN_NAME, DOMAIN_VERSION, LIMITS, } from './types.js';
21
+ /** Failure carrying a stable code the transport maps onto the RPC envelope. */
22
+ export class WallError extends Error {
23
+ code;
24
+ details;
25
+ constructor(code, message, details = {}) {
26
+ super(message);
27
+ this.code = code;
28
+ this.details = details;
29
+ this.name = 'WallError';
30
+ }
31
+ }
32
+ /** Record schema; every stored note is validated at domain open and on write. */
33
+ const NoteSchema = z
34
+ .object({
35
+ id: z.string().min(1).max(128),
36
+ title: z.string().max(LIMITS.title).default(''),
37
+ body: z.string().min(1).max(LIMITS.body),
38
+ tags: z.array(z.string().min(1).max(LIMITS.tag)).max(LIMITS.tags).default([]),
39
+ color: z.enum(COLORS).default('yellow'),
40
+ pinned: z.boolean().default(false),
41
+ archived: z.boolean().default(false),
42
+ createdAt: z.string().min(1),
43
+ updatedAt: z.string().min(1),
44
+ // `null` means "never placed": the Client derives a preview position from
45
+ // the note id. A stored layout is owned by the Host, so a wall opened in
46
+ // two windows shows the same arrangement.
47
+ layout: z
48
+ .object({
49
+ x: z.number(),
50
+ y: z.number(),
51
+ rotate: z.number(),
52
+ width: z.enum(WIDTHS),
53
+ })
54
+ .nullable()
55
+ .default(null),
56
+ })
57
+ .passthrough();
58
+ /** The domain declaration handed to `ctx.storageDomain.open()`. */
59
+ export const DOMAIN_SPEC = {
60
+ name: DOMAIN_NAME,
61
+ version: DOMAIN_VERSION,
62
+ layout: 'per-record',
63
+ invalidRecords: 'backup-and-skip',
64
+ tables: { notes: { valueSchema: NoteSchema } },
65
+ };
66
+ /** Normalize one tag: trim, lowercase, collapse inner whitespace, bound length. */
67
+ function normalizeTag(raw) {
68
+ const value = String(raw ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
69
+ return value.slice(0, LIMITS.tag);
70
+ }
71
+ /** Normalize a submitted tag list: drop empties, deduplicate, cap the count. */
72
+ function normalizeTags(raw) {
73
+ if (!Array.isArray(raw))
74
+ return [];
75
+ const seen = new Set();
76
+ for (const entry of raw) {
77
+ const tag = normalizeTag(entry);
78
+ if (tag === '' || seen.has(tag))
79
+ continue;
80
+ seen.add(tag);
81
+ if (seen.size >= LIMITS.tags)
82
+ break;
83
+ }
84
+ return [...seen];
85
+ }
86
+ /** Validate and trim a note body; a live note may not be blank. */
87
+ function normalizeBody(raw) {
88
+ const value = String(raw ?? '').replace(/\r\n?/g, '\n').trim();
89
+ if (value === '')
90
+ throw new WallError('empty-body', 'A note needs some text.');
91
+ if (value.length > LIMITS.body)
92
+ throw new WallError('body-too-long', `A note is limited to ${LIMITS.body} characters.`, { limit: LIMITS.body, length: value.length });
93
+ return value;
94
+ }
95
+ /** Validate and trim a title; an absent title is the empty string. */
96
+ function normalizeTitle(raw) {
97
+ const value = String(raw ?? '').replace(/\r\n?/g, ' ').trim();
98
+ if (value.length > LIMITS.title)
99
+ throw new WallError('title-too-long', `A note title is limited to ${LIMITS.title} characters.`, { limit: LIMITS.title, length: value.length });
100
+ return value;
101
+ }
102
+ /** Accept only a declared pastel key. */
103
+ function normalizeColor(raw) {
104
+ const value = String(raw ?? '');
105
+ const match = COLORS.find((color) => color === value);
106
+ if (match === undefined)
107
+ throw new WallError('invalid-color', `Unknown note color ${JSON.stringify(value)}.`, { colors: [...COLORS] });
108
+ return match;
109
+ }
110
+ /** Validate and clamp a submitted placement; `null` clears it back to derived. */
111
+ function normalizeLayout(raw) {
112
+ if (raw === null || raw === undefined)
113
+ return null;
114
+ if (typeof raw !== 'object')
115
+ throw new WallError('invalid-layout', 'A note placement must be an object.');
116
+ const value = raw;
117
+ const x = Number(value.x);
118
+ const y = Number(value.y);
119
+ const rotate = Number(value.rotate ?? 0);
120
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(rotate)) {
121
+ throw new WallError('invalid-layout', 'A note placement needs finite x, y and rotate values.');
122
+ }
123
+ return clampLayout({ x, y, rotate, width: normalizeWidth(value.width) });
124
+ }
125
+ /** Reject ids that are not plausible note keys before touching storage. */
126
+ function assertIds(raw) {
127
+ if (!Array.isArray(raw) || raw.length === 0)
128
+ throw new WallError('invalid-ids', 'Select at least one note.');
129
+ if (raw.length > LIMITS.bulk)
130
+ throw new WallError('too-many-ids', `At most ${LIMITS.bulk} notes per request.`, { limit: LIMITS.bulk });
131
+ const ids = [...new Set(raw.map((id) => String(id ?? '')))];
132
+ if (ids.some((id) => id === '' || id.length > 128))
133
+ throw new WallError('invalid-ids', 'One of the selected note ids is invalid.');
134
+ return ids;
135
+ }
136
+ /**
137
+ * One record's sort weight: pinned first, then the requested ordering, with the
138
+ * id as a final tiebreaker so two notes written in the same millisecond still
139
+ * render in a stable order.
140
+ */
141
+ function compareNotes(sort) {
142
+ return (left, right) => {
143
+ if (left.pinned !== right.pinned)
144
+ return left.pinned ? -1 : 1;
145
+ if (sort === 'created') {
146
+ return right.createdAt.localeCompare(left.createdAt) || right.updatedAt.localeCompare(left.updatedAt) || left.id.localeCompare(right.id);
147
+ }
148
+ return right.updatedAt.localeCompare(left.updatedAt) || left.id.localeCompare(right.id);
149
+ };
150
+ }
151
+ /** The store facade the Host endpoint and the agent tool share. */
152
+ export class StickyWallStore {
153
+ domain;
154
+ maxNotes;
155
+ now;
156
+ notes;
157
+ closed = false;
158
+ /**
159
+ * Store-level mutation chain.
160
+ *
161
+ * The storage domain serializes its own writes, but a read-then-write pair
162
+ * (the `maxNotes` check followed by the `put`) is two steps: two concurrent
163
+ * creates at the boundary would both read the pre-write count and both
164
+ * succeed. Every mutation therefore runs on this chain, so each check and its
165
+ * write are one indivisible step.
166
+ */
167
+ chain = Promise.resolve();
168
+ constructor(domain, maxNotes, now = () => new Date()) {
169
+ this.domain = domain;
170
+ this.maxNotes = maxNotes;
171
+ this.now = now;
172
+ this.notes = domain.table('notes');
173
+ }
174
+ /** Open one domain and wrap it in a store. */
175
+ static async open(open, options) {
176
+ const domain = await open(DOMAIN_SPEC);
177
+ const store = new StickyWallStore(domain, options.maxNotes, options.now ?? (() => new Date()));
178
+ if (options.seedWelcomeNote)
179
+ await store.seedWelcomeNote();
180
+ return store;
181
+ }
182
+ /** Release the domain handle; later writes fail instead of silently dropping. */
183
+ async close() {
184
+ if (this.closed)
185
+ return;
186
+ this.closed = true;
187
+ await this.domain.close();
188
+ }
189
+ assertOpen() {
190
+ if (this.closed)
191
+ throw new WallError('closed', 'The note wall is shutting down.');
192
+ }
193
+ /** Queue one mutation so its checks and writes cannot interleave with another. */
194
+ mutate(job) {
195
+ const result = this.chain.then(job);
196
+ this.chain = result.then(() => undefined, () => undefined);
197
+ return result;
198
+ }
199
+ liveCount() {
200
+ let count = 0;
201
+ for (const [, note] of this.notes.entries())
202
+ if (!note.archived)
203
+ count += 1;
204
+ return count;
205
+ }
206
+ /**
207
+ * Reject a write that would take the live (non-archived) note count past
208
+ * `maxNotes`.
209
+ *
210
+ * Every path that can make a note live goes through this — `create`, an
211
+ * `update` that unarchives, and a batch restore — because the documented cap
212
+ * is an invariant of the wall, not a hint on the create form.
213
+ */
214
+ assertRoom(incomingLive) {
215
+ if (incomingLive <= 0)
216
+ return;
217
+ const projected = this.liveCount() + incomingLive;
218
+ if (projected > this.maxNotes) {
219
+ throw new WallError('wall-full', `The wall already holds ${this.maxNotes} notes; archive or delete one first.`, {
220
+ limit: this.maxNotes,
221
+ projected,
222
+ });
223
+ }
224
+ }
225
+ /** Write the one-time welcome note on a brand-new wall. */
226
+ async seedWelcomeNote() {
227
+ if (this.notes.size > 0)
228
+ return;
229
+ const stamp = this.now().toISOString();
230
+ const note = {
231
+ id: 'welcome',
232
+ title: '便签墙',
233
+ body: '随手记下一闪而过的灵感,或一件临时的工作。点右下角的 + 新建一张便签;按 ⌘/Ctrl + Enter 保存。',
234
+ tags: ['开始'],
235
+ color: 'yellow',
236
+ pinned: true,
237
+ archived: false,
238
+ createdAt: stamp,
239
+ updatedAt: stamp,
240
+ // The one note the plugin places itself, so a brand-new wall opens with a
241
+ // card that is obviously meant to be dragged.
242
+ layout: { x: 120, y: 96, rotate: -1.6, width: 'md' },
243
+ };
244
+ await this.notes.put(note.id, note);
245
+ }
246
+ /** Every stored note, newest-relevant first; archived ones only on request. */
247
+ list(includeArchived = false, sort = 'pinned') {
248
+ this.assertOpen();
249
+ const notes = [];
250
+ for (const [, note] of this.notes.entries()) {
251
+ const value = note;
252
+ if (value.archived && !includeArchived)
253
+ continue;
254
+ notes.push(value);
255
+ }
256
+ return notes.sort(compareNotes(sort));
257
+ }
258
+ /** Create one note from a Client or agent submission. */
259
+ async create(input, id) {
260
+ this.assertOpen();
261
+ return this.mutate(() => this.createNow(input, id));
262
+ }
263
+ async createNow(input, id) {
264
+ const body = normalizeBody(input?.body);
265
+ const title = normalizeTitle(input?.title);
266
+ this.assertRoom(1);
267
+ const stamp = this.now().toISOString();
268
+ const note = {
269
+ id: id ?? globalThis.crypto.randomUUID(),
270
+ title,
271
+ body,
272
+ tags: normalizeTags(input?.tags),
273
+ color: input?.color === undefined ? 'yellow' : normalizeColor(input.color),
274
+ pinned: input?.pinned === true,
275
+ archived: false,
276
+ createdAt: stamp,
277
+ updatedAt: stamp,
278
+ layout: input?.layout === undefined ? null : normalizeLayout(input.layout),
279
+ };
280
+ if (this.notes.get(note.id) !== undefined)
281
+ throw new WallError('duplicate-id', `A note with id ${JSON.stringify(note.id)} already exists.`);
282
+ await this.notes.put(note.id, note);
283
+ return note;
284
+ }
285
+ /** Apply one patch; `updatedAt` moves only when a field actually changed. */
286
+ async update(id, patch) {
287
+ this.assertOpen();
288
+ return this.mutate(() => this.updateNow(id, patch));
289
+ }
290
+ async updateNow(id, patch) {
291
+ const current = this.notes.get(id);
292
+ if (current === undefined)
293
+ throw new WallError('not-found', `No note with id ${JSON.stringify(id)}.`, { id });
294
+ const next = { ...current };
295
+ if (patch?.body !== undefined)
296
+ next.body = normalizeBody(patch.body);
297
+ if (patch?.title !== undefined)
298
+ next.title = normalizeTitle(patch.title);
299
+ if (patch?.tags !== undefined)
300
+ next.tags = normalizeTags(patch.tags);
301
+ if (patch?.color !== undefined)
302
+ next.color = normalizeColor(patch.color);
303
+ if (patch?.pinned !== undefined)
304
+ next.pinned = patch.pinned === true;
305
+ if (patch?.archived !== undefined)
306
+ next.archived = patch.archived === true;
307
+ // Restoring an archived note makes it live again, so it must fit the cap.
308
+ if (current.archived && !next.archived)
309
+ this.assertRoom(1);
310
+ const changed = ['body', 'title', 'color', 'pinned', 'archived'].some((key) => next[key] !== current[key]) ||
311
+ JSON.stringify(next.tags) !== JSON.stringify(current.tags);
312
+ if (!changed)
313
+ return current;
314
+ next.updatedAt = this.now().toISOString();
315
+ return this.notes.update(id, () => next);
316
+ }
317
+ /**
318
+ * Flip the archived flag on a batch; returns the notes that actually moved.
319
+ *
320
+ * Every id is resolved BEFORE the first write: a missing id rejects the whole
321
+ * request, so a bad batch cannot leave half the selection archived while the
322
+ * Client still believes the mutation failed.
323
+ */
324
+ async setArchived(ids, archived) {
325
+ this.assertOpen();
326
+ return this.mutate(() => this.setArchivedNow(ids, archived));
327
+ }
328
+ async setArchivedNow(ids, archived) {
329
+ const selected = assertIds(ids);
330
+ const targets = [];
331
+ for (const id of selected) {
332
+ const current = this.notes.get(id);
333
+ if (current === undefined)
334
+ throw new WallError('not-found', `No note with id ${JSON.stringify(id)}.`, { id });
335
+ if (current.archived !== archived)
336
+ targets.push(current);
337
+ }
338
+ // A restore batch makes every target live; check the whole batch up front so
339
+ // a partial restore can never exceed the cap.
340
+ if (!archived)
341
+ this.assertRoom(targets.length);
342
+ const changed = [];
343
+ for (const current of targets) {
344
+ const next = { ...current, archived, updatedAt: this.now().toISOString() };
345
+ changed.push(await this.notes.update(current.id, () => next));
346
+ }
347
+ return changed;
348
+ }
349
+ /**
350
+ * Delete a batch; returns the ids that were removed.
351
+ *
352
+ * The whole selection is resolved before the first delete, so a request
353
+ * naming an absent note changes nothing: a caller is never told the batch
354
+ * failed while part of it was already applied. Batch semantics match
355
+ * {@link setArchived}.
356
+ */
357
+ async remove(ids) {
358
+ this.assertOpen();
359
+ return this.mutate(() => this.removeNow(ids));
360
+ }
361
+ async removeNow(ids) {
362
+ const selected = assertIds(ids);
363
+ for (const id of selected) {
364
+ if (this.notes.get(id) === undefined)
365
+ throw new WallError('not-found', `No note with id ${JSON.stringify(id)}.`, { id });
366
+ }
367
+ const removed = [];
368
+ for (const id of selected) {
369
+ await this.notes.delete(id);
370
+ removed.push(id);
371
+ }
372
+ return removed;
373
+ }
374
+ /**
375
+ * Move one card on the free canvas.
376
+ *
377
+ * Moving is its own operation rather than an `update` patch: it deliberately
378
+ * does NOT bump `updatedAt`, because rearranging a wall is not editing a
379
+ * note's content and must not reshuffle a "by updated" sort. The write is
380
+ * still serialised on the mutation chain so two drags cannot race.
381
+ */
382
+ async move(id, layout) {
383
+ this.assertOpen();
384
+ return this.mutate(() => this.moveNow(id, layout));
385
+ }
386
+ async moveNow(id, layout) {
387
+ const current = this.notes.get(id);
388
+ if (current === undefined)
389
+ throw new WallError('not-found', `No note with id ${JSON.stringify(id)}.`, { id });
390
+ const next = normalizeLayout(layout);
391
+ if (sameLayout(current.layout, next))
392
+ return current;
393
+ return this.notes.update(id, () => ({ ...current, layout: next }));
394
+ }
395
+ /**
396
+ * Tidy the wall: repack every note into a deterministic shelf arrangement.
397
+ *
398
+ * Returns only the notes that actually moved, so the Client can reconcile
399
+ * without refetching the whole wall, and pressing tidy on an already-tidy
400
+ * wall is a no-op instead of a write storm.
401
+ */
402
+ async arrange() {
403
+ this.assertOpen();
404
+ return this.mutate(() => this.arrangeNow());
405
+ }
406
+ async arrangeNow() {
407
+ // Every note, archived ones included. Packing only the live set left an
408
+ // archived-but-visible card at its dragged position, on top of the freshly
409
+ // tidied ones — the wall has to be tidy in every filter, not just the
410
+ // default one.
411
+ const current = this.list(true, 'pinned');
412
+ const planned = arrangeLayouts(current);
413
+ const changed = [];
414
+ for (const note of current) {
415
+ const target = planned.get(note.id);
416
+ if (target === undefined || sameLayout(note.layout, target))
417
+ continue;
418
+ changed.push(await this.notes.update(note.id, () => ({ ...note, layout: target })));
419
+ }
420
+ return changed;
421
+ }
422
+ /**
423
+ * Permanently remove every archived note.
424
+ *
425
+ * The archived set is read once and deleted in order; a delete failure stops
426
+ * the sweep and surfaces, leaving the remaining archived notes intact rather
427
+ * than reporting a clean sweep that did not happen.
428
+ */
429
+ async clearArchived() {
430
+ this.assertOpen();
431
+ return this.mutate(() => this.clearArchivedNow());
432
+ }
433
+ async clearArchivedNow() {
434
+ const archived = this.list(true).filter((note) => note.archived).map((note) => note.id);
435
+ const removed = [];
436
+ for (const id of archived) {
437
+ await this.notes.delete(id);
438
+ removed.push(id);
439
+ }
440
+ return removed;
441
+ }
442
+ /** Compact plain-text digest of the wall for the model-facing tool. */
443
+ summary(limit = 20) {
444
+ this.assertOpen();
445
+ const all = this.list(true, 'updated');
446
+ return {
447
+ total: all.length,
448
+ archived: all.filter((note) => note.archived).length,
449
+ notes: all.slice(0, Math.max(1, Math.min(limit, 50))),
450
+ };
451
+ }
452
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Host ↔ Client transport: one exact Fetch route inside Connection's fence.
3
+ *
4
+ * Connection owns the Host/Origin check, the browser-session authentication and
5
+ * the Peer scope; this module registers one exact `POST /api/<endpoint>` route
6
+ * and only has to validate its own envelope and payload. No physical server, no
7
+ * Express, and no second RPC channel is claimed.
8
+ *
9
+ * The wire envelope is the inspected Connection unary shape:
10
+ * `{ type: 'client-request', rpcId, method, payload }` in and
11
+ * `{ type: 'server-response', rpcId, result: { ok: true, value } | { ok: false, error } }`
12
+ * out. A non-owner Peer is rejected by Connection before this handler runs.
13
+ */
14
+ import { type Note, type NoteInput, type NoteLayout, type NotePatch, type NoteSort, type WallResult } from './types.js';
15
+ /** Path of the exact route, under the shared `/api` channel. */
16
+ export declare const EXECUTE_PATH = "/api/skillre-sticky-wall/execute";
17
+ /** One buffered exact Fetch route as the Connection registry accepts it. */
18
+ export interface FetchRoute {
19
+ readonly path: string;
20
+ readonly methods: readonly ['POST'];
21
+ readonly requestBody: 'buffered';
22
+ readonly fetch: (request: Request) => Promise<Response>;
23
+ }
24
+ /** The Host Connection face this plugin depends on. */
25
+ export interface HostConnection {
26
+ readonly fetch: {
27
+ register(route: FetchRoute): () => Promise<void>;
28
+ };
29
+ }
30
+ /** The store operations the route dispatches onto. */
31
+ export interface WallActions {
32
+ list(includeArchived?: boolean, sort?: NoteSort): Note[];
33
+ create(input: NoteInput): Promise<Note>;
34
+ update(id: string, patch: NotePatch): Promise<Note>;
35
+ setArchived(ids: string[], archived: boolean): Promise<Note[]>;
36
+ remove(ids: string[]): Promise<string[]>;
37
+ clearArchived(): Promise<string[]>;
38
+ move(id: string, layout: NoteLayout): Promise<Note>;
39
+ arrange(): Promise<Note[]>;
40
+ }
41
+ /**
42
+ * Build the exact route Connection registers; every failure stays inside the
43
+ * envelope. `ready` resolves the store lazily so a storage-open failure is
44
+ * reported on the first request instead of at registration time.
45
+ */
46
+ export declare function createExecuteRoute(ready: () => Promise<WallActions>): FetchRoute;
47
+ /** Re-export the result envelope type for the Client half's structural mirror. */
48
+ export type { WallResult };