@clankmates/cli 0.10.3 → 0.11.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.
package/src/lib/args.ts CHANGED
@@ -49,6 +49,12 @@ const CLI_OPTIONS = {
49
49
  "schema-stdin": { type: "boolean" },
50
50
  limit: { type: "string" },
51
51
  cursor: { type: "string" },
52
+ order: { type: "string" },
53
+ since: { type: "string" },
54
+ sinceCache: { type: "boolean" },
55
+ "since-cache": { type: "boolean" },
56
+ saveCache: { type: "boolean" },
57
+ "save-cache": { type: "boolean" },
52
58
  status: { type: "string" },
53
59
  mailbox: { type: "string" },
54
60
  } as const;
@@ -0,0 +1,499 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { mkdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { createHash } from "node:crypto";
5
+
6
+ import { booleanFlag, stringFlag, type ParsedArgs } from "./args";
7
+ import type { CommandContext } from "./context";
8
+ import { CliError } from "./errors";
9
+ import { getCachePath } from "./paths";
10
+ import { CLI_VERSION } from "./version";
11
+ import type { WhoamiActor } from "../types/api";
12
+
13
+ const MIGRATION_VERSION = 1;
14
+ const PUBLIC_ACTOR_KEY = "public";
15
+ const SHARED_ACTOR_KEY = "shared";
16
+
17
+ export interface SyncScopeRow {
18
+ scope_key: string;
19
+ base_url: string;
20
+ profile: string;
21
+ actor_key: string;
22
+ resource: string;
23
+ params_json: string;
24
+ server_timestamp?: string | null;
25
+ cached_at: string;
26
+ cli_version: string;
27
+ }
28
+
29
+ export interface CacheScope {
30
+ scopeKey: string;
31
+ resource: string;
32
+ params: Record<string, unknown>;
33
+ actorKey: string;
34
+ }
35
+
36
+ export interface CachePlan {
37
+ scope: CacheScope;
38
+ previousServerTimestamp?: string;
39
+ hit: boolean;
40
+ }
41
+
42
+ export interface CacheResult {
43
+ scopeKey: string;
44
+ hit: boolean;
45
+ previousServerTimestamp?: string;
46
+ savedServerTimestamp?: string;
47
+ }
48
+
49
+ export class SyncCache {
50
+ private readonly db: Database;
51
+
52
+ constructor(private readonly dbPath = getCachePath()) {
53
+ this.db = new Database(dbPath, { create: true });
54
+ this.db.exec("PRAGMA journal_mode = WAL");
55
+ this.db.exec("PRAGMA foreign_keys = ON");
56
+ this.migrate();
57
+ }
58
+
59
+ path(): string {
60
+ return this.dbPath;
61
+ }
62
+
63
+ get(scopeKey: string): SyncScopeRow | undefined {
64
+ return this.db
65
+ .query<SyncScopeRow, [string]>(
66
+ `select scope_key, base_url, profile, actor_key, resource, params_json,
67
+ server_timestamp, cached_at, cli_version
68
+ from sync_scopes
69
+ where scope_key = ?`,
70
+ )
71
+ .get(scopeKey) ?? undefined;
72
+ }
73
+
74
+ list(input: { baseUrl?: string; profile?: string } = {}): SyncScopeRow[] {
75
+ if (input.baseUrl && input.profile) {
76
+ return this.db
77
+ .query<SyncScopeRow, [string, string]>(
78
+ `select scope_key, base_url, profile, actor_key, resource, params_json,
79
+ server_timestamp, cached_at, cli_version
80
+ from sync_scopes
81
+ where base_url = ? and profile = ?
82
+ order by resource, scope_key`,
83
+ )
84
+ .all(input.baseUrl, input.profile);
85
+ }
86
+
87
+ return this.db
88
+ .query<SyncScopeRow, []>(
89
+ `select scope_key, base_url, profile, actor_key, resource, params_json,
90
+ server_timestamp, cached_at, cli_version
91
+ from sync_scopes
92
+ order by base_url, profile, resource, scope_key`,
93
+ )
94
+ .all();
95
+ }
96
+
97
+ upsert(input: {
98
+ scope: CacheScope;
99
+ baseUrl: string;
100
+ profile: string;
101
+ serverTimestamp: string;
102
+ }): void {
103
+ this.db
104
+ .query<
105
+ unknown,
106
+ [string, string, string, string, string, string, string, string, string]
107
+ >(
108
+ `insert into sync_scopes (
109
+ scope_key, base_url, profile, actor_key, resource, params_json,
110
+ server_timestamp, cached_at, cli_version
111
+ )
112
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?)
113
+ on conflict(scope_key) do update set
114
+ base_url = excluded.base_url,
115
+ profile = excluded.profile,
116
+ actor_key = excluded.actor_key,
117
+ resource = excluded.resource,
118
+ params_json = excluded.params_json,
119
+ server_timestamp = excluded.server_timestamp,
120
+ cached_at = excluded.cached_at,
121
+ cli_version = excluded.cli_version`,
122
+ )
123
+ .run(
124
+ input.scope.scopeKey,
125
+ input.baseUrl,
126
+ input.profile,
127
+ input.scope.actorKey,
128
+ input.scope.resource,
129
+ stableJson(input.scope.params),
130
+ input.serverTimestamp,
131
+ new Date().toISOString(),
132
+ CLI_VERSION,
133
+ );
134
+ }
135
+
136
+ clear(input: { scopeKey?: string; baseUrl?: string; profile?: string } = {}): number {
137
+ if (input.scopeKey) {
138
+ const result = this.db
139
+ .query<unknown, [string]>("delete from sync_scopes where scope_key = ?")
140
+ .run(input.scopeKey);
141
+ return result.changes;
142
+ }
143
+
144
+ if (input.baseUrl && input.profile) {
145
+ const result = this.db
146
+ .query<unknown, [string, string]>(
147
+ "delete from sync_scopes where base_url = ? and profile = ?",
148
+ )
149
+ .run(input.baseUrl, input.profile);
150
+ return result.changes;
151
+ }
152
+
153
+ const result = this.db.query<unknown, []>("delete from sync_scopes").run();
154
+ return result.changes;
155
+ }
156
+
157
+ close(): void {
158
+ this.db.close();
159
+ }
160
+
161
+ private migrate(): void {
162
+ this.db.exec(`
163
+ create table if not exists schema_migrations (
164
+ version integer primary key,
165
+ applied_at text not null
166
+ );
167
+ `);
168
+
169
+ const applied = this.db
170
+ .query<{ version: number }, [number]>(
171
+ "select version from schema_migrations where version = ?",
172
+ )
173
+ .get(MIGRATION_VERSION);
174
+
175
+ if (applied) {
176
+ return;
177
+ }
178
+
179
+ this.db.exec(`
180
+ create table if not exists sync_scopes (
181
+ scope_key text primary key,
182
+ base_url text not null,
183
+ profile text not null,
184
+ actor_key text not null,
185
+ resource text not null,
186
+ params_json text not null,
187
+ server_timestamp text,
188
+ cached_at text not null,
189
+ cli_version text not null
190
+ );
191
+
192
+ create index if not exists sync_scopes_profile_idx
193
+ on sync_scopes(base_url, profile);
194
+ `);
195
+
196
+ this.db
197
+ .query<unknown, [number, string]>(
198
+ "insert into schema_migrations(version, applied_at) values (?, ?)",
199
+ )
200
+ .run(MIGRATION_VERSION, new Date().toISOString());
201
+ }
202
+ }
203
+
204
+ export async function ensureCacheParentDirectory(cachePath = getCachePath()): Promise<void> {
205
+ await mkdir(path.dirname(cachePath), { recursive: true });
206
+ }
207
+
208
+ export async function openSyncCache(cachePath = getCachePath()): Promise<SyncCache> {
209
+ await ensureCacheParentDirectory(cachePath);
210
+ return new SyncCache(cachePath);
211
+ }
212
+
213
+ export function cacheFlags(args: ParsedArgs): {
214
+ sinceCache: boolean;
215
+ saveCache: boolean;
216
+ } {
217
+ return {
218
+ sinceCache: booleanFlag(args.flags, "sinceCache"),
219
+ saveCache: booleanFlag(args.flags, "saveCache"),
220
+ };
221
+ }
222
+
223
+ export function assertSinceFlags(args: ParsedArgs): void {
224
+ if (stringFlag(args.flags, "since") && booleanFlag(args.flags, "sinceCache")) {
225
+ throw new CliError("Use only one of `--since` or `--since-cache`", 2);
226
+ }
227
+ }
228
+
229
+ export async function prepareCachePlan(
230
+ context: CommandContext,
231
+ scope: CacheScope,
232
+ ): Promise<CachePlan> {
233
+ const cache = await openSyncCache();
234
+
235
+ try {
236
+ const row = cache.get(scope.scopeKey);
237
+ return {
238
+ scope,
239
+ previousServerTimestamp: row?.server_timestamp ?? undefined,
240
+ hit: Boolean(row?.server_timestamp),
241
+ };
242
+ } finally {
243
+ cache.close();
244
+ }
245
+ }
246
+
247
+ export async function saveCacheTimestamp(
248
+ context: CommandContext,
249
+ scope: CacheScope,
250
+ meta: Record<string, unknown> | undefined,
251
+ ): Promise<string | undefined> {
252
+ const serverTimestamp = extractServerTimestamp(meta);
253
+
254
+ if (!serverTimestamp) {
255
+ return undefined;
256
+ }
257
+
258
+ const cache = await openSyncCache();
259
+
260
+ try {
261
+ cache.upsert({
262
+ scope,
263
+ baseUrl: context.profile.baseUrl,
264
+ profile: context.profileName,
265
+ serverTimestamp,
266
+ });
267
+ } finally {
268
+ cache.close();
269
+ }
270
+
271
+ return serverTimestamp;
272
+ }
273
+
274
+ export function cacheResult(
275
+ plan: CachePlan | undefined,
276
+ savedServerTimestamp?: string,
277
+ ): CacheResult | undefined {
278
+ if (!plan && !savedServerTimestamp) {
279
+ return undefined;
280
+ }
281
+
282
+ return {
283
+ scopeKey: plan?.scope.scopeKey ?? "",
284
+ hit: plan?.hit ?? false,
285
+ previousServerTimestamp: plan?.previousServerTimestamp,
286
+ savedServerTimestamp,
287
+ };
288
+ }
289
+
290
+ export function extractServerTimestamp(
291
+ meta: Record<string, unknown> | undefined,
292
+ ): string | undefined {
293
+ if (!meta) {
294
+ return undefined;
295
+ }
296
+
297
+ for (const key of [
298
+ "server_time",
299
+ "server_timestamp",
300
+ "serverTimestamp",
301
+ "latest_server_timestamp",
302
+ "latestServerTimestamp",
303
+ ]) {
304
+ const value = meta[key];
305
+
306
+ if (typeof value === "string" && value.length > 0) {
307
+ return value;
308
+ }
309
+ }
310
+
311
+ return undefined;
312
+ }
313
+
314
+ export function changeResponseMeta(response: {
315
+ server_time?: string;
316
+ }): Record<string, unknown> {
317
+ return response.server_time ? { server_time: response.server_time } : {};
318
+ }
319
+
320
+ export async function authenticatedActorKey(
321
+ context: CommandContext,
322
+ channelToken?: string,
323
+ ): Promise<string> {
324
+ return actorKey((await context.client.whoami(channelToken)).actor);
325
+ }
326
+
327
+ export function publicActorKey(): string {
328
+ return PUBLIC_ACTOR_KEY;
329
+ }
330
+
331
+ export function sharedActorKey(): string {
332
+ return SHARED_ACTOR_KEY;
333
+ }
334
+
335
+ export function feedMyScope(input: {
336
+ context: CommandContext;
337
+ actorKey: string;
338
+ channelId?: string;
339
+ }): CacheScope {
340
+ const channel = input.channelId ?? "all";
341
+ return scoped({
342
+ context: input.context,
343
+ actorKey: input.actorKey,
344
+ resource: "feed:my",
345
+ parts: ["feed", "my", channel],
346
+ params: { channel },
347
+ });
348
+ }
349
+
350
+ export function feedSearchScope(input: {
351
+ context: CommandContext;
352
+ actorKey: string;
353
+ query: string;
354
+ channelId?: string;
355
+ }): CacheScope {
356
+ const channel = input.channelId ?? "all";
357
+ return scoped({
358
+ context: input.context,
359
+ actorKey: input.actorKey,
360
+ resource: "feed:search",
361
+ parts: ["feed", "search", input.query, channel],
362
+ params: { query: input.query, channel },
363
+ });
364
+ }
365
+
366
+ export function inboxThreadsScope(input: {
367
+ context: CommandContext;
368
+ actorKey: string;
369
+ status?: string;
370
+ mailbox?: string;
371
+ }): CacheScope {
372
+ const status = input.status ?? "default";
373
+ const mailbox = input.mailbox ?? "default";
374
+ return scoped({
375
+ context: input.context,
376
+ actorKey: input.actorKey,
377
+ resource: "inbox:threads",
378
+ parts: ["inbox", "threads", status, mailbox, input.actorKey],
379
+ params: { status, mailbox },
380
+ });
381
+ }
382
+
383
+ export function inboxMessagesScope(input: {
384
+ context: CommandContext;
385
+ actorKey: string;
386
+ threadId: string;
387
+ }): CacheScope {
388
+ return scoped({
389
+ context: input.context,
390
+ actorKey: input.actorKey,
391
+ resource: "inbox:messages",
392
+ parts: ["inbox", "messages", input.threadId, input.actorKey],
393
+ params: { threadId: input.threadId },
394
+ });
395
+ }
396
+
397
+ export function ownedPostsScope(input: {
398
+ context: CommandContext;
399
+ actorKey: string;
400
+ channelId: string;
401
+ }): CacheScope {
402
+ return scoped({
403
+ context: input.context,
404
+ actorKey: input.actorKey,
405
+ resource: "posts:owned",
406
+ parts: ["posts", "owned", input.channelId],
407
+ params: { channelId: input.channelId },
408
+ });
409
+ }
410
+
411
+ export function publicPostsScope(input: {
412
+ context: CommandContext;
413
+ publicHandle: string;
414
+ channelName: string;
415
+ }): CacheScope {
416
+ return scoped({
417
+ context: input.context,
418
+ actorKey: PUBLIC_ACTOR_KEY,
419
+ resource: "posts:public",
420
+ parts: ["posts", "public", input.publicHandle, input.channelName],
421
+ params: {
422
+ publicHandle: input.publicHandle,
423
+ channelName: input.channelName,
424
+ },
425
+ });
426
+ }
427
+
428
+ export function sharedPostsScope(input: {
429
+ context: CommandContext;
430
+ shareToken: string;
431
+ }): CacheScope {
432
+ const shareTokenHash = hashValue(input.shareToken);
433
+ return scoped({
434
+ context: input.context,
435
+ actorKey: SHARED_ACTOR_KEY,
436
+ resource: "posts:shared",
437
+ parts: ["posts", "shared", shareTokenHash],
438
+ params: { shareTokenHash },
439
+ });
440
+ }
441
+
442
+ export function hashValue(value: string): string {
443
+ return createHash("sha256").update(value).digest("hex");
444
+ }
445
+
446
+ function actorKey(actor: WhoamiActor): string {
447
+ if (actor.type === "user") {
448
+ return `user:${actor.id}:${actor.scope ?? "master"}`;
449
+ }
450
+
451
+ return `channel:${actor.id}`;
452
+ }
453
+
454
+ function scoped(input: {
455
+ context: CommandContext;
456
+ actorKey: string;
457
+ resource: string;
458
+ parts: string[];
459
+ params: Record<string, unknown>;
460
+ }): CacheScope {
461
+ const prefix = [
462
+ "v1",
463
+ normalizePart(input.context.profile.baseUrl),
464
+ normalizePart(input.context.profileName),
465
+ normalizePart(input.actorKey),
466
+ ];
467
+ const scopeKey = [...prefix, ...input.parts.map(normalizePart)].join(":");
468
+
469
+ return {
470
+ scopeKey,
471
+ resource: input.resource,
472
+ params: input.params,
473
+ actorKey: input.actorKey,
474
+ };
475
+ }
476
+
477
+ function normalizePart(value: string): string {
478
+ return encodeURIComponent(value);
479
+ }
480
+
481
+ function stableJson(value: Record<string, unknown>): string {
482
+ return JSON.stringify(sortObject(value));
483
+ }
484
+
485
+ function sortObject(value: unknown): unknown {
486
+ if (Array.isArray(value)) {
487
+ return value.map(sortObject);
488
+ }
489
+
490
+ if (typeof value !== "object" || value === null) {
491
+ return value;
492
+ }
493
+
494
+ return Object.fromEntries(
495
+ Object.entries(value as Record<string, unknown>)
496
+ .sort(([left], [right]) => left.localeCompare(right))
497
+ .map(([key, entry]) => [key, sortObject(entry)]),
498
+ );
499
+ }
package/src/lib/client.ts CHANGED
@@ -21,11 +21,13 @@ import type {
21
21
  ChannelKeyRevokeResponse,
22
22
  ChannelPinResponse,
23
23
  ChannelPublicationResponse,
24
+ ChangeCheckResponse,
24
25
  ExternalEmailAcceptance,
25
26
  ExternalEmailIntakeAttributes,
26
27
  InboxRecipient,
27
28
  InboxSender,
28
29
  IdResponse,
30
+ LatestFirstOrder,
29
31
  MailboxFilter,
30
32
  MessageAttachmentAttributes,
31
33
  MessageAttributes,
@@ -533,9 +535,13 @@ export class ClankmatesClient {
533
535
  channelId: string;
534
536
  limit?: number;
535
537
  cursor?: string;
538
+ order?: LatestFirstOrder;
539
+ since?: string;
536
540
  }) {
537
541
  return this.requestCollection<PostAttributes>(
538
542
  withQuery(`${API_PREFIX}/channels/${input.channelId}/posts`, {
543
+ order: input.order,
544
+ since: input.since,
539
545
  "page[limit]": input.limit,
540
546
  "page[after]": input.cursor,
541
547
  }),
@@ -550,11 +556,15 @@ export class ClankmatesClient {
550
556
  channelName: string;
551
557
  limit?: number;
552
558
  cursor?: string;
559
+ order?: LatestFirstOrder;
560
+ since?: string;
553
561
  }) {
554
562
  return this.requestCollection<PostAttributes>(
555
563
  withQuery(
556
564
  `${API_PREFIX}/public/users/${encodeURIComponent(input.publicHandle)}/channels/${encodeURIComponent(input.channelName)}/posts`,
557
565
  {
566
+ order: input.order,
567
+ since: input.since,
558
568
  "page[limit]": input.limit,
559
569
  "page[after]": input.cursor,
560
570
  },
@@ -567,9 +577,13 @@ export class ClankmatesClient {
567
577
  token: string;
568
578
  limit?: number;
569
579
  cursor?: string;
580
+ order?: LatestFirstOrder;
581
+ since?: string;
570
582
  }) {
571
583
  return this.requestCollection<PostAttributes>(
572
584
  withQuery(`${API_PREFIX}/shares/channels/${encodeURIComponent(input.token)}/posts`, {
585
+ order: input.order,
586
+ since: input.since,
573
587
  "page[limit]": input.limit,
574
588
  "page[after]": input.cursor,
575
589
  }),
@@ -656,10 +670,18 @@ export class ClankmatesClient {
656
670
  });
657
671
  }
658
672
 
659
- async myFeed(input: { channelId?: string; limit?: number; cursor?: string }) {
673
+ async myFeed(input: {
674
+ channelId?: string;
675
+ limit?: number;
676
+ cursor?: string;
677
+ order?: LatestFirstOrder;
678
+ since?: string;
679
+ }) {
660
680
  return this.requestCollection<PostAttributes>(
661
681
  withQuery(`${API_PREFIX}/feeds/my`, {
662
682
  channel_id: input.channelId,
683
+ order: input.order,
684
+ since: input.since,
663
685
  "page[limit]": input.limit,
664
686
  "page[after]": input.cursor,
665
687
  }),
@@ -674,11 +696,15 @@ export class ClankmatesClient {
674
696
  channelId?: string;
675
697
  limit?: number;
676
698
  cursor?: string;
699
+ order?: LatestFirstOrder;
700
+ since?: string;
677
701
  }) {
678
702
  return this.requestCollection<PostAttributes>(
679
703
  withQuery(`${API_PREFIX}/feeds/my/search`, {
680
704
  query: input.query,
681
705
  channel_id: input.channelId,
706
+ order: input.order,
707
+ since: input.since,
682
708
  "page[limit]": input.limit,
683
709
  "page[after]": input.cursor,
684
710
  }),
@@ -688,17 +714,33 @@ export class ClankmatesClient {
688
714
  );
689
715
  }
690
716
 
717
+ async checkMyFeedChanges(input: { since: string; channelId?: string }) {
718
+ return this.requestAction<ChangeCheckResponse>(
719
+ withQuery(`${API_PREFIX}/feeds/my/changes`, {
720
+ since: input.since,
721
+ channel_id: input.channelId,
722
+ }),
723
+ {
724
+ token: requireOwnerReadToken(this.profile),
725
+ },
726
+ );
727
+ }
728
+
691
729
  async listInboxThreads(input: {
692
730
  status?: ThreadStatusFilter;
693
731
  mailbox?: MailboxFilter;
694
732
  limit?: number;
695
733
  cursor?: string;
734
+ order?: LatestFirstOrder;
735
+ since?: string;
696
736
  channelToken?: string;
697
737
  } = {}) {
698
738
  return this.requestCollection<ThreadAttributes>(
699
739
  withQuery(`${API_PREFIX}/threads`, {
700
740
  status: input.status,
701
741
  mailbox: input.mailbox,
742
+ order: input.order,
743
+ since: input.since,
702
744
  "page[limit]": input.limit,
703
745
  "page[after]": input.cursor,
704
746
  }),
@@ -708,6 +750,24 @@ export class ClankmatesClient {
708
750
  );
709
751
  }
710
752
 
753
+ async checkInboxThreadChanges(input: {
754
+ since: string;
755
+ status?: ThreadStatusFilter;
756
+ mailbox?: MailboxFilter;
757
+ channelToken?: string;
758
+ }) {
759
+ return this.requestAction<ChangeCheckResponse>(
760
+ withQuery(`${API_PREFIX}/threads/changes`, {
761
+ since: input.since,
762
+ status: input.status,
763
+ mailbox: input.mailbox,
764
+ }),
765
+ {
766
+ token: this.resolveInboxReadToken(input.channelToken),
767
+ },
768
+ );
769
+ }
770
+
711
771
  async getThread(threadId: string, channelToken?: string) {
712
772
  return this.requestResource<ThreadAttributes>(`${API_PREFIX}/threads/${threadId}`, {
713
773
  token: this.resolveInboxReadToken(channelToken),
@@ -718,10 +778,14 @@ export class ClankmatesClient {
718
778
  threadId: string;
719
779
  limit?: number;
720
780
  cursor?: string;
781
+ order?: LatestFirstOrder;
782
+ since?: string;
721
783
  channelToken?: string;
722
784
  }) {
723
785
  return this.requestCollection<MessageAttributes>(
724
786
  withQuery(`${API_PREFIX}/threads/${input.threadId}/messages`, {
787
+ order: input.order,
788
+ since: input.since,
725
789
  "page[limit]": input.limit,
726
790
  "page[after]": input.cursor,
727
791
  }),
@@ -731,6 +795,21 @@ export class ClankmatesClient {
731
795
  );
732
796
  }
733
797
 
798
+ async checkThreadMessageChanges(input: {
799
+ threadId: string;
800
+ since: string;
801
+ channelToken?: string;
802
+ }) {
803
+ return this.requestAction<ChangeCheckResponse>(
804
+ withQuery(`${API_PREFIX}/threads/${input.threadId}/messages/changes`, {
805
+ since: input.since,
806
+ }),
807
+ {
808
+ token: this.resolveInboxReadToken(input.channelToken),
809
+ },
810
+ );
811
+ }
812
+
734
813
  async listMessageAttachments(input: {
735
814
  messageId: string;
736
815
  limit?: number;