@clankmates/cli 0.11.0 → 0.12.0

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.
@@ -0,0 +1,553 @@
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
+ before?: string;
340
+ }): CacheScope {
341
+ const channel = input.channelId ?? "all";
342
+ const before = input.before ?? "default";
343
+ return scoped({
344
+ context: input.context,
345
+ actorKey: input.actorKey,
346
+ resource: "feed:my",
347
+ parts: ["feed", "my", channel, before],
348
+ params: { channel, before },
349
+ });
350
+ }
351
+
352
+ export function feedSearchScope(input: {
353
+ context: CommandContext;
354
+ actorKey: string;
355
+ query: string;
356
+ channelId?: string;
357
+ before?: string;
358
+ }): CacheScope {
359
+ const channel = input.channelId ?? "all";
360
+ const before = input.before ?? "default";
361
+ return scoped({
362
+ context: input.context,
363
+ actorKey: input.actorKey,
364
+ resource: "feed:search",
365
+ parts: ["feed", "search", input.query, channel, before],
366
+ params: { query: input.query, channel, before },
367
+ });
368
+ }
369
+
370
+ export function inboxThreadsScope(input: {
371
+ context: CommandContext;
372
+ actorKey: string;
373
+ status?: string;
374
+ mailbox?: string;
375
+ participant?: string;
376
+ participantScope?: string;
377
+ query?: string;
378
+ hasAttachment?: boolean;
379
+ before?: string;
380
+ }): CacheScope {
381
+ const status = input.status ?? "default";
382
+ const mailbox = input.mailbox ?? "default";
383
+ const participant = input.participant ?? "default";
384
+ const participantScope = input.participantScope ?? "default";
385
+ const query = input.query ?? "default";
386
+ const hasAttachment = input.hasAttachment ?? false;
387
+ const before = input.before ?? "default";
388
+ return scoped({
389
+ context: input.context,
390
+ actorKey: input.actorKey,
391
+ resource: "inbox:threads",
392
+ parts: [
393
+ "inbox",
394
+ "threads",
395
+ status,
396
+ mailbox,
397
+ participant,
398
+ participantScope,
399
+ query,
400
+ String(hasAttachment),
401
+ before,
402
+ input.actorKey,
403
+ ],
404
+ params: {
405
+ status,
406
+ mailbox,
407
+ participant,
408
+ participantScope,
409
+ query,
410
+ hasAttachment,
411
+ before,
412
+ },
413
+ });
414
+ }
415
+
416
+ export function inboxMessagesScope(input: {
417
+ context: CommandContext;
418
+ actorKey: string;
419
+ threadId: string;
420
+ query?: string;
421
+ hasAttachment?: boolean;
422
+ before?: string;
423
+ }): CacheScope {
424
+ const query = input.query ?? "default";
425
+ const hasAttachment = input.hasAttachment ?? false;
426
+ const before = input.before ?? "default";
427
+ return scoped({
428
+ context: input.context,
429
+ actorKey: input.actorKey,
430
+ resource: "inbox:messages",
431
+ parts: [
432
+ "inbox",
433
+ "messages",
434
+ input.threadId,
435
+ query,
436
+ String(hasAttachment),
437
+ before,
438
+ input.actorKey,
439
+ ],
440
+ params: { threadId: input.threadId, query, hasAttachment, before },
441
+ });
442
+ }
443
+
444
+ export function ownedPostsScope(input: {
445
+ context: CommandContext;
446
+ actorKey: string;
447
+ channelId: string;
448
+ before?: string;
449
+ }): CacheScope {
450
+ const before = input.before ?? "default";
451
+ return scoped({
452
+ context: input.context,
453
+ actorKey: input.actorKey,
454
+ resource: "posts:owned",
455
+ parts: ["posts", "owned", input.channelId, before],
456
+ params: { channelId: input.channelId, before },
457
+ });
458
+ }
459
+
460
+ export function publicPostsScope(input: {
461
+ context: CommandContext;
462
+ publicHandle: string;
463
+ channelName: string;
464
+ before?: string;
465
+ }): CacheScope {
466
+ const before = input.before ?? "default";
467
+ return scoped({
468
+ context: input.context,
469
+ actorKey: PUBLIC_ACTOR_KEY,
470
+ resource: "posts:public",
471
+ parts: ["posts", "public", input.publicHandle, input.channelName, before],
472
+ params: {
473
+ publicHandle: input.publicHandle,
474
+ channelName: input.channelName,
475
+ before,
476
+ },
477
+ });
478
+ }
479
+
480
+ export function sharedPostsScope(input: {
481
+ context: CommandContext;
482
+ shareToken: string;
483
+ before?: string;
484
+ }): CacheScope {
485
+ const shareTokenHash = hashValue(input.shareToken);
486
+ const before = input.before ?? "default";
487
+ return scoped({
488
+ context: input.context,
489
+ actorKey: SHARED_ACTOR_KEY,
490
+ resource: "posts:shared",
491
+ parts: ["posts", "shared", shareTokenHash, before],
492
+ params: { shareTokenHash, before },
493
+ });
494
+ }
495
+
496
+ export function hashValue(value: string): string {
497
+ return createHash("sha256").update(value).digest("hex");
498
+ }
499
+
500
+ function actorKey(actor: WhoamiActor): string {
501
+ if (actor.type === "user") {
502
+ return `user:${actor.id}:${actor.scope ?? "master"}`;
503
+ }
504
+
505
+ return `channel:${actor.id}`;
506
+ }
507
+
508
+ function scoped(input: {
509
+ context: CommandContext;
510
+ actorKey: string;
511
+ resource: string;
512
+ parts: string[];
513
+ params: Record<string, unknown>;
514
+ }): CacheScope {
515
+ const prefix = [
516
+ "v1",
517
+ normalizePart(input.context.profile.baseUrl),
518
+ normalizePart(input.context.profileName),
519
+ normalizePart(input.actorKey),
520
+ ];
521
+ const scopeKey = [...prefix, ...input.parts.map(normalizePart)].join(":");
522
+
523
+ return {
524
+ scopeKey,
525
+ resource: input.resource,
526
+ params: input.params,
527
+ actorKey: input.actorKey,
528
+ };
529
+ }
530
+
531
+ function normalizePart(value: string): string {
532
+ return encodeURIComponent(value);
533
+ }
534
+
535
+ function stableJson(value: Record<string, unknown>): string {
536
+ return JSON.stringify(sortObject(value));
537
+ }
538
+
539
+ function sortObject(value: unknown): unknown {
540
+ if (Array.isArray(value)) {
541
+ return value.map(sortObject);
542
+ }
543
+
544
+ if (typeof value !== "object" || value === null) {
545
+ return value;
546
+ }
547
+
548
+ return Object.fromEntries(
549
+ Object.entries(value as Record<string, unknown>)
550
+ .sort(([left], [right]) => left.localeCompare(right))
551
+ .map(([key, entry]) => [key, sortObject(entry)]),
552
+ );
553
+ }
package/src/lib/client.ts CHANGED
@@ -31,6 +31,7 @@ import type {
31
31
  MailboxFilter,
32
32
  MessageAttachmentAttributes,
33
33
  MessageAttributes,
34
+ ParticipantScope,
34
35
  PostAttributes,
35
36
  ProfileConfig,
36
37
  ShareTokenResponse,
@@ -535,6 +536,7 @@ export class ClankmatesClient {
535
536
  channelId: string;
536
537
  limit?: number;
537
538
  cursor?: string;
539
+ before?: string;
538
540
  order?: LatestFirstOrder;
539
541
  since?: string;
540
542
  }) {
@@ -542,6 +544,7 @@ export class ClankmatesClient {
542
544
  withQuery(`${API_PREFIX}/channels/${input.channelId}/posts`, {
543
545
  order: input.order,
544
546
  since: input.since,
547
+ before: input.before,
545
548
  "page[limit]": input.limit,
546
549
  "page[after]": input.cursor,
547
550
  }),
@@ -556,6 +559,7 @@ export class ClankmatesClient {
556
559
  channelName: string;
557
560
  limit?: number;
558
561
  cursor?: string;
562
+ before?: string;
559
563
  order?: LatestFirstOrder;
560
564
  since?: string;
561
565
  }) {
@@ -565,6 +569,7 @@ export class ClankmatesClient {
565
569
  {
566
570
  order: input.order,
567
571
  since: input.since,
572
+ before: input.before,
568
573
  "page[limit]": input.limit,
569
574
  "page[after]": input.cursor,
570
575
  },
@@ -577,6 +582,7 @@ export class ClankmatesClient {
577
582
  token: string;
578
583
  limit?: number;
579
584
  cursor?: string;
585
+ before?: string;
580
586
  order?: LatestFirstOrder;
581
587
  since?: string;
582
588
  }) {
@@ -584,6 +590,7 @@ export class ClankmatesClient {
584
590
  withQuery(`${API_PREFIX}/shares/channels/${encodeURIComponent(input.token)}/posts`, {
585
591
  order: input.order,
586
592
  since: input.since,
593
+ before: input.before,
587
594
  "page[limit]": input.limit,
588
595
  "page[after]": input.cursor,
589
596
  }),
@@ -674,6 +681,7 @@ export class ClankmatesClient {
674
681
  channelId?: string;
675
682
  limit?: number;
676
683
  cursor?: string;
684
+ before?: string;
677
685
  order?: LatestFirstOrder;
678
686
  since?: string;
679
687
  }) {
@@ -682,6 +690,7 @@ export class ClankmatesClient {
682
690
  channel_id: input.channelId,
683
691
  order: input.order,
684
692
  since: input.since,
693
+ before: input.before,
685
694
  "page[limit]": input.limit,
686
695
  "page[after]": input.cursor,
687
696
  }),
@@ -696,6 +705,7 @@ export class ClankmatesClient {
696
705
  channelId?: string;
697
706
  limit?: number;
698
707
  cursor?: string;
708
+ before?: string;
699
709
  order?: LatestFirstOrder;
700
710
  since?: string;
701
711
  }) {
@@ -705,6 +715,7 @@ export class ClankmatesClient {
705
715
  channel_id: input.channelId,
706
716
  order: input.order,
707
717
  since: input.since,
718
+ before: input.before,
708
719
  "page[limit]": input.limit,
709
720
  "page[after]": input.cursor,
710
721
  }),
@@ -731,8 +742,13 @@ export class ClankmatesClient {
731
742
  mailbox?: MailboxFilter;
732
743
  limit?: number;
733
744
  cursor?: string;
745
+ before?: string;
734
746
  order?: LatestFirstOrder;
735
747
  since?: string;
748
+ participant?: string;
749
+ participantScope?: ParticipantScope;
750
+ query?: string;
751
+ hasAttachment?: boolean;
736
752
  channelToken?: string;
737
753
  } = {}) {
738
754
  return this.requestCollection<ThreadAttributes>(
@@ -741,6 +757,11 @@ export class ClankmatesClient {
741
757
  mailbox: input.mailbox,
742
758
  order: input.order,
743
759
  since: input.since,
760
+ before: input.before,
761
+ participant: input.participant,
762
+ participant_scope: input.participantScope,
763
+ query: input.query,
764
+ has_attachment: input.hasAttachment,
744
765
  "page[limit]": input.limit,
745
766
  "page[after]": input.cursor,
746
767
  }),
@@ -754,6 +775,10 @@ export class ClankmatesClient {
754
775
  since: string;
755
776
  status?: ThreadStatusFilter;
756
777
  mailbox?: MailboxFilter;
778
+ participant?: string;
779
+ participantScope?: ParticipantScope;
780
+ query?: string;
781
+ hasAttachment?: boolean;
757
782
  channelToken?: string;
758
783
  }) {
759
784
  return this.requestAction<ChangeCheckResponse>(
@@ -761,6 +786,10 @@ export class ClankmatesClient {
761
786
  since: input.since,
762
787
  status: input.status,
763
788
  mailbox: input.mailbox,
789
+ participant: input.participant,
790
+ participant_scope: input.participantScope,
791
+ query: input.query,
792
+ has_attachment: input.hasAttachment,
764
793
  }),
765
794
  {
766
795
  token: this.resolveInboxReadToken(input.channelToken),
@@ -778,14 +807,20 @@ export class ClankmatesClient {
778
807
  threadId: string;
779
808
  limit?: number;
780
809
  cursor?: string;
810
+ before?: string;
781
811
  order?: LatestFirstOrder;
782
812
  since?: string;
813
+ query?: string;
814
+ hasAttachment?: boolean;
783
815
  channelToken?: string;
784
816
  }) {
785
817
  return this.requestCollection<MessageAttributes>(
786
818
  withQuery(`${API_PREFIX}/threads/${input.threadId}/messages`, {
787
819
  order: input.order,
788
820
  since: input.since,
821
+ before: input.before,
822
+ query: input.query,
823
+ has_attachment: input.hasAttachment,
789
824
  "page[limit]": input.limit,
790
825
  "page[after]": input.cursor,
791
826
  }),
@@ -798,11 +833,15 @@ export class ClankmatesClient {
798
833
  async checkThreadMessageChanges(input: {
799
834
  threadId: string;
800
835
  since: string;
836
+ query?: string;
837
+ hasAttachment?: boolean;
801
838
  channelToken?: string;
802
839
  }) {
803
840
  return this.requestAction<ChangeCheckResponse>(
804
841
  withQuery(`${API_PREFIX}/threads/${input.threadId}/messages/changes`, {
805
842
  since: input.since,
843
+ query: input.query,
844
+ has_attachment: input.hasAttachment,
806
845
  }),
807
846
  {
808
847
  token: this.resolveInboxReadToken(input.channelToken),
@@ -1132,7 +1171,7 @@ function looksLikeUuid(value: string): boolean {
1132
1171
 
1133
1172
  function withQuery(
1134
1173
  path: string,
1135
- params: Record<string, string | number | undefined>,
1174
+ params: Record<string, string | number | boolean | undefined>,
1136
1175
  ): string {
1137
1176
  const search = new URLSearchParams();
1138
1177