@mepuka/skygent 0.2.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.
Files changed (114) hide show
  1. package/README.md +59 -0
  2. package/index.ts +146 -0
  3. package/package.json +56 -0
  4. package/src/cli/app.ts +75 -0
  5. package/src/cli/config-command.ts +140 -0
  6. package/src/cli/config.ts +91 -0
  7. package/src/cli/derive.ts +205 -0
  8. package/src/cli/doc/annotation.ts +36 -0
  9. package/src/cli/doc/filter.ts +69 -0
  10. package/src/cli/doc/index.ts +9 -0
  11. package/src/cli/doc/post.ts +155 -0
  12. package/src/cli/doc/primitives.ts +25 -0
  13. package/src/cli/doc/render.ts +18 -0
  14. package/src/cli/doc/table.ts +114 -0
  15. package/src/cli/doc/thread.ts +46 -0
  16. package/src/cli/doc/tree.ts +126 -0
  17. package/src/cli/errors.ts +59 -0
  18. package/src/cli/exit-codes.ts +52 -0
  19. package/src/cli/feed.ts +177 -0
  20. package/src/cli/filter-dsl.ts +1411 -0
  21. package/src/cli/filter-errors.ts +208 -0
  22. package/src/cli/filter-help.ts +70 -0
  23. package/src/cli/filter-input.ts +54 -0
  24. package/src/cli/filter.ts +435 -0
  25. package/src/cli/graph.ts +472 -0
  26. package/src/cli/help.ts +14 -0
  27. package/src/cli/interval.ts +35 -0
  28. package/src/cli/jetstream.ts +173 -0
  29. package/src/cli/layers.ts +180 -0
  30. package/src/cli/logging.ts +136 -0
  31. package/src/cli/output-format.ts +26 -0
  32. package/src/cli/output.ts +82 -0
  33. package/src/cli/parse.ts +80 -0
  34. package/src/cli/post.ts +193 -0
  35. package/src/cli/preferences.ts +11 -0
  36. package/src/cli/query-fields.ts +247 -0
  37. package/src/cli/query.ts +415 -0
  38. package/src/cli/range.ts +44 -0
  39. package/src/cli/search.ts +465 -0
  40. package/src/cli/shared-options.ts +169 -0
  41. package/src/cli/shared.ts +20 -0
  42. package/src/cli/store-errors.ts +80 -0
  43. package/src/cli/store-tree.ts +392 -0
  44. package/src/cli/store.ts +395 -0
  45. package/src/cli/sync-factory.ts +107 -0
  46. package/src/cli/sync.ts +366 -0
  47. package/src/cli/view-thread.ts +196 -0
  48. package/src/cli/view.ts +47 -0
  49. package/src/cli/watch.ts +344 -0
  50. package/src/db/migrations/store-catalog/001_init.ts +14 -0
  51. package/src/db/migrations/store-index/001_init.ts +34 -0
  52. package/src/db/migrations/store-index/002_event_log.ts +24 -0
  53. package/src/db/migrations/store-index/003_fts_and_derived.ts +52 -0
  54. package/src/db/migrations/store-index/004_query_indexes.ts +9 -0
  55. package/src/db/migrations/store-index/005_post_lang.ts +15 -0
  56. package/src/db/migrations/store-index/006_has_embed.ts +10 -0
  57. package/src/db/migrations/store-index/007_event_seq_and_checkpoints.ts +68 -0
  58. package/src/domain/bsky.ts +467 -0
  59. package/src/domain/config.ts +11 -0
  60. package/src/domain/credentials.ts +6 -0
  61. package/src/domain/defaults.ts +8 -0
  62. package/src/domain/derivation.ts +55 -0
  63. package/src/domain/errors.ts +71 -0
  64. package/src/domain/events.ts +55 -0
  65. package/src/domain/extract.ts +64 -0
  66. package/src/domain/filter-describe.ts +551 -0
  67. package/src/domain/filter-explain.ts +9 -0
  68. package/src/domain/filter.ts +797 -0
  69. package/src/domain/format.ts +91 -0
  70. package/src/domain/index.ts +13 -0
  71. package/src/domain/indexes.ts +17 -0
  72. package/src/domain/policies.ts +16 -0
  73. package/src/domain/post.ts +88 -0
  74. package/src/domain/primitives.ts +50 -0
  75. package/src/domain/raw.ts +140 -0
  76. package/src/domain/store.ts +103 -0
  77. package/src/domain/sync.ts +211 -0
  78. package/src/domain/text-width.ts +56 -0
  79. package/src/services/app-config.ts +278 -0
  80. package/src/services/bsky-client.ts +2113 -0
  81. package/src/services/credential-store.ts +408 -0
  82. package/src/services/derivation-engine.ts +502 -0
  83. package/src/services/derivation-settings.ts +61 -0
  84. package/src/services/derivation-validator.ts +68 -0
  85. package/src/services/filter-compiler.ts +269 -0
  86. package/src/services/filter-library.ts +371 -0
  87. package/src/services/filter-runtime.ts +821 -0
  88. package/src/services/filter-settings.ts +30 -0
  89. package/src/services/identity-resolver.ts +563 -0
  90. package/src/services/jetstream-sync.ts +636 -0
  91. package/src/services/lineage-store.ts +89 -0
  92. package/src/services/link-validator.ts +244 -0
  93. package/src/services/output-manager.ts +274 -0
  94. package/src/services/post-parser.ts +62 -0
  95. package/src/services/profile-resolver.ts +223 -0
  96. package/src/services/resource-monitor.ts +106 -0
  97. package/src/services/shared.ts +69 -0
  98. package/src/services/store-cleaner.ts +43 -0
  99. package/src/services/store-commit.ts +168 -0
  100. package/src/services/store-db.ts +248 -0
  101. package/src/services/store-event-log.ts +285 -0
  102. package/src/services/store-index-sql.ts +289 -0
  103. package/src/services/store-index.ts +1152 -0
  104. package/src/services/store-keys.ts +4 -0
  105. package/src/services/store-manager.ts +358 -0
  106. package/src/services/store-stats.ts +522 -0
  107. package/src/services/store-writer.ts +200 -0
  108. package/src/services/sync-checkpoint-store.ts +169 -0
  109. package/src/services/sync-engine.ts +547 -0
  110. package/src/services/sync-reporter.ts +16 -0
  111. package/src/services/sync-settings.ts +72 -0
  112. package/src/services/trending-topics.ts +226 -0
  113. package/src/services/view-checkpoint-store.ts +238 -0
  114. package/src/typeclass/chunk.ts +84 -0
@@ -0,0 +1,30 @@
1
+ import { Config, Context, Effect, Layer } from "effect";
2
+ import { validatePositive } from "./shared.js";
3
+
4
+ export type FilterSettingsValue = {
5
+ readonly concurrency: number;
6
+ };
7
+
8
+ export class FilterSettings extends Context.Tag("@skygent/FilterSettings")<
9
+ FilterSettings,
10
+ FilterSettingsValue
11
+ >() {
12
+ static readonly layer = Layer.effect(
13
+ FilterSettings,
14
+ Effect.gen(function* () {
15
+ const concurrency = yield* Config.integer("SKYGENT_FILTER_CONCURRENCY").pipe(
16
+ Config.withDefault(10)
17
+ );
18
+
19
+ const concurrencyError = validatePositive(
20
+ "SKYGENT_FILTER_CONCURRENCY",
21
+ concurrency
22
+ );
23
+ if (concurrencyError) {
24
+ return yield* concurrencyError;
25
+ }
26
+
27
+ return FilterSettings.of({ concurrency });
28
+ })
29
+ );
30
+ }
@@ -0,0 +1,563 @@
1
+ /**
2
+ * Identity Resolver Service
3
+ *
4
+ * Resolves Bluesky handles to DIDs (Decentralized Identifiers) and vice versa.
5
+ * Provides identity resolution with multiple caching layers for performance:
6
+ * - Persistent key-value store cache for long-term caching
7
+ * - In-memory request cache for short-term deduplication
8
+ *
9
+ * Supports various resolution strategies:
10
+ * - Direct handle resolution via resolveHandle API
11
+ * - Identity resolution via resolveIdentity API (with verification)
12
+ * - Profile-based resolution for handles from DIDs
13
+ *
14
+ * Handles edge cases like deactivated DIDs, invalid handles, and not-found errors
15
+ * with appropriate caching to avoid repeated failed requests.
16
+ *
17
+ * @module services/identity-resolver
18
+ */
19
+
20
+ import * as KeyValueStore from "@effect/platform/KeyValueStore";
21
+ import {
22
+ Cache,
23
+ Clock,
24
+ Config,
25
+ Context,
26
+ Duration,
27
+ Effect,
28
+ Layer,
29
+ Option,
30
+ ParseResult,
31
+ Schema
32
+ } from "effect";
33
+ import { IdentityInfo } from "../domain/bsky.js";
34
+ import { BskyError } from "../domain/errors.js";
35
+ import { Did, Handle } from "../domain/primitives.js";
36
+ import { formatSchemaError, messageFromCause } from "./shared.js";
37
+ import { BskyClient } from "./bsky-client.js";
38
+
39
+ const cachePrefixHandle = "cache/identity/handle/";
40
+ const cachePrefixDid = "cache/identity/did/";
41
+
42
+ const normalizeHandleInput = (value: string) => {
43
+ const trimmed = value.trim();
44
+ const normalized = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
45
+ return normalized.toLowerCase();
46
+ };
47
+
48
+ const isHandleInvalid = (handle: Handle) => handle === "handle.invalid";
49
+
50
+ const toIdentityError = (message: string, operation?: string) => (cause: unknown) =>
51
+ BskyError.make({
52
+ message: messageFromCause(message, cause),
53
+ cause,
54
+ ...(operation ? { operation } : {})
55
+ });
56
+
57
+ const decodeHandle = (value: string) =>
58
+ Schema.decodeUnknown(Handle)(normalizeHandleInput(value)).pipe(
59
+ Effect.mapError((error) =>
60
+ BskyError.make({
61
+ message: `Invalid handle: ${formatSchemaError(error)}`,
62
+ cause: { handle: value },
63
+ operation: "identityDecodeHandle"
64
+ })
65
+ )
66
+ );
67
+
68
+ const decodeDid = (value: string) =>
69
+ Schema.decodeUnknown(Did)(value).pipe(
70
+ Effect.mapError((error) =>
71
+ BskyError.make({
72
+ message: `Invalid DID: ${formatSchemaError(error)}`,
73
+ cause: { did: value },
74
+ operation: "identityDecodeDid"
75
+ })
76
+ )
77
+ );
78
+
79
+ class IdentityCacheEntry extends Schema.Class<IdentityCacheEntry>("IdentityCacheEntry")({
80
+ did: Schema.optional(Did),
81
+ handle: Schema.optional(Handle),
82
+ verified: Schema.Boolean,
83
+ status: Schema.Literal("resolved", "not_found", "deactivated", "invalid"),
84
+ source: Schema.Literal("resolveHandle", "getProfiles", "resolveIdentity"),
85
+ checkedAt: Schema.DateFromString
86
+ }) {}
87
+
88
+ type IdentityStatus = IdentityCacheEntry["status"];
89
+
90
+ type CacheStores = {
91
+ readonly handleStore: KeyValueStore.SchemaStore<IdentityCacheEntry, never>;
92
+ readonly didStore: KeyValueStore.SchemaStore<IdentityCacheEntry, never>;
93
+ };
94
+
95
+ const cacheKey = (value: string) => encodeURIComponent(value);
96
+
97
+ const makeCacheStores = (kv: KeyValueStore.KeyValueStore): CacheStores => {
98
+ const store = kv.forSchema(IdentityCacheEntry);
99
+ return {
100
+ handleStore: KeyValueStore.prefix(store, cachePrefixHandle),
101
+ didStore: KeyValueStore.prefix(store, cachePrefixDid)
102
+ };
103
+ };
104
+
105
+ const entryStatusError = (
106
+ status: IdentityStatus,
107
+ identifier: string,
108
+ kind: "handle" | "did"
109
+ ) => {
110
+ if (status === "not_found") {
111
+ return BskyError.make({
112
+ message:
113
+ kind === "handle"
114
+ ? `Handle not found: ${identifier}`
115
+ : `DID not found: ${identifier}`,
116
+ error: kind === "handle" ? "HandleNotFound" : "DidNotFound",
117
+ operation: kind === "handle" ? "resolveDid" : "resolveHandle"
118
+ });
119
+ }
120
+ if (status === "deactivated") {
121
+ return BskyError.make({
122
+ message: `DID deactivated: ${identifier}`,
123
+ error: "DidDeactivated",
124
+ operation: "resolveHandle"
125
+ });
126
+ }
127
+ return BskyError.make({
128
+ message: `Handle invalid for ${identifier}`,
129
+ error: "HandleInvalid",
130
+ operation: kind === "handle" ? "resolveDid" : "resolveHandle"
131
+ });
132
+ };
133
+
134
+ /**
135
+ * Service for resolving Bluesky identities (handles <-> DIDs).
136
+ *
137
+ * Provides bidirectional resolution between handles and DIDs with comprehensive
138
+ * caching. Lookup methods check cache only, while resolve methods will fetch
139
+ * from the network if not cached.
140
+ */
141
+ export class IdentityResolver extends Context.Tag("@skygent/IdentityResolver")<
142
+ IdentityResolver,
143
+ {
144
+ /**
145
+ * Looks up a DID from cache by handle (cache-only, no network request).
146
+ *
147
+ * @param handle - The handle to look up (e.g., "alice.bsky.social")
148
+ * @returns Effect resolving to Option of DID, or BskyError on cache failure
149
+ */
150
+ readonly lookupDid: (handle: string) => Effect.Effect<Option.Option<Did>, BskyError>;
151
+
152
+ /**
153
+ * Looks up a handle from cache by DID (cache-only, no network request).
154
+ *
155
+ * @param did - The DID to look up (e.g., "did:plc:...")
156
+ * @returns Effect resolving to Option of Handle, or BskyError on cache failure
157
+ */
158
+ readonly lookupHandle: (did: string) => Effect.Effect<Option.Option<Handle>, BskyError>;
159
+
160
+ /**
161
+ * Resolves a handle to a DID, fetching from network if not cached.
162
+ *
163
+ * @param handle - The handle to resolve
164
+ * @returns Effect resolving to DID, or BskyError on resolution failure
165
+ */
166
+ readonly resolveDid: (handle: string) => Effect.Effect<Did, BskyError>;
167
+
168
+ /**
169
+ * Resolves a DID to a handle, fetching from network if not cached.
170
+ *
171
+ * @param did - The DID to resolve
172
+ * @returns Effect resolving to Handle, or BskyError on resolution failure
173
+ */
174
+ readonly resolveHandle: (did: string) => Effect.Effect<Handle, BskyError>;
175
+
176
+ /**
177
+ * Resolves an identity (handle or DID) to full identity info with verification.
178
+ *
179
+ * @param identifier - The handle or DID to resolve
180
+ * @returns Effect resolving to IdentityInfo with did and handle, or BskyError
181
+ */
182
+ readonly resolveIdentity: (
183
+ identifier: string
184
+ ) => Effect.Effect<IdentityInfo, BskyError>;
185
+
186
+ /**
187
+ * Manually caches a profile's identity information.
188
+ *
189
+ * @param input - Object containing did, handle, and optional verified flag and source
190
+ * @returns Effect resolving to void, or BskyError on cache failure
191
+ */
192
+ readonly cacheProfile: (input: {
193
+ readonly did: Did;
194
+ readonly handle: Handle;
195
+ readonly verified?: boolean;
196
+ readonly source?: IdentityCacheEntry["source"];
197
+ }) => Effect.Effect<void, BskyError>;
198
+ }
199
+ >() {
200
+ static readonly layer = Layer.effect(
201
+ IdentityResolver,
202
+ Effect.gen(function* () {
203
+ const bsky = yield* BskyClient;
204
+ const kv = yield* KeyValueStore.KeyValueStore;
205
+ const { handleStore, didStore } = makeCacheStores(kv);
206
+
207
+ const cacheTtl = yield* Config.duration("SKYGENT_IDENTITY_CACHE_TTL").pipe(
208
+ Config.withDefault(Duration.hours(24))
209
+ );
210
+ const failureTtl = yield* Config.duration("SKYGENT_IDENTITY_FAILURE_TTL").pipe(
211
+ Config.withDefault(Duration.minutes(5))
212
+ );
213
+ const strict = yield* Config.boolean("SKYGENT_IDENTITY_STRICT").pipe(
214
+ Config.withDefault(false)
215
+ );
216
+ const requestCapacity = yield* Config.integer(
217
+ "SKYGENT_IDENTITY_REQUEST_CACHE_CAPACITY"
218
+ ).pipe(Config.withDefault(5000));
219
+
220
+ const successTtlMs = Duration.toMillis(cacheTtl);
221
+ const failureTtlMs = Duration.toMillis(failureTtl);
222
+
223
+ const entryTtl = (entry: IdentityCacheEntry) =>
224
+ entry.status === "resolved" ? cacheTtl : failureTtl;
225
+
226
+ const isFresh = (entry: IdentityCacheEntry, now: number) => {
227
+ const ttlMs = Duration.toMillis(entryTtl(entry));
228
+ return ttlMs > 0 && now - entry.checkedAt.getTime() < ttlMs;
229
+ };
230
+
231
+ const shouldPersist = (entry: IdentityCacheEntry) =>
232
+ Duration.toMillis(entryTtl(entry)) > 0;
233
+
234
+ const readEntry = (
235
+ store: KeyValueStore.SchemaStore<IdentityCacheEntry, never>,
236
+ key: string,
237
+ now: number
238
+ ) =>
239
+ store.get(key).pipe(
240
+ Effect.catchAll((error) =>
241
+ ParseResult.isParseError(error)
242
+ ? Effect.succeed(Option.none())
243
+ : Effect.fail(
244
+ toIdentityError("Identity cache read failed", "identityCacheRead")(
245
+ error
246
+ )
247
+ )
248
+ ),
249
+ Effect.map((cached) =>
250
+ Option.filter(cached, (entry) =>
251
+ isFresh(entry, now) && (!strict || entry.verified)
252
+ )
253
+ )
254
+ );
255
+
256
+ const writeEntry = (
257
+ store: KeyValueStore.SchemaStore<IdentityCacheEntry, never>,
258
+ key: string,
259
+ entry: IdentityCacheEntry
260
+ ) =>
261
+ store
262
+ .set(key, entry)
263
+ .pipe(
264
+ Effect.mapError(
265
+ toIdentityError("Identity cache write failed", "identityCacheWrite")
266
+ )
267
+ );
268
+
269
+ const writeResolvedEntry = (entry: IdentityCacheEntry) => {
270
+ if (!shouldPersist(entry)) {
271
+ return Effect.void;
272
+ }
273
+ const effects: Array<Effect.Effect<void, BskyError>> = [];
274
+ if (entry.handle) {
275
+ effects.push(
276
+ writeEntry(handleStore, cacheKey(entry.handle), entry)
277
+ );
278
+ }
279
+ if (entry.did) {
280
+ effects.push(writeEntry(didStore, cacheKey(entry.did), entry));
281
+ }
282
+ if (effects.length === 0) {
283
+ return Effect.void;
284
+ }
285
+ return Effect.all(effects, { discard: true });
286
+ };
287
+
288
+ const cacheProfile = Effect.fn("IdentityResolver.cacheProfile")(
289
+ (input: {
290
+ readonly did: Did;
291
+ readonly handle: Handle;
292
+ readonly verified?: boolean;
293
+ readonly source?: IdentityCacheEntry["source"];
294
+ }) =>
295
+ Effect.gen(function* () {
296
+ const now = yield* Clock.currentTimeMillis;
297
+ const entry = IdentityCacheEntry.make({
298
+ did: input.did,
299
+ handle: input.handle,
300
+ verified: input.verified ?? false,
301
+ status: "resolved",
302
+ source: input.source ?? "getProfiles",
303
+ checkedAt: new Date(now)
304
+ });
305
+ yield* writeResolvedEntry(entry);
306
+ })
307
+ );
308
+
309
+ const writeNegativeEntry = (
310
+ entry: IdentityCacheEntry,
311
+ target: "handle" | "did",
312
+ key: string
313
+ ) => {
314
+ if (!shouldPersist(entry)) {
315
+ return Effect.void;
316
+ }
317
+ const store = target === "handle" ? handleStore : didStore;
318
+ return writeEntry(store, cacheKey(key), entry).pipe(Effect.asVoid);
319
+ };
320
+
321
+ const resolveDidFromCache = (handle: Handle) =>
322
+ Effect.gen(function* () {
323
+ const now = yield* Clock.currentTimeMillis;
324
+ const cached = yield* readEntry(handleStore, cacheKey(handle), now);
325
+ if (Option.isNone(cached)) {
326
+ return Option.none<Did>();
327
+ }
328
+ const entry = cached.value;
329
+ if (entry.status === "resolved") {
330
+ return entry.did ? Option.some(entry.did) : Option.none<Did>();
331
+ }
332
+ const identifier =
333
+ entry.status === "deactivated" && entry.did ? entry.did : handle;
334
+ const kind = entry.status === "deactivated" ? "did" : "handle";
335
+ return yield* entryStatusError(entry.status, identifier, kind);
336
+ });
337
+
338
+ const resolveHandleFromCache = (did: Did) =>
339
+ Effect.gen(function* () {
340
+ const now = yield* Clock.currentTimeMillis;
341
+ const cached = yield* readEntry(didStore, cacheKey(did), now);
342
+ if (Option.isNone(cached)) {
343
+ return Option.none<Handle>();
344
+ }
345
+ const entry = cached.value;
346
+ if (entry.status === "resolved") {
347
+ return entry.handle ? Option.some(entry.handle) : Option.none<Handle>();
348
+ }
349
+ return yield* entryStatusError(entry.status, did, "did");
350
+ });
351
+
352
+ const resolveViaResolveIdentity = (identifier: string) =>
353
+ Effect.gen(function* () {
354
+ const info = yield* bsky.resolveIdentity(identifier);
355
+ const now = yield* Clock.currentTimeMillis;
356
+ const invalid = isHandleInvalid(info.handle);
357
+ const entry = IdentityCacheEntry.make({
358
+ did: info.did,
359
+ ...(invalid ? {} : { handle: info.handle }),
360
+ verified: !invalid,
361
+ status: invalid ? "invalid" : "resolved",
362
+ source: "resolveIdentity",
363
+ checkedAt: new Date(now)
364
+ });
365
+
366
+ if (invalid) {
367
+ const key = identifier.startsWith("did:") ? info.did : identifier;
368
+ const target = identifier.startsWith("did:") ? "did" : "handle";
369
+ yield* writeNegativeEntry(entry, target, key);
370
+ return yield* entryStatusError("invalid", identifier, target);
371
+ }
372
+
373
+ yield* writeResolvedEntry(entry);
374
+ return info;
375
+ }).pipe(
376
+ Effect.catchTag("BskyError", (error) =>
377
+ Effect.gen(function* () {
378
+ if (
379
+ error.error === "HandleNotFound" ||
380
+ error.error === "DidNotFound" ||
381
+ error.error === "DidDeactivated"
382
+ ) {
383
+ const now = yield* Clock.currentTimeMillis;
384
+ const isDid = identifier.startsWith("did:");
385
+ const status =
386
+ error.error === "DidDeactivated" ? "deactivated" : "not_found";
387
+ const entry = IdentityCacheEntry.make({
388
+ ...(isDid ? { did: identifier as Did } : { handle: identifier as Handle }),
389
+ verified: false,
390
+ status,
391
+ source: "resolveIdentity",
392
+ checkedAt: new Date(now)
393
+ });
394
+ yield* writeNegativeEntry(entry, isDid ? "did" : "handle", identifier);
395
+ }
396
+ return yield* error;
397
+ })
398
+ )
399
+ );
400
+
401
+ const resolveDidUncached = (handle: Handle) =>
402
+ Effect.gen(function* () {
403
+ const cached = yield* resolveDidFromCache(handle);
404
+ if (Option.isSome(cached)) {
405
+ return cached.value;
406
+ }
407
+
408
+ if (strict) {
409
+ const info = yield* resolveViaResolveIdentity(handle);
410
+ return info.did;
411
+ }
412
+
413
+ const did = yield* bsky.resolveHandle(handle).pipe(
414
+ Effect.catchTag("BskyError", (error) =>
415
+ Effect.gen(function* () {
416
+ if (error.error === "HandleNotFound") {
417
+ const now = yield* Clock.currentTimeMillis;
418
+ const entry = IdentityCacheEntry.make({
419
+ handle,
420
+ verified: false,
421
+ status: "not_found",
422
+ source: "resolveHandle",
423
+ checkedAt: new Date(now)
424
+ });
425
+ yield* writeNegativeEntry(entry, "handle", handle);
426
+ }
427
+ return yield* error;
428
+ })
429
+ )
430
+ );
431
+
432
+ const now = yield* Clock.currentTimeMillis;
433
+ const entry = IdentityCacheEntry.make({
434
+ did,
435
+ handle,
436
+ verified: false,
437
+ status: "resolved",
438
+ source: "resolveHandle",
439
+ checkedAt: new Date(now)
440
+ });
441
+ yield* writeResolvedEntry(entry);
442
+ return did;
443
+ });
444
+
445
+ const resolveHandleUncached = (did: Did) =>
446
+ Effect.gen(function* () {
447
+ const cached = yield* resolveHandleFromCache(did);
448
+ if (Option.isSome(cached)) {
449
+ return cached.value;
450
+ }
451
+
452
+ if (strict) {
453
+ const info = yield* resolveViaResolveIdentity(did);
454
+ return info.handle;
455
+ }
456
+
457
+ const profiles = yield* bsky.getProfiles([did]);
458
+ const profile = profiles[0];
459
+ if (!profile) {
460
+ const error = BskyError.make({
461
+ message: `Profile not found for DID ${did}`,
462
+ error: "ProfileNotFound",
463
+ operation: "getProfiles"
464
+ });
465
+ const now = yield* Clock.currentTimeMillis;
466
+ const entry = IdentityCacheEntry.make({
467
+ did,
468
+ verified: false,
469
+ status: "not_found",
470
+ source: "getProfiles",
471
+ checkedAt: new Date(now)
472
+ });
473
+ yield* writeNegativeEntry(entry, "did", did);
474
+ return yield* error;
475
+ }
476
+
477
+ const now = yield* Clock.currentTimeMillis;
478
+ const entry = IdentityCacheEntry.make({
479
+ did,
480
+ handle: profile.handle,
481
+ verified: false,
482
+ status: "resolved",
483
+ source: "getProfiles",
484
+ checkedAt: new Date(now)
485
+ });
486
+ yield* writeResolvedEntry(entry);
487
+ return profile.handle;
488
+ });
489
+
490
+ const makeRequestCache = <K, V>(
491
+ lookup: (key: K) => Effect.Effect<V, BskyError>
492
+ ) => {
493
+ if (requestCapacity <= 0 || (successTtlMs <= 0 && failureTtlMs <= 0)) {
494
+ return Effect.succeed(Option.none<Cache.Cache<K, V, BskyError>>());
495
+ }
496
+ return Cache.makeWith({
497
+ capacity: requestCapacity,
498
+ lookup,
499
+ timeToLive: (exit) =>
500
+ exit._tag === "Failure" ? failureTtl : cacheTtl
501
+ }).pipe(Effect.map(Option.some));
502
+ };
503
+
504
+ const resolveDidCache = yield* makeRequestCache(resolveDidUncached);
505
+ const resolveHandleCache = yield* makeRequestCache(resolveHandleUncached);
506
+
507
+ const resolveDid = Effect.fn("IdentityResolver.resolveDid")((handle: string) =>
508
+ Effect.gen(function* () {
509
+ const normalized = yield* decodeHandle(handle);
510
+ const effect = Option.match(resolveDidCache, {
511
+ onNone: () => resolveDidUncached(normalized),
512
+ onSome: (cache) => cache.get(normalized)
513
+ });
514
+ return yield* effect;
515
+ })
516
+ );
517
+
518
+ const lookupDid = Effect.fn("IdentityResolver.lookupDid")((handle: string) =>
519
+ Effect.gen(function* () {
520
+ const normalized = yield* decodeHandle(handle);
521
+ return yield* resolveDidFromCache(normalized);
522
+ })
523
+ );
524
+
525
+ const resolveHandle = Effect.fn("IdentityResolver.resolveHandle")((did: string) =>
526
+ Effect.gen(function* () {
527
+ const normalized = yield* decodeDid(did);
528
+ const effect = Option.match(resolveHandleCache, {
529
+ onNone: () => resolveHandleUncached(normalized),
530
+ onSome: (cache) => cache.get(normalized)
531
+ });
532
+ return yield* effect;
533
+ })
534
+ );
535
+
536
+ const lookupHandle = Effect.fn("IdentityResolver.lookupHandle")((did: string) =>
537
+ Effect.gen(function* () {
538
+ const normalized = yield* decodeDid(did);
539
+ return yield* resolveHandleFromCache(normalized);
540
+ })
541
+ );
542
+
543
+ const resolveIdentity = Effect.fn("IdentityResolver.resolveIdentity")(
544
+ (identifier: string) =>
545
+ Effect.gen(function* () {
546
+ const normalized = identifier.startsWith("did:")
547
+ ? yield* decodeDid(identifier)
548
+ : yield* decodeHandle(identifier);
549
+ return yield* resolveViaResolveIdentity(normalized);
550
+ })
551
+ );
552
+
553
+ return IdentityResolver.of({
554
+ lookupDid,
555
+ lookupHandle,
556
+ resolveDid,
557
+ resolveHandle,
558
+ resolveIdentity,
559
+ cacheProfile
560
+ });
561
+ })
562
+ );
563
+ }