@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 +372 -0
- package/dist/handlers/list.d.ts.map +1 -1
- package/dist/handlers/list.js +3 -1
- package/package.json +8 -7
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;
|
|
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"}
|
package/dist/handlers/list.js
CHANGED
|
@@ -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.
|
|
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.
|
|
33
|
+
"vitest": "4.1.11",
|
|
33
34
|
"zod": "4.4.3"
|
|
34
35
|
},
|
|
35
36
|
"peerDependencies": {
|
|
36
|
-
"@molecule/api-database": "^1.0.
|
|
37
|
-
"@molecule/api-i18n": "^1.0.
|
|
38
|
-
"@molecule/api-logger": "^1.0.
|
|
39
|
-
"@molecule/api-resource": "^1.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": {
|