@ketrics/ketrics-cli 0.5.0 → 0.6.1

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.
Files changed (36) hide show
  1. package/dist/src/cli.d.ts.map +1 -1
  2. package/dist/src/cli.js +6 -0
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/commands/create.d.ts +1 -0
  5. package/dist/src/commands/create.d.ts.map +1 -1
  6. package/dist/src/commands/create.js +44 -13
  7. package/dist/src/commands/create.js.map +1 -1
  8. package/dist/src/services/local-template-service.d.ts +52 -0
  9. package/dist/src/services/local-template-service.d.ts.map +1 -0
  10. package/dist/src/services/local-template-service.js +216 -0
  11. package/dist/src/services/local-template-service.js.map +1 -0
  12. package/dist/src/services/remote-template-service.d.ts +41 -0
  13. package/dist/src/services/remote-template-service.d.ts.map +1 -0
  14. package/dist/src/services/remote-template-service.js +232 -0
  15. package/dist/src/services/remote-template-service.js.map +1 -0
  16. package/dist/src/services/template-cache-service.d.ts +44 -0
  17. package/dist/src/services/template-cache-service.d.ts.map +1 -0
  18. package/dist/src/services/template-cache-service.js +193 -0
  19. package/dist/src/services/template-cache-service.js.map +1 -0
  20. package/dist/src/services/template-service.d.ts +25 -31
  21. package/dist/src/services/template-service.d.ts.map +1 -1
  22. package/dist/src/services/template-service.js +136 -132
  23. package/dist/src/services/template-service.js.map +1 -1
  24. package/dist/src/types/index.d.ts +46 -0
  25. package/dist/src/types/index.d.ts.map +1 -1
  26. package/dist/src/types/index.js.map +1 -1
  27. package/dist/src/version.d.ts +1 -1
  28. package/dist/src/version.js +1 -1
  29. package/package.json +5 -1
  30. package/templates/HelloWorld/.claude/skills/ketrics-app/BACKEND_REFERENCE.md +693 -0
  31. package/templates/HelloWorld/.claude/skills/ketrics-app/CONFIG_AND_DEPLOY.md +278 -0
  32. package/templates/HelloWorld/.claude/skills/ketrics-app/FRONTEND_REFERENCE.md +325 -0
  33. package/templates/HelloWorld/.claude/skills/ketrics-app/SKILL.md +348 -0
  34. package/templates/HelloWorld/.env.example +20 -0
  35. package/templates/HelloWorld/.github/workflows/deploy.yml +51 -0
  36. package/templates/HelloWorld/backend/package.json +1 -1
@@ -0,0 +1,693 @@
1
+ # Backend SDK Reference
2
+
3
+ Complete reference for the `ketrics` global object and backend handler patterns. The `ketrics` object is automatically available in all backend handlers — no imports needed. Types come from `@ketrics/sdk-backend` (devDependency).
4
+
5
+ ## Handler function pattern
6
+
7
+ Every handler is an async function that takes a typed payload and returns a result. Handlers are exported by name and must match the `actions` array in `ketrics.config.json`.
8
+
9
+ ```typescript
10
+ interface MyPayload {
11
+ id: string;
12
+ name: string;
13
+ }
14
+
15
+ const myHandler = async (payload: MyPayload) => {
16
+ requireEditor(); // permission check
17
+ const { id, name } = payload;
18
+
19
+ // Validate input
20
+ if (!id) throw new Error("id is required");
21
+ if (!name?.trim()) throw new Error("name is required");
22
+
23
+ // Get requestor context
24
+ const userId = ketrics.requestor.userId;
25
+ if (!userId) throw new Error("User context is required");
26
+
27
+ // ... handler logic ...
28
+
29
+ return { item: { id, name } };
30
+ };
31
+
32
+ export { myHandler };
33
+ ```
34
+
35
+ ## ketrics.environment
36
+
37
+ Read application environment variables configured in the Ketrics dashboard.
38
+
39
+ ```typescript
40
+ // Simple string value
41
+ const apiKey = ketrics.environment["API_KEY"];
42
+
43
+ // JSON-encoded config
44
+ const raw = ketrics.environment["DB_CONNECTIONS"];
45
+ const connections: { code: string; name: string }[] = JSON.parse(raw);
46
+
47
+ // With fallback
48
+ const docDbCode = ketrics.environment["DOCDB_CODE"] || "default-store";
49
+
50
+ // Volume reference
51
+ const volumeCode = ketrics.environment["EXPORTS_VOLUME"];
52
+ if (!volumeCode) throw new Error("EXPORTS_VOLUME not configured");
53
+ ```
54
+
55
+ Common environment variables:
56
+ - `DB_CONNECTIONS` — JSON array of `{code, name}` for database connection dropdown
57
+ - `DOCDB_*` — DocumentDB resource codes
58
+ - `EXPORTS_VOLUME` — Volume code for file exports
59
+
60
+ ## ketrics.requestor
61
+
62
+ Context about the authenticated user making the request.
63
+
64
+ ```typescript
65
+ ketrics.requestor.userId // string — User ID
66
+ ketrics.requestor.name // string — Display name
67
+ ketrics.requestor.email // string — Email address
68
+ ketrics.requestor.applicationPermissions // string[] — e.g., ["editor"]
69
+ ```
70
+
71
+ ### Permission checking pattern
72
+
73
+ ```typescript
74
+ function requireEditor(): void {
75
+ if (!ketrics.requestor.applicationPermissions.includes("editor")) {
76
+ throw new Error("Permission denied: editor role required");
77
+ }
78
+ }
79
+
80
+ // Usage: call at the start of any handler that modifies data
81
+ const createItem = async (payload: CreatePayload) => {
82
+ requireEditor();
83
+ // ...
84
+ };
85
+ ```
86
+
87
+ ## ketrics.Database
88
+
89
+ Connect to SQL databases configured for the tenant. Supports parameterized queries.
90
+
91
+ ### Connect and query
92
+
93
+ ```typescript
94
+ const db = await ketrics.Database.connect(connectionCode);
95
+ try {
96
+ const result = await db.query<Record<string, unknown>>(sql, params);
97
+ // result.rows: Record<string, unknown>[]
98
+ // result.rowCount: number
99
+ } finally {
100
+ await db.close(); // ALWAYS close in finally block
101
+ }
102
+ ```
103
+
104
+ ### Read-only enforcement
105
+
106
+ Block DML/DDL keywords to ensure read-only access:
107
+
108
+ ```typescript
109
+ const FORBIDDEN_KEYWORDS = /\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE)\b/i;
110
+
111
+ if (FORBIDDEN_KEYWORDS.test(sql)) {
112
+ throw new Error("Only SELECT queries are allowed.");
113
+ }
114
+ ```
115
+
116
+ ### Row limiting
117
+
118
+ Wrap queries to enforce server-side row limits:
119
+
120
+ ```typescript
121
+ const MAX_ROWS = 500;
122
+ const limitedSql = `SELECT * FROM (${sql}) AS __limited_result LIMIT ${MAX_ROWS}`;
123
+ const result = await db.query<Record<string, unknown>>(limitedSql, params);
124
+ ```
125
+
126
+ ### Parameterized queries (positional)
127
+
128
+ Use `$1`, `$2`, etc. for positional parameters:
129
+
130
+ ```typescript
131
+ const sql = "SELECT * FROM users WHERE status = $1 AND role = $2";
132
+ const params = ["active", "admin"];
133
+ const result = await db.query<Record<string, unknown>>(sql, params);
134
+ ```
135
+
136
+ ### Template parameter replacement
137
+
138
+ Convert `{{paramName}}` placeholders to positional parameters:
139
+
140
+ ```typescript
141
+ // Input: "SELECT * FROM users WHERE status = {{status}}"
142
+ // Output: "SELECT * FROM users WHERE status = $1" with values = ["active"]
143
+
144
+ let parameterizedSql = rawSql;
145
+ const values: unknown[] = [];
146
+ let paramIndex = 0;
147
+
148
+ // Detect all {{paramName}} placeholders
149
+ const paramNames: string[] = [];
150
+ const regex = /\{\{(\w+)\}\}/g;
151
+ let match;
152
+ while ((match = regex.exec(parameterizedSql)) !== null) {
153
+ if (!paramNames.includes(match[1])) paramNames.push(match[1]);
154
+ }
155
+
156
+ // Replace each with positional $N
157
+ for (const pName of paramNames) {
158
+ paramIndex++;
159
+ parameterizedSql = parameterizedSql.replace(
160
+ new RegExp(`\\{\\{${pName}\\}\\}`, "g"),
161
+ () => `$${paramIndex}`
162
+ );
163
+ values.push(paramValues[pName] ?? "");
164
+ }
165
+ ```
166
+
167
+ ### Identifier parameters (table/column names)
168
+
169
+ For dynamic table or column names, validate and interpolate directly (cannot use positional params):
170
+
171
+ ```typescript
172
+ const VALID_IDENTIFIER = /^[a-zA-Z0-9_.\-]+$/;
173
+
174
+ if (param.isIdentifier) {
175
+ if (!val || !VALID_IDENTIFIER.test(val)) {
176
+ throw new Error(`Invalid identifier: "${val}"`);
177
+ }
178
+ // Direct string interpolation (safe after validation)
179
+ sql = sql.replace(`{{${paramName}}}`, val);
180
+ }
181
+ ```
182
+
183
+ ### Conditional SQL blocks
184
+
185
+ Support optional sections in SQL using `{{#if param}}...{{/if}}`:
186
+
187
+ ```typescript
188
+ parameterizedSql = parameterizedSql.replace(
189
+ /\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g,
190
+ (_, condParam: string, content: string) => {
191
+ const val = paramValues[condParam];
192
+ return (val != null && val.trim() !== "") ? content : "";
193
+ }
194
+ );
195
+ ```
196
+
197
+ Example SQL:
198
+ ```sql
199
+ SELECT * FROM orders
200
+ WHERE status = {{status}}
201
+ {{#if date_from}} AND created_at >= {{date_from}} {{/if}}
202
+ {{#if date_to}} AND created_at <= {{date_to}} {{/if}}
203
+ ```
204
+
205
+ ## ketrics.DocumentDb
206
+
207
+ NoSQL document store with DynamoDB-style partition key (pk) and sort key (sk).
208
+
209
+ ### Connect
210
+
211
+ ```typescript
212
+ const docdb = await ketrics.DocumentDb.connect(resourceCode);
213
+ // resourceCode matches the "code" in ketrics.config.json resources.documentdb
214
+ ```
215
+
216
+ ### Put (create or overwrite)
217
+
218
+ ```typescript
219
+ const item: Record<string, unknown> = {
220
+ id: crypto.randomUUID(),
221
+ name: "My Item",
222
+ createdBy: userId,
223
+ createdAt: new Date().toISOString(),
224
+ updatedAt: new Date().toISOString(),
225
+ };
226
+
227
+ await docdb.put(`USER#${userId}`, `ITEM#${item.id}`, item);
228
+ ```
229
+
230
+ ### Get (single item)
231
+
232
+ ```typescript
233
+ const item = await docdb.get(`USER#${userId}`, `ITEM#${itemId}`);
234
+ if (!item) throw new Error("Not found");
235
+ ```
236
+
237
+ ### List (query by pk with optional sk prefix)
238
+
239
+ ```typescript
240
+ const result = await docdb.list(`USER#${userId}`, {
241
+ skPrefix: "ITEM#", // Filter by sort key prefix
242
+ scanForward: false, // Reverse chronological order
243
+ limit: 100, // Max items to return
244
+ });
245
+ // result.items: Record<string, unknown>[]
246
+ ```
247
+
248
+ ### Delete
249
+
250
+ ```typescript
251
+ await docdb.delete(`USER#${userId}`, `ITEM#${itemId}`);
252
+ ```
253
+
254
+ ### Key design patterns
255
+
256
+ Design composite keys with prefixes for multi-entity storage in a single DocumentDB:
257
+
258
+ **User-scoped items (each user owns their items):**
259
+ ```typescript
260
+ pk = `USER#${userId}`
261
+ sk = `ITEM#${itemId}`
262
+ ```
263
+
264
+ **Tenant-wide items (shared across all users):**
265
+ ```typescript
266
+ pk = `TENANT_ITEMS`
267
+ sk = `ITEM#${itemId}`
268
+ ```
269
+
270
+ **Comments on a target entity:**
271
+ ```typescript
272
+ pk = `COMMENTS#${connectionCode}#${targetKey}`
273
+ sk = `COMMENT#${createdAt}#${commentId}`
274
+ // Using createdAt in sk gives chronological ordering
275
+ ```
276
+
277
+ **Count index for fast lookups:**
278
+ ```typescript
279
+ pk = `INDEX#${scope}`
280
+ sk = `KEY#${lookupKey}`
281
+ // Store: { lookupKey, count: N, lastUpdatedAt }
282
+ ```
283
+
284
+ ### CRUD handler template
285
+
286
+ ```typescript
287
+ // CREATE
288
+ const createItem = async (payload: CreatePayload) => {
289
+ requireEditor();
290
+ const { name } = payload;
291
+ if (!name?.trim()) throw new Error("name is required");
292
+
293
+ const userId = ketrics.requestor.userId;
294
+ const docdb = await ketrics.DocumentDb.connect(getDocDbCode());
295
+ const itemId = crypto.randomUUID();
296
+ const now = new Date().toISOString();
297
+
298
+ const item = { id: itemId, name: name.trim(), createdBy: userId, createdAt: now, updatedAt: now };
299
+ await docdb.put(`USER#${userId}`, `ITEM#${itemId}`, item);
300
+ return { item };
301
+ };
302
+
303
+ // READ (single)
304
+ const getItem = async (payload: { id: string }) => {
305
+ const { id } = payload;
306
+ if (!id) throw new Error("id is required");
307
+
308
+ const userId = ketrics.requestor.userId;
309
+ const docdb = await ketrics.DocumentDb.connect(getDocDbCode());
310
+ const item = await docdb.get(`USER#${userId}`, `ITEM#${id}`);
311
+ if (!item) throw new Error("Not found");
312
+ if (item.createdBy !== userId) throw new Error("Access denied");
313
+ return { item };
314
+ };
315
+
316
+ // READ (list)
317
+ const listItems = async () => {
318
+ const userId = ketrics.requestor.userId;
319
+ const docdb = await ketrics.DocumentDb.connect(getDocDbCode());
320
+ const result = await docdb.list(`USER#${userId}`, { skPrefix: "ITEM#", scanForward: false });
321
+ return { items: result.items };
322
+ };
323
+
324
+ // UPDATE
325
+ const updateItem = async (payload: UpdatePayload) => {
326
+ requireEditor();
327
+ const { id, name } = payload;
328
+ if (!id) throw new Error("id is required");
329
+
330
+ const userId = ketrics.requestor.userId;
331
+ const docdb = await ketrics.DocumentDb.connect(getDocDbCode());
332
+ const pk = `USER#${userId}`;
333
+ const sk = `ITEM#${id}`;
334
+
335
+ const existing = await docdb.get(pk, sk);
336
+ if (!existing) throw new Error("Not found");
337
+ if (existing.createdBy !== userId) throw new Error("You can only update your own items");
338
+
339
+ const item = { ...existing, name: name.trim(), updatedAt: new Date().toISOString() };
340
+ await docdb.put(pk, sk, item);
341
+ return { item };
342
+ };
343
+
344
+ // DELETE
345
+ const deleteItem = async (payload: { id: string }) => {
346
+ requireEditor();
347
+ const { id } = payload;
348
+ if (!id) throw new Error("id is required");
349
+
350
+ const userId = ketrics.requestor.userId;
351
+ const docdb = await ketrics.DocumentDb.connect(getDocDbCode());
352
+ const pk = `USER#${userId}`;
353
+ const sk = `ITEM#${id}`;
354
+
355
+ const existing = await docdb.get(pk, sk);
356
+ if (!existing) throw new Error("Not found");
357
+ if (existing.createdBy !== userId) throw new Error("You can only delete your own items");
358
+
359
+ await docdb.delete(pk, sk);
360
+ return { success: true };
361
+ };
362
+ ```
363
+
364
+ ## ketrics.Excel
365
+
366
+ Create Excel workbooks in memory.
367
+
368
+ ```typescript
369
+ const excel = ketrics.Excel.create();
370
+ const sheet = excel.addWorksheet("Sheet Name");
371
+
372
+ // Set columns with headers and widths
373
+ sheet.columns = [
374
+ { header: "Name", key: "name", width: 20 },
375
+ { header: "Email", key: "email", width: 30 },
376
+ { header: "Status", key: "status", width: 15 },
377
+ ];
378
+
379
+ // Add rows (array of arrays, matching column order)
380
+ sheet.addRows(
381
+ data.map(row => [row.name, row.email, row.status])
382
+ );
383
+
384
+ // Get buffer for saving to Volume
385
+ const buffer = await excel.toBuffer();
386
+ ```
387
+
388
+ ### Dynamic column widths
389
+
390
+ ```typescript
391
+ sheet.columns = columns.map(col => ({
392
+ header: col,
393
+ key: col,
394
+ width: Math.max(12, col.length + 4),
395
+ }));
396
+ ```
397
+
398
+ ## ketrics.Volume
399
+
400
+ File storage with presigned download URLs.
401
+
402
+ ```typescript
403
+ const volume = await ketrics.Volume.connect(volumeCode);
404
+
405
+ // Upload a file
406
+ const fileKey = `${ketrics.application.id}/${filename}`;
407
+ await volume.put(fileKey, buffer);
408
+
409
+ // Generate download URL
410
+ const presigned = await volume.generateDownloadUrl(fileKey);
411
+ // presigned.url — time-limited download URL
412
+ ```
413
+
414
+ ### Complete export pattern (Excel + Volume)
415
+
416
+ ```typescript
417
+ const exportData = async (payload: { data: Record<string, unknown>[] }) => {
418
+ requireEditor();
419
+ const { data } = payload;
420
+ if (!data?.length) throw new Error("No data to export");
421
+
422
+ const volumeCode = ketrics.environment["EXPORTS_VOLUME"];
423
+ if (!volumeCode) throw new Error("EXPORTS_VOLUME not configured");
424
+
425
+ const columns = Object.keys(data[0]);
426
+
427
+ // Build Excel
428
+ const excel = ketrics.Excel.create();
429
+ const sheet = excel.addWorksheet("Export");
430
+ sheet.columns = columns.map(col => ({
431
+ header: col,
432
+ key: col,
433
+ width: Math.max(12, col.length + 4),
434
+ }));
435
+ sheet.addRows(data.map(row => columns.map(col => row[col] ?? "")));
436
+ const buffer = await excel.toBuffer();
437
+
438
+ // Save to Volume
439
+ const filename = `export_${Date.now()}.xlsx`;
440
+ const fileKey = `${ketrics.application.id}/${filename}`;
441
+ const volume = await ketrics.Volume.connect(volumeCode);
442
+ await volume.put(fileKey, buffer);
443
+ const { url } = await volume.generateDownloadUrl(fileKey);
444
+
445
+ return { url, filename };
446
+ };
447
+ ```
448
+
449
+ ## ketrics.Messages
450
+
451
+ Send notifications to users in the tenant.
452
+
453
+ ```typescript
454
+ // Bulk send to specific users
455
+ await ketrics.Messages.sendBulk({
456
+ userIds: ["user-id-1", "user-id-2"],
457
+ type: "CUSTOM_EVENT_TYPE",
458
+ subject: "Notification subject",
459
+ body: "**Markdown** content is supported in the body.",
460
+ priority: "MEDIUM", // "LOW" | "MEDIUM" | "HIGH"
461
+ });
462
+ ```
463
+
464
+ ### Notification on share pattern
465
+
466
+ ```typescript
467
+ if (visibility === "shared" && Array.isArray(sharedWith) && sharedWith.length > 0) {
468
+ const senderName = ketrics.requestor.name || ketrics.requestor.email;
469
+ try {
470
+ await ketrics.Messages.sendBulk({
471
+ userIds: sharedWith,
472
+ type: "RESOURCE_SHARED",
473
+ subject: `${senderName} shared a resource with you`,
474
+ body: `**${senderName}** shared **${resourceName}** with you.`,
475
+ priority: "MEDIUM",
476
+ });
477
+ } catch {
478
+ // Non-critical — don't fail the main operation
479
+ ketrics.console.error("Failed to send share notifications");
480
+ }
481
+ }
482
+ ```
483
+
484
+ ## ketrics.Users
485
+
486
+ List users in the current tenant.
487
+
488
+ ```typescript
489
+ const tenantUsers = await ketrics.Users.list();
490
+ // Returns: { id: string, firstName: string, lastName: string, email: string }[]
491
+
492
+ const users = tenantUsers.map(user => ({
493
+ userId: user.id,
494
+ name: `${user.firstName} ${user.lastName}`.trim(),
495
+ email: user.email,
496
+ }));
497
+ ```
498
+
499
+ ## ketrics.application
500
+
501
+ ```typescript
502
+ ketrics.application.id // Application UUID, useful for namespacing Volume file keys
503
+ ```
504
+
505
+ ## ketrics.console
506
+
507
+ ```typescript
508
+ ketrics.console.error("Error message for logs");
509
+ ```
510
+
511
+ Use for non-critical errors where you don't want to throw and fail the handler.
512
+
513
+ ## Comment system pattern
514
+
515
+ A full comment system using DocumentDB with a count index for efficient lookups.
516
+
517
+ ### Add comment
518
+
519
+ ```typescript
520
+ const addComment = async (payload: { targetId: string; text: string }) => {
521
+ const { targetId, text } = payload;
522
+ if (!targetId) throw new Error("targetId is required");
523
+ if (!text?.trim()) throw new Error("text is required");
524
+
525
+ const userId = ketrics.requestor.userId;
526
+ const userName = ketrics.requestor.name || ketrics.requestor.email || userId;
527
+ const docdb = await ketrics.DocumentDb.connect(getDocDbCode());
528
+ const commentId = crypto.randomUUID();
529
+ const now = new Date().toISOString();
530
+
531
+ const comment = {
532
+ commentId,
533
+ text: text.trim(),
534
+ targetId,
535
+ createdBy: userId,
536
+ createdByName: userName,
537
+ createdAt: now,
538
+ };
539
+
540
+ // Store comment (chronologically sorted by sk)
541
+ await docdb.put(
542
+ `COMMENTS#${targetId}`,
543
+ `COMMENT#${now}#${commentId}`,
544
+ comment as Record<string, unknown>
545
+ );
546
+
547
+ // Update count index
548
+ const indexPk = `COMMENTINDEX#main`;
549
+ const indexSk = `TARGET#${targetId}`;
550
+ const existing = await docdb.get(indexPk, indexSk);
551
+ const count = existing ? (existing.count as number) || 0 : 0;
552
+ await docdb.put(indexPk, indexSk, { targetId, count: count + 1, lastCommentAt: now });
553
+
554
+ return { comment };
555
+ };
556
+ ```
557
+
558
+ ### List comments
559
+
560
+ ```typescript
561
+ const getComments = async (payload: { targetId: string }) => {
562
+ const { targetId } = payload;
563
+ if (!targetId) throw new Error("targetId is required");
564
+
565
+ const docdb = await ketrics.DocumentDb.connect(getDocDbCode());
566
+ const result = await docdb.list(`COMMENTS#${targetId}`, {
567
+ skPrefix: "COMMENT#",
568
+ scanForward: false, // Newest first
569
+ });
570
+
571
+ return { comments: result.items };
572
+ };
573
+ ```
574
+
575
+ ### Get comment counts (batch)
576
+
577
+ ```typescript
578
+ const getCommentCounts = async (payload: { targetIds: string[] }) => {
579
+ const { targetIds } = payload;
580
+ const docdb = await ketrics.DocumentDb.connect(getDocDbCode());
581
+ const result = await docdb.list(`COMMENTINDEX#main`, {
582
+ skPrefix: "TARGET#",
583
+ limit: 1000,
584
+ });
585
+
586
+ const counts: Record<string, number> = {};
587
+ const requested = new Set(targetIds || []);
588
+ for (const item of result.items) {
589
+ const key = item.targetId as string;
590
+ if (key && (requested.size === 0 || requested.has(key))) {
591
+ counts[key] = (item.count as number) || 0;
592
+ }
593
+ }
594
+ return { counts };
595
+ };
596
+ ```
597
+
598
+ ### Delete comment (with index update)
599
+
600
+ ```typescript
601
+ const deleteComment = async (payload: { targetId: string; commentId: string; createdAt: string }) => {
602
+ const { targetId, commentId, createdAt } = payload;
603
+ const userId = ketrics.requestor.userId;
604
+ const docdb = await ketrics.DocumentDb.connect(getDocDbCode());
605
+
606
+ const pk = `COMMENTS#${targetId}`;
607
+ const sk = `COMMENT#${createdAt}#${commentId}`;
608
+
609
+ const existing = await docdb.get(pk, sk);
610
+ if (!existing) throw new Error("Comment not found");
611
+ if (existing.createdBy !== userId) throw new Error("You can only delete your own comments");
612
+
613
+ await docdb.delete(pk, sk);
614
+
615
+ // Decrement count index
616
+ const indexPk = `COMMENTINDEX#main`;
617
+ const indexSk = `TARGET#${targetId}`;
618
+ const indexItem = await docdb.get(indexPk, indexSk);
619
+ if (indexItem) {
620
+ const count = (indexItem.count as number) || 0;
621
+ if (count <= 1) {
622
+ await docdb.delete(indexPk, indexSk);
623
+ } else {
624
+ await docdb.put(indexPk, indexSk, { targetId, count: count - 1, lastCommentAt: indexItem.lastCommentAt });
625
+ }
626
+ }
627
+
628
+ return { success: true };
629
+ };
630
+ ```
631
+
632
+ ## Sharing and access control pattern
633
+
634
+ For resources that can be shared between users:
635
+
636
+ ```typescript
637
+ interface SharedResource {
638
+ id: string;
639
+ owner: string;
640
+ visibility: "private" | "shared";
641
+ sharedWith: string[] | "all";
642
+ // ... other fields
643
+ }
644
+
645
+ // Share handler
646
+ const shareResource = async (payload: { id: string; visibility: "private" | "shared"; sharedWith: string[] | "all" }) => {
647
+ requireEditor();
648
+ const { id, visibility, sharedWith } = payload;
649
+ const userId = ketrics.requestor.userId;
650
+ const docdb = await ketrics.DocumentDb.connect(getDocDbCode());
651
+
652
+ const existing = await docdb.get("TENANT_RESOURCES", `RESOURCE#${id}`);
653
+ if (!existing) throw new Error("Not found");
654
+ if (existing.owner !== userId) throw new Error("Only the owner can share");
655
+
656
+ await docdb.put("TENANT_RESOURCES", `RESOURCE#${id}`, {
657
+ ...existing,
658
+ visibility: visibility || "private",
659
+ sharedWith: sharedWith || [],
660
+ updatedAt: new Date().toISOString(),
661
+ });
662
+
663
+ return { success: true };
664
+ };
665
+
666
+ // Access check helper
667
+ function checkAccess(item: Record<string, unknown>, userId: string): void {
668
+ if (item.owner === userId) return;
669
+ if (item.visibility !== "shared") throw new Error("Access denied");
670
+ const sw = item.sharedWith;
671
+ if (sw === "all") return;
672
+ if (Array.isArray(sw) && sw.includes(userId)) return;
673
+ throw new Error("Access denied");
674
+ }
675
+
676
+ // List with visibility filter
677
+ const listResources = async () => {
678
+ const userId = ketrics.requestor.userId;
679
+ const docdb = await ketrics.DocumentDb.connect(getDocDbCode());
680
+ const result = await docdb.list("TENANT_RESOURCES", { skPrefix: "RESOURCE#", scanForward: false });
681
+
682
+ const resources = result.items.filter(item => {
683
+ if (item.owner === userId) return true;
684
+ if (item.visibility !== "shared") return false;
685
+ const sw = item.sharedWith;
686
+ if (sw === "all") return true;
687
+ if (Array.isArray(sw) && sw.includes(userId)) return true;
688
+ return false;
689
+ });
690
+
691
+ return { resources };
692
+ };
693
+ ```