@molecule/api-resource-bookmark 1.0.0 → 1.0.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/README.md ADDED
@@ -0,0 +1,372 @@
1
+ <!--
2
+ AUTO-GENERATED — DO NOT EDIT THIS FILE.
3
+ Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
4
+ Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
5
+ To change this document, edit the module-level JSDoc in src/index.ts.
6
+ Generated: 2026-08-04T01:49:09.417Z
7
+ -->
8
+
9
+ # @molecule/api-resource-bookmark
10
+
11
+ > **Auto-generated, AI-first package reference** for the [molecule.dev](https://molecule.dev) ecosystem.
12
+ > It is written to be read by coding agents as much as by people, and is generated from this
13
+ > package's source — edit `src/index.ts` JSDoc, not this file.
14
+
15
+ Bookmark/favorite resource for molecule.dev.
16
+
17
+ Allows users to bookmark any resource, organize into folders, and check
18
+ bookmark status.
19
+
20
+ ## Quick Start
21
+
22
+ ```typescript
23
+ import { routes, requestHandlerMap } from '@molecule/api-resource-bookmark'
24
+
25
+ // Wire routes into your Express app via mlcl inject
26
+ // POST /bookmarks
27
+ // GET /bookmarks
28
+ // GET /bookmarks/folders
29
+ // GET /bookmarks/check/:resourceType/:resourceId
30
+ // DELETE /bookmarks/:resourceType/:resourceId
31
+ ```
32
+
33
+ ## Type
34
+
35
+ `resource`
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ npm install @molecule/api-resource-bookmark @molecule/api-database @molecule/api-i18n @molecule/api-logger @molecule/api-resource zod
41
+ ```
42
+
43
+ ## API
44
+
45
+ ### Interfaces
46
+
47
+ #### `Bookmark`
48
+
49
+ A bookmark linking a user to a resource, with optional folder grouping.
50
+
51
+ ```typescript
52
+ interface Bookmark {
53
+ /** Unique bookmark identifier. */
54
+ id: string
55
+ /** The ID of the user who created the bookmark. */
56
+ userId: string
57
+ /** The type of resource bookmarked (e.g. 'post', 'project'). */
58
+ resourceType: string
59
+ /** The ID of the bookmarked resource. */
60
+ resourceId: string
61
+ /** Optional folder name for organizing bookmarks. */
62
+ folder: string | null
63
+ /** When the bookmark was created (ISO 8601). */
64
+ createdAt: string
65
+ /** When the bookmark was last updated (ISO 8601). */
66
+ updatedAt: string
67
+ }
68
+ ```
69
+
70
+ #### `BookmarkQuery`
71
+
72
+ Query options for listing bookmarks.
73
+
74
+ ```typescript
75
+ interface BookmarkQuery {
76
+ /** Filter by resource type. */
77
+ resourceType?: string
78
+ /** Filter by folder name. */
79
+ folder?: string
80
+ /** Maximum number of results to return. */
81
+ limit?: number
82
+ /** Number of results to skip. */
83
+ offset?: number
84
+ }
85
+ ```
86
+
87
+ #### `PaginatedResult`
88
+
89
+ A paginated result set.
90
+
91
+ ```typescript
92
+ interface PaginatedResult<T> {
93
+ /** The result items for the current page. */
94
+ data: T[]
95
+ /** Total number of matching items across all pages. */
96
+ total: number
97
+ /** Maximum number of results per page. */
98
+ limit: number
99
+ /** Number of results skipped. */
100
+ offset: number
101
+ }
102
+ ```
103
+
104
+ ### Functions
105
+
106
+ #### `addBookmark(userId, resourceType, resourceId, folder)`
107
+
108
+ Adds a bookmark. Idempotent — returns existing bookmark if already bookmarked.
109
+
110
+ ```typescript
111
+ function addBookmark(
112
+ userId: string,
113
+ resourceType: string,
114
+ resourceId: string,
115
+ folder?: string,
116
+ ): Promise<Bookmark>
117
+ ```
118
+
119
+ - `userId` — The user ID.
120
+ - `resourceType` — The type of resource to bookmark.
121
+ - `resourceId` — The ID of the resource to bookmark.
122
+ - `folder` — Optional folder name.
123
+
124
+ **Returns:** The created or existing bookmark.
125
+
126
+ #### `check(req, res)`
127
+
128
+ Checks whether the current user has bookmarked a resource.
129
+
130
+ ```typescript
131
+ function check(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
132
+ ```
133
+
134
+ - `req` — The request with `resourceType` and `resourceId` params.
135
+ - `res` — The response object.
136
+
137
+ #### `create(req, res)`
138
+
139
+ Adds a bookmark for the current user. Idempotent.
140
+
141
+ ```typescript
142
+ function create(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
143
+ ```
144
+
145
+ - `req` — The request with bookmark body (resourceType, resourceId, folder?).
146
+ - `res` — The response object.
147
+
148
+ #### `del(req, res)`
149
+
150
+ Removes a bookmark by resource type and ID.
151
+
152
+ ```typescript
153
+ function del(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
154
+ ```
155
+
156
+ - `req` — The request with `resourceType` and `resourceId` params.
157
+ - `res` — The response object.
158
+
159
+ #### `folders(_req, res)`
160
+
161
+ Lists all unique folder names for the current user's bookmarks.
162
+
163
+ ```typescript
164
+ function folders(_req: MoleculeRequest, res: MoleculeResponse): Promise<void>
165
+ ```
166
+
167
+ - `_req` — The request (unused).
168
+ - `res` — The response object.
169
+
170
+ #### `getBookmarks(userId, options)`
171
+
172
+ Gets all bookmarks for a user with optional filtering and pagination.
173
+
174
+ ```typescript
175
+ function getBookmarks(userId: string, options?: BookmarkQuery): Promise<PaginatedResult<Bookmark>>
176
+ ```
177
+
178
+ - `userId` — The user ID.
179
+ - `options` — Query options.
180
+
181
+ **Returns:** Paginated bookmarks.
182
+
183
+ #### `getFolders(userId)`
184
+
185
+ Gets all unique folder names for a user's bookmarks.
186
+
187
+ ```typescript
188
+ function getFolders(userId: string): Promise<string[]>
189
+ ```
190
+
191
+ - `userId` — The user ID.
192
+
193
+ **Returns:** Array of folder names.
194
+
195
+ #### `isBookmarked(userId, resourceType, resourceId)`
196
+
197
+ Checks if a resource is bookmarked by a user.
198
+
199
+ ```typescript
200
+ function isBookmarked(userId: string, resourceType: string, resourceId: string): Promise<boolean>
201
+ ```
202
+
203
+ - `userId` — The user ID.
204
+ - `resourceType` — The type of resource.
205
+ - `resourceId` — The ID of the resource.
206
+
207
+ **Returns:** `true` if bookmarked.
208
+
209
+ #### `list(req, res)`
210
+
211
+ Lists the current user's bookmarks with optional filtering and pagination.
212
+
213
+ ```typescript
214
+ function list(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
215
+ ```
216
+
217
+ - `req` — The request with optional query params (resourceType, folder, limit, offset).
218
+ - `res` — The response object.
219
+
220
+ #### `removeBookmark(userId, resourceType, resourceId)`
221
+
222
+ Removes a bookmark.
223
+
224
+ ```typescript
225
+ function removeBookmark(userId: string, resourceType: string, resourceId: string): Promise<void>
226
+ ```
227
+
228
+ - `userId` — The user ID.
229
+ - `resourceType` — The type of resource.
230
+ - `resourceId` — The ID of the resource.
231
+
232
+ ### Constants
233
+
234
+ #### `createBookmarkSchema`
235
+
236
+ Schema for validating bookmark creation input.
237
+
238
+ ```typescript
239
+ const createBookmarkSchema: z.ZodObject<
240
+ { resourceType: z.ZodString; resourceId: z.ZodString; folder: z.ZodOptional<z.ZodString> },
241
+ z.core.$strip
242
+ >
243
+ ```
244
+
245
+ #### `requestHandlerMap`
246
+
247
+ Handler map for bookmark routes.
248
+
249
+ ```typescript
250
+ const requestHandlerMap: {
251
+ readonly create: typeof create
252
+ readonly list: typeof list
253
+ readonly check: typeof check
254
+ readonly folders: typeof folders
255
+ readonly del: typeof del
256
+ }
257
+ ```
258
+
259
+ #### `routes`
260
+
261
+ Routes for bookmark add/remove/list/check and folder listing.
262
+
263
+ ```typescript
264
+ const routes: readonly [
265
+ {
266
+ readonly method: 'post'
267
+ readonly path: '/bookmarks'
268
+ readonly handler: 'create'
269
+ readonly middlewares: readonly ['authenticate']
270
+ },
271
+ {
272
+ readonly method: 'get'
273
+ readonly path: '/bookmarks'
274
+ readonly handler: 'list'
275
+ readonly middlewares: readonly ['authenticate']
276
+ },
277
+ {
278
+ readonly method: 'get'
279
+ readonly path: '/bookmarks/folders'
280
+ readonly handler: 'folders'
281
+ readonly middlewares: readonly ['authenticate']
282
+ },
283
+ {
284
+ readonly method: 'get'
285
+ readonly path: '/bookmarks/check/:resourceType/:resourceId'
286
+ readonly handler: 'check'
287
+ readonly middlewares: readonly ['authenticate']
288
+ },
289
+ {
290
+ readonly method: 'delete'
291
+ readonly path: '/bookmarks/:resourceType/:resourceId'
292
+ readonly handler: 'del'
293
+ readonly middlewares: readonly ['authenticate']
294
+ },
295
+ ]
296
+ ```
297
+
298
+ ## Injection Notes
299
+
300
+ ### Requirements
301
+
302
+ Peer dependencies:
303
+
304
+ - `@molecule/api-database` ^1.0.1
305
+ - `@molecule/api-i18n` ^1.0.1
306
+ - `@molecule/api-logger` ^1.0.1
307
+ - `@molecule/api-resource` ^1.0.1
308
+ - `zod` ^4.0.0
309
+
310
+ ### Runtime Dependencies
311
+
312
+ - `@molecule/api-database`
313
+ - `@molecule/api-i18n`
314
+ - `@molecule/api-logger`
315
+ - `@molecule/api-resource`
316
+ - `zod`
317
+
318
+ - **List endpoints return a PAGINATED envelope** `{ data, total, limit, offset }`, not a
319
+ bare array — read the rows off `result.data` (server). On the client, `unwrapList(res)`
320
+ from `@molecule/app-http` normalizes this envelope (pass it the whole HttpResponse), so
321
+ the rows come back; reading the response as a bare array — or `res.data` alone (which is
322
+ the envelope) — yields an EMPTY list.
323
+ - **Migration required.** `src/__setup__/bookmarks.sql` ships with this package
324
+ and must exist in the target database before use (scaffolded apps apply it
325
+ automatically). Note the `UNIQUE ("userId","resourceType","resourceId")`
326
+ constraint — one bookmark per user per resource.
327
+ - **`addBookmark()` is idempotent and does NOT move folders.** Re-adding an
328
+ existing bookmark returns the existing row unchanged — to move a bookmark to
329
+ another folder, remove and re-add it (or add your own update path).
330
+ - **Owner-scoped via the session.** All routes require `authenticate` and every
331
+ query filters by the session `userId` — never accept a target userId from the
332
+ client (IDOR).
333
+ - Bookmarked resources are polymorphic and unverified (no FK): use the same
334
+ canonical `resourceType` slugs as your other polymorphic resources
335
+ (comments, activity feed) so `check`/`remove` keys line up.
336
+ - Folders are free-form strings on the bookmark row (`GET /bookmarks/folders`
337
+ returns the distinct set) — there is no folder entity to create first.
338
+
339
+ ## E2E Tests
340
+
341
+ Integration checklist — drive the real UI (live preview, no mocks), adapt
342
+ each item to this app's actual screens/flows, and check every box off one
343
+ by one. A box you can't check is an integration bug to fix — not a skip:
344
+
345
+ - [ ] Bookmarking an item (POST /bookmarks with its resourceType +
346
+ resourceId) saves it, it then appears in the user's bookmarks list
347
+ (GET /bookmarks), and its bookmark/star control reflects the saved state
348
+ (GET /bookmarks/check/:resourceType/:resourceId returns { bookmarked: true }).
349
+ - [ ] It is idempotent and toggles cleanly: bookmarking the SAME
350
+ resourceType+resourceId twice does NOT create a duplicate (the UNIQUE
351
+ (userId, resourceType, resourceId) constraint holds — the second add
352
+ returns the existing row), and un-bookmarking
353
+ (DELETE /bookmarks/:resourceType/:resourceId) removes it from the list and
354
+ flips the control back (check returns { bookmarked: false }).
355
+ - [ ] Folders work: a bookmark filed into a folder (the free-form `folder`
356
+ string, set at create time — re-adding does NOT move it between folders)
357
+ shows under that folder, GET /bookmarks/folders returns the distinct folder
358
+ set, and GET /bookmarks?folder=X (and ?resourceType=X) filters the list to
359
+ only the matching bookmarks.
360
+ - [ ] Display data resolves from the referenced resource: the bookmark row
361
+ stores only resourceType+resourceId (no title/url/thumbnail, no FK), so each
362
+ list item renders its real title/thumbnail by looking the target up, and a
363
+ bookmark whose target was since deleted is handled gracefully (hidden or
364
+ tombstoned, never a crash or a blank row).
365
+ - [ ] Authorization — bookmarks are strictly per-user: the owner is the
366
+ session userId (res.locals.session), NEVER a userId taken from the request
367
+ body; every list/check/remove is scoped to that session user, so one user
368
+ can neither see nor delete another user's saved items (there is no
369
+ bookmark-id route to guess — keys are resourceType+resourceId under the
370
+ caller's own userId); and a user can only bookmark targets they are allowed
371
+ to see (the target is polymorphic and unverified, so gate the create by
372
+ target visibility).
@@ -1 +1 @@
1
- {"version":3,"file":"list.d.ts","sourceRoot":"","sources":["../../src/handlers/list.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAA;AAI/E;;;;;GAKG;AACH,wBAAsB,IAAI,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CA2BrF"}
1
+ {"version":3,"file":"list.d.ts","sourceRoot":"","sources":["../../src/handlers/list.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAA;AAO/E;;;;;GAKG;AACH,wBAAsB,IAAI,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CA2BrF"}
@@ -6,6 +6,8 @@
6
6
  import { t } from '@molecule/api-i18n';
7
7
  import { logger } from '@molecule/api-logger';
8
8
  import { getBookmarks } from '../service.js';
9
+ /** Upper bound for the list page size — an uncapped `limit` is a table-dump primitive (e.g. `?limit=999999999`). */
10
+ const MAX_LIST_LIMIT = 500;
9
11
  /**
10
12
  * Lists the current user's bookmarks with optional filtering and pagination.
11
13
  *
@@ -23,7 +25,7 @@ export async function list(req, res) {
23
25
  }
24
26
  const resourceType = req.query.resourceType;
25
27
  const folder = req.query.folder;
26
- const limit = parseInt(req.query.limit, 10) || 20;
28
+ const limit = Math.min(MAX_LIST_LIMIT, Math.max(1, parseInt(req.query.limit, 10) || 20));
27
29
  const offset = parseInt(req.query.offset, 10) || 0;
28
30
  try {
29
31
  const result = await getBookmarks(userId, { resourceType, folder, limit, offset });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-resource-bookmark",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Bookmark/favorite any resource with folder organization",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -17,7 +17,8 @@
17
17
  }
18
18
  },
19
19
  "files": [
20
- "dist"
20
+ "dist",
21
+ "README.md"
21
22
  ],
22
23
  "keywords": [
23
24
  "molecule",
@@ -29,14 +30,14 @@
29
30
  "devDependencies": {
30
31
  "@types/node": "26.1.2",
31
32
  "typescript": "6.0.3",
32
- "vitest": "4.1.10",
33
+ "vitest": "4.1.11",
33
34
  "zod": "4.4.3"
34
35
  },
35
36
  "peerDependencies": {
36
- "@molecule/api-database": "^1.0.0",
37
- "@molecule/api-i18n": "^1.0.0",
38
- "@molecule/api-logger": "^1.0.0",
39
- "@molecule/api-resource": "^1.0.0",
37
+ "@molecule/api-database": "^1.0.1",
38
+ "@molecule/api-i18n": "^1.0.1",
39
+ "@molecule/api-logger": "^1.0.1",
40
+ "@molecule/api-resource": "^1.0.1",
40
41
  "zod": "^4.0.0"
41
42
  },
42
43
  "peerDependenciesMeta": {