birdclaw 0.8.2 → 0.8.3

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 (89) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +2 -1
  3. package/src/cli/command-context.ts +17 -0
  4. package/src/cli/register-analysis.ts +500 -0
  5. package/src/cli/register-compose.ts +40 -0
  6. package/src/cli/register-graph.ts +132 -0
  7. package/src/cli/register-inbox.ts +41 -0
  8. package/src/cli/register-storage.ts +106 -0
  9. package/src/cli.ts +30 -750
  10. package/src/components/AccountSwitcher.tsx +7 -15
  11. package/src/components/AvatarChip.tsx +1 -1
  12. package/src/components/AvatarPreload.ts +149 -0
  13. package/src/components/MarkdownCitations.tsx +680 -0
  14. package/src/components/MarkdownViewer.tsx +8 -674
  15. package/src/components/ProfileAnalysisClient.ts +191 -0
  16. package/src/components/ProfileAnalysisStream.tsx +16 -185
  17. package/src/components/ProfilePreview.tsx +2 -0
  18. package/src/components/links-controller.ts +162 -0
  19. package/src/components/links-model.ts +198 -0
  20. package/src/components/network-map-controller.ts +84 -0
  21. package/src/components/network-map-model.ts +255 -0
  22. package/src/components/useTimelineRouteData.ts +105 -235
  23. package/src/lib/analysis-runtime.ts +238 -0
  24. package/src/lib/api-client.ts +16 -215
  25. package/src/lib/api-contracts.ts +328 -0
  26. package/src/lib/archive-import-plan.ts +102 -0
  27. package/src/lib/archive-import.ts +170 -239
  28. package/src/lib/authored-live.ts +75 -120
  29. package/src/lib/backup.ts +335 -424
  30. package/src/lib/blocks-write.ts +30 -26
  31. package/src/lib/blocks.ts +18 -20
  32. package/src/lib/database-metrics.ts +88 -0
  33. package/src/lib/database-migrations.ts +34 -0
  34. package/src/lib/database-schema.ts +312 -0
  35. package/src/lib/database-writer.ts +69 -0
  36. package/src/lib/db.ts +84 -330
  37. package/src/lib/dm-read-model.ts +533 -0
  38. package/src/lib/dms-live.ts +34 -97
  39. package/src/lib/follow-graph.ts +17 -27
  40. package/src/lib/import-repository.ts +138 -0
  41. package/src/lib/inbox.ts +2 -1
  42. package/src/lib/live-sync-engine.ts +209 -0
  43. package/src/lib/live-transport-gateway.ts +128 -0
  44. package/src/lib/mention-threads-live.ts +90 -177
  45. package/src/lib/mentions-export.ts +1 -1
  46. package/src/lib/mentions-live.ts +57 -181
  47. package/src/lib/moderation-target.ts +15 -4
  48. package/src/lib/moderation-write.ts +1 -1
  49. package/src/lib/mutes-write.ts +30 -26
  50. package/src/lib/openai-response-runtime.ts +251 -0
  51. package/src/lib/paginated-sync.ts +93 -0
  52. package/src/lib/period-digest.ts +116 -304
  53. package/src/lib/profile-analysis.ts +36 -110
  54. package/src/lib/queries.ts +6 -2381
  55. package/src/lib/query-actions.ts +437 -0
  56. package/src/lib/query-client.tsx +47 -0
  57. package/src/lib/query-read-model-shared.ts +52 -0
  58. package/src/lib/query-read-models.ts +5 -0
  59. package/src/lib/query-resource.ts +41 -0
  60. package/src/lib/query-status.ts +164 -0
  61. package/src/lib/research.ts +1 -1
  62. package/src/lib/runtime-services.ts +20 -0
  63. package/src/lib/search-discussion.ts +75 -279
  64. package/src/lib/server-runtime-services.ts +30 -0
  65. package/src/lib/sqlite.ts +48 -12
  66. package/src/lib/streaming-ingestion.ts +240 -0
  67. package/src/lib/sync-cache.ts +6 -1
  68. package/src/lib/sync-plan.ts +175 -0
  69. package/src/lib/timeline-collections-live.ts +83 -257
  70. package/src/lib/timeline-live.ts +86 -236
  71. package/src/lib/timeline-read-model.ts +1191 -0
  72. package/src/lib/tweet-repository.ts +156 -0
  73. package/src/lib/tweet-search-live.ts +63 -167
  74. package/src/lib/web-sync.ts +67 -50
  75. package/src/lib/whois.ts +2 -1
  76. package/src/routes/__root.tsx +11 -8
  77. package/src/routes/api/action.tsx +1 -1
  78. package/src/routes/api/conversation.tsx +1 -1
  79. package/src/routes/api/query.tsx +32 -26
  80. package/src/routes/api/status.tsx +6 -4
  81. package/src/routes/api/sync.tsx +5 -2
  82. package/src/routes/blocks.tsx +97 -131
  83. package/src/routes/data-sources.tsx +17 -25
  84. package/src/routes/dms.tsx +167 -184
  85. package/src/routes/inbox.tsx +63 -57
  86. package/src/routes/links.tsx +31 -394
  87. package/src/routes/network-map.tsx +41 -344
  88. package/src/routes/rate-limits.tsx +17 -21
  89. package/src/lib/client-cache.ts +0 -109
@@ -1,5 +1,6 @@
1
1
  import { Effect } from "effect";
2
2
  import { runModerationAction } from "./actions-transport";
3
+ import { databaseWriteEffect } from "./database-writer";
3
4
  import { runEffectPromise, tryPromise } from "./effect-runtime";
4
5
  import {
5
6
  deleteModerationRow,
@@ -8,14 +9,6 @@ import {
8
9
  writeModerationRow,
9
10
  } from "./moderation-write";
10
11
 
11
- function trySync<T>(try_: () => T) {
12
- return Effect.try({
13
- try: try_,
14
- catch: (error) =>
15
- error instanceof Error ? error : new Error(String(error)),
16
- });
17
- }
18
-
19
12
  export function addBlockEffect(
20
13
  accountId: string,
21
14
  query: string,
@@ -49,14 +42,16 @@ export function addBlockEffect(
49
42
  }
50
43
 
51
44
  const blockedAt = new Date().toISOString();
52
- yield* trySync(() =>
53
- writeModerationRow(
54
- db,
55
- "blocks",
56
- resolvedAccountId,
57
- resolved.profile.id,
58
- blockedAt,
59
- ),
45
+ yield* databaseWriteEffect(
46
+ (writeDb) =>
47
+ writeModerationRow(
48
+ writeDb,
49
+ "blocks",
50
+ resolvedAccountId,
51
+ resolved.profile.id,
52
+ blockedAt,
53
+ ),
54
+ db,
60
55
  );
61
56
 
62
57
  return {
@@ -88,14 +83,16 @@ export function recordBlockEffect(accountId: string, query: string) {
88
83
  });
89
84
 
90
85
  const blockedAt = new Date().toISOString();
91
- yield* trySync(() =>
92
- writeModerationRow(
93
- db,
94
- "blocks",
95
- resolvedAccountId,
96
- resolved.profile.id,
97
- blockedAt,
98
- ),
86
+ yield* databaseWriteEffect(
87
+ (writeDb) =>
88
+ writeModerationRow(
89
+ writeDb,
90
+ "blocks",
91
+ resolvedAccountId,
92
+ resolved.profile.id,
93
+ blockedAt,
94
+ ),
95
+ db,
99
96
  );
100
97
 
101
98
  return {
@@ -144,8 +141,15 @@ export function removeBlockEffect(
144
141
  };
145
142
  }
146
143
 
147
- yield* trySync(() =>
148
- deleteModerationRow(db, "blocks", resolvedAccountId, resolved.profile.id),
144
+ yield* databaseWriteEffect(
145
+ (writeDb) =>
146
+ deleteModerationRow(
147
+ writeDb,
148
+ "blocks",
149
+ resolvedAccountId,
150
+ resolved.profile.id,
151
+ ),
152
+ db,
149
153
  );
150
154
 
151
155
  return {
package/src/lib/blocks.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Effect } from "effect";
2
2
  import type { Database } from "./sqlite";
3
3
  import { getNativeDb } from "./db";
4
+ import { databaseWriteEffect } from "./database-writer";
4
5
  import { runEffectPromise, tryPromise } from "./effect-runtime";
5
6
  import {
6
7
  getAccountHandle,
@@ -322,33 +323,30 @@ export function syncBlocksEffect(accountId: string) {
322
323
  const page = yield* tryPromise(() =>
323
324
  listBlockedUsers(sourceUserId, nextToken ?? undefined),
324
325
  ).pipe(Effect.mapError(toError));
325
- yield* trySync(() => {
326
- const mergeRemotePage = db.transaction(() => {
327
- for (const user of page.items) {
328
- const resolved = upsertProfileFromXUser(db, user);
329
- remoteProfileIds.push(resolved.profile.id);
330
- upsertRemoteBlock(
331
- db,
332
- resolvedAccountId,
333
- resolved.profile.id,
334
- blockedAt,
335
- );
336
- }
326
+ const pageProfileIds = yield* databaseWriteEffect((writeDb) => {
327
+ return page.items.map((user) => {
328
+ const resolved = upsertProfileFromXUser(writeDb, user);
329
+ upsertRemoteBlock(
330
+ writeDb,
331
+ resolvedAccountId,
332
+ resolved.profile.id,
333
+ blockedAt,
334
+ );
335
+ return resolved.profile.id;
337
336
  });
338
- mergeRemotePage();
339
- });
337
+ }, db);
338
+ remoteProfileIds.push(...pageProfileIds);
340
339
  nextToken = page.nextToken;
341
340
  pageCount += 1;
342
341
  } while (nextToken && pageCount < 20);
343
342
 
344
343
  completed = !nextToken;
345
344
  if (completed) {
346
- yield* trySync(() => {
347
- const pruneMergedBlocks = db.transaction(() => {
348
- pruneRemoteBlocks(db, resolvedAccountId, remoteProfileIds);
349
- });
350
- pruneMergedBlocks();
351
- });
345
+ yield* databaseWriteEffect(
346
+ (writeDb) =>
347
+ pruneRemoteBlocks(writeDb, resolvedAccountId, remoteProfileIds),
348
+ db,
349
+ );
352
350
  }
353
351
 
354
352
  return {
@@ -0,0 +1,88 @@
1
+ export type DatabaseConnectionRole = "reader" | "writer";
2
+
3
+ export interface SlowDatabaseStatement {
4
+ durationMs: number;
5
+ role: DatabaseConnectionRole;
6
+ sql: string;
7
+ }
8
+
9
+ const SLOW_STATEMENT_MS = 50;
10
+ const MAX_SLOW_STATEMENTS = 10;
11
+
12
+ let readStatements = 0;
13
+ let writeStatements = 0;
14
+ let slowStatements: SlowDatabaseStatement[] = [];
15
+ let queuedWrites = 0;
16
+ let activeWrites = 0;
17
+ let completedWrites = 0;
18
+ let failedWrites = 0;
19
+ let maxQueuedWrites = 0;
20
+ let maxWriteWaitMs = 0;
21
+
22
+ function compactSql(sql: string) {
23
+ return sql.replace(/\s+/g, " ").trim().slice(0, 240);
24
+ }
25
+
26
+ export function recordDatabaseStatement(
27
+ role: DatabaseConnectionRole,
28
+ sql: string,
29
+ durationMs: number,
30
+ ) {
31
+ if (role === "reader") {
32
+ readStatements += 1;
33
+ } else {
34
+ writeStatements += 1;
35
+ }
36
+ if (durationMs < SLOW_STATEMENT_MS) return;
37
+ slowStatements = [
38
+ ...slowStatements,
39
+ { durationMs, role, sql: compactSql(sql) },
40
+ ].slice(-MAX_SLOW_STATEMENTS);
41
+ }
42
+
43
+ export function recordDatabaseWriteQueued() {
44
+ queuedWrites += 1;
45
+ maxQueuedWrites = Math.max(maxQueuedWrites, queuedWrites);
46
+ }
47
+
48
+ export function recordDatabaseWriteStarted(waitMs: number) {
49
+ queuedWrites = Math.max(0, queuedWrites - 1);
50
+ activeWrites += 1;
51
+ maxWriteWaitMs = Math.max(maxWriteWaitMs, waitMs);
52
+ }
53
+
54
+ export function recordDatabaseWriteCompleted(failed: boolean) {
55
+ activeWrites = Math.max(0, activeWrites - 1);
56
+ completedWrites += 1;
57
+ if (failed) failedWrites += 1;
58
+ }
59
+
60
+ export function getDatabaseRuntimeMetrics() {
61
+ return {
62
+ connections: {
63
+ readStatements,
64
+ writeStatements,
65
+ slowStatements: [...slowStatements],
66
+ },
67
+ writer: {
68
+ active: activeWrites,
69
+ completed: completedWrites,
70
+ failed: failedWrites,
71
+ maxQueued: maxQueuedWrites,
72
+ maxWaitMs: maxWriteWaitMs,
73
+ queued: queuedWrites,
74
+ },
75
+ };
76
+ }
77
+
78
+ export function resetDatabaseRuntimeMetricsForTests() {
79
+ readStatements = 0;
80
+ writeStatements = 0;
81
+ slowStatements = [];
82
+ queuedWrites = 0;
83
+ activeWrites = 0;
84
+ completedWrites = 0;
85
+ failedWrites = 0;
86
+ maxQueuedWrites = 0;
87
+ maxWriteWaitMs = 0;
88
+ }
@@ -0,0 +1,34 @@
1
+ import type { Database } from "./sqlite";
2
+
3
+ export interface DatabaseMigration {
4
+ version: number;
5
+ name: string;
6
+ up: (db: Database) => void;
7
+ }
8
+
9
+ export function getDatabaseSchemaVersion(db: Database) {
10
+ return Number(db.pragma("user_version", { simple: true }) ?? 0);
11
+ }
12
+
13
+ export function runDatabaseMigrations(
14
+ db: Database,
15
+ migrations: readonly DatabaseMigration[],
16
+ ) {
17
+ let currentVersion = getDatabaseSchemaVersion(db);
18
+ const pending = [...migrations]
19
+ .filter((migration) => migration.version > currentVersion)
20
+ .sort((left, right) => left.version - right.version);
21
+
22
+ for (const migration of pending) {
23
+ if (migration.version !== currentVersion + 1) {
24
+ throw new Error(
25
+ `Missing database migration between versions ${String(currentVersion)} and ${String(migration.version)}`,
26
+ );
27
+ }
28
+ migration.up(db);
29
+ db.pragma(`user_version = ${String(migration.version)}`);
30
+ currentVersion = migration.version;
31
+ }
32
+
33
+ return currentVersion;
34
+ }
@@ -0,0 +1,312 @@
1
+ export interface AccountsTable {
2
+ id: string;
3
+ name: string;
4
+ handle: string;
5
+ external_user_id: string | null;
6
+ transport: string;
7
+ is_default: number;
8
+ created_at: string;
9
+ }
10
+
11
+ export interface ProfilesTable {
12
+ id: string;
13
+ handle: string;
14
+ display_name: string;
15
+ bio: string;
16
+ followers_count: number;
17
+ following_count: number;
18
+ public_metrics_json: string;
19
+ avatar_hue: number;
20
+ avatar_url: string | null;
21
+ location: string | null;
22
+ url: string | null;
23
+ verified_type: string | null;
24
+ entities_json: string;
25
+ raw_json: string;
26
+ created_at: string;
27
+ }
28
+
29
+ export interface ProfileAffiliationsTable {
30
+ subject_profile_id: string;
31
+ organization_profile_id: string;
32
+ organization_name: string | null;
33
+ organization_handle: string | null;
34
+ badge_url: string | null;
35
+ url: string | null;
36
+ label: string | null;
37
+ source: string;
38
+ is_active: number;
39
+ first_seen_at: string;
40
+ last_seen_at: string;
41
+ raw_json: string;
42
+ updated_at: string;
43
+ }
44
+
45
+ export interface ProfileSnapshotsTable {
46
+ profile_id: string;
47
+ snapshot_hash: string;
48
+ observed_at: string;
49
+ last_seen_at: string;
50
+ source: string;
51
+ handle: string;
52
+ display_name: string;
53
+ bio: string;
54
+ location: string | null;
55
+ url: string | null;
56
+ verified_type: string | null;
57
+ followers_count: number;
58
+ following_count: number;
59
+ affiliations_json: string;
60
+ raw_json: string;
61
+ }
62
+
63
+ export interface ProfileBioEntitiesTable {
64
+ profile_id: string;
65
+ kind: string;
66
+ value: string;
67
+ source: string;
68
+ is_active: number;
69
+ first_seen_at: string;
70
+ last_seen_at: string;
71
+ raw_json: string;
72
+ }
73
+
74
+ export interface IdentitySearchIndexTable {
75
+ profile_id: string;
76
+ kind: string;
77
+ value: string;
78
+ normalized_value: string;
79
+ source: string;
80
+ weight: number;
81
+ updated_at: string;
82
+ }
83
+
84
+ export interface TweetsTable {
85
+ id: string;
86
+ account_id: string;
87
+ author_profile_id: string;
88
+ kind: string;
89
+ text: string;
90
+ created_at: string;
91
+ is_replied: number;
92
+ reply_to_id: string | null;
93
+ like_count: number;
94
+ media_count: number;
95
+ bookmarked: number;
96
+ liked: number;
97
+ entities_json: string;
98
+ media_json: string;
99
+ quoted_tweet_id: string | null;
100
+ }
101
+
102
+ export interface TweetCollectionsTable {
103
+ account_id: string;
104
+ tweet_id: string;
105
+ kind: "likes" | "bookmarks";
106
+ collected_at: string | null;
107
+ source: string;
108
+ raw_json: string;
109
+ updated_at: string;
110
+ }
111
+
112
+ export interface TweetAccountEdgesTable {
113
+ account_id: string;
114
+ tweet_id: string;
115
+ kind:
116
+ | "home"
117
+ | "mention"
118
+ | "authored"
119
+ | "search"
120
+ | "profile"
121
+ | "thread_context";
122
+ first_seen_at: string;
123
+ last_seen_at: string;
124
+ seen_count: number;
125
+ source: string;
126
+ raw_json: string;
127
+ updated_at: string;
128
+ }
129
+
130
+ export interface DmConversationsTable {
131
+ id: string;
132
+ account_id: string;
133
+ participant_profile_id: string;
134
+ title: string;
135
+ inbox_kind: "accepted" | "request";
136
+ last_message_at: string;
137
+ unread_count: number;
138
+ needs_reply: number;
139
+ }
140
+
141
+ export interface DmMessagesTable {
142
+ id: string;
143
+ conversation_id: string;
144
+ sender_profile_id: string;
145
+ text: string;
146
+ created_at: string;
147
+ direction: "inbound" | "outbound";
148
+ is_replied: number;
149
+ media_count: number;
150
+ }
151
+
152
+ export interface TweetActionsTable {
153
+ id: string;
154
+ account_id: string;
155
+ tweet_id: string | null;
156
+ kind: string;
157
+ body: string;
158
+ created_at: string;
159
+ }
160
+
161
+ export interface BlocksTable {
162
+ account_id: string;
163
+ profile_id: string;
164
+ source: string;
165
+ created_at: string;
166
+ }
167
+
168
+ export interface MutesTable {
169
+ account_id: string;
170
+ profile_id: string;
171
+ source: string;
172
+ created_at: string;
173
+ }
174
+
175
+ export interface AiScoresTable {
176
+ entity_kind: string;
177
+ entity_id: string;
178
+ model: string;
179
+ score: number;
180
+ summary: string;
181
+ reasoning: string;
182
+ updated_at: string;
183
+ }
184
+
185
+ export interface SyncCacheTable {
186
+ cache_key: string;
187
+ value_json: string;
188
+ updated_at: string;
189
+ }
190
+
191
+ export interface UrlExpansionsTable {
192
+ short_url: string;
193
+ expanded_url: string;
194
+ final_url: string;
195
+ status: string;
196
+ expanded_tweet_id: string | null;
197
+ expanded_handle: string | null;
198
+ title: string | null;
199
+ description: string | null;
200
+ image_url: string | null;
201
+ site_name: string | null;
202
+ error: string | null;
203
+ source: string;
204
+ updated_at: string;
205
+ }
206
+
207
+ export interface LinkOccurrencesTable {
208
+ source_kind: "dm" | "tweet";
209
+ source_id: string;
210
+ source_position: number;
211
+ short_url: string;
212
+ account_id: string | null;
213
+ conversation_id: string | null;
214
+ direction: string | null;
215
+ created_at: string;
216
+ }
217
+
218
+ export interface FollowEdgesTable {
219
+ account_id: string;
220
+ direction: "followers" | "following";
221
+ profile_id: string;
222
+ external_user_id: string;
223
+ source: string;
224
+ current: number;
225
+ first_seen_at: string;
226
+ last_seen_at: string;
227
+ ended_at: string | null;
228
+ updated_at: string;
229
+ }
230
+
231
+ export interface FollowSnapshotsTable {
232
+ id: string;
233
+ account_id: string;
234
+ direction: "followers" | "following";
235
+ source: string;
236
+ status: "complete" | "incomplete";
237
+ page_count: number;
238
+ result_count: number;
239
+ started_at: string;
240
+ completed_at: string;
241
+ raw_meta_json: string;
242
+ }
243
+
244
+ export interface FollowSnapshotMembersTable {
245
+ snapshot_id: string;
246
+ profile_id: string;
247
+ external_user_id: string;
248
+ position: number;
249
+ }
250
+
251
+ export interface FollowEventsTable {
252
+ id: string;
253
+ account_id: string;
254
+ direction: "followers" | "following";
255
+ profile_id: string;
256
+ external_user_id: string;
257
+ kind: "started" | "ended";
258
+ event_at: string;
259
+ snapshot_id: string;
260
+ }
261
+
262
+ export interface GeocodedLocationsTable {
263
+ normalized_key: string;
264
+ original: string;
265
+ lat: number;
266
+ lng: number;
267
+ formatted: string | null;
268
+ country_code: string | null;
269
+ confidence: number | null;
270
+ provider: string;
271
+ approx_radius_m: number | null;
272
+ bounds_json: string;
273
+ components_json: string;
274
+ hits: number;
275
+ created_at: string;
276
+ last_used_at: string;
277
+ }
278
+
279
+ export interface GeocodedLocationsUnresolvedTable {
280
+ normalized_key: string;
281
+ original: string;
282
+ reason: string;
283
+ last_attempted_at: string;
284
+ ttl_until: string | null;
285
+ }
286
+
287
+ export interface BirdclawDatabase {
288
+ accounts: AccountsTable;
289
+ profiles: ProfilesTable;
290
+ profile_affiliations: ProfileAffiliationsTable;
291
+ profile_snapshots: ProfileSnapshotsTable;
292
+ profile_bio_entities: ProfileBioEntitiesTable;
293
+ identity_search_index: IdentitySearchIndexTable;
294
+ tweets: TweetsTable;
295
+ tweet_collections: TweetCollectionsTable;
296
+ tweet_account_edges: TweetAccountEdgesTable;
297
+ dm_conversations: DmConversationsTable;
298
+ dm_messages: DmMessagesTable;
299
+ tweet_actions: TweetActionsTable;
300
+ blocks: BlocksTable;
301
+ mutes: MutesTable;
302
+ ai_scores: AiScoresTable;
303
+ sync_cache: SyncCacheTable;
304
+ url_expansions: UrlExpansionsTable;
305
+ link_occurrences: LinkOccurrencesTable;
306
+ follow_edges: FollowEdgesTable;
307
+ follow_snapshots: FollowSnapshotsTable;
308
+ follow_snapshot_members: FollowSnapshotMembersTable;
309
+ follow_events: FollowEventsTable;
310
+ geocoded_locations: GeocodedLocationsTable;
311
+ geocoded_locations_unresolved: GeocodedLocationsUnresolvedTable;
312
+ }
@@ -0,0 +1,69 @@
1
+ import { Effect } from "effect";
2
+ import type { Database } from "./sqlite";
3
+ import {
4
+ defaultServerRuntimeServices,
5
+ type ServerRuntimeServices,
6
+ } from "./server-runtime-services";
7
+ import {
8
+ recordDatabaseWriteCompleted,
9
+ recordDatabaseWriteQueued,
10
+ recordDatabaseWriteStarted,
11
+ } from "./database-metrics";
12
+
13
+ let writeTails = new Map<string | object, Promise<void>>();
14
+
15
+ export function enqueueDatabaseWrite<T>(
16
+ write: (db: Database) => T,
17
+ providedDb?: Database,
18
+ runtime: ServerRuntimeServices = defaultServerRuntimeServices,
19
+ ): Promise<T> {
20
+ const db = providedDb ?? runtime.getDatabase({ seedDemoData: false });
21
+ const writeIdentity = db.writeIdentity;
22
+ const queuedAt = performance.now();
23
+ recordDatabaseWriteQueued();
24
+ const writeTail = writeTails.get(writeIdentity) ?? Promise.resolve();
25
+ const pending = writeTail.then(() => {
26
+ recordDatabaseWriteStarted(performance.now() - queuedAt);
27
+ try {
28
+ const result = db.transaction(() => write(db))();
29
+ recordDatabaseWriteCompleted(false);
30
+ return result;
31
+ } catch (error) {
32
+ recordDatabaseWriteCompleted(true);
33
+ throw error;
34
+ }
35
+ });
36
+ const settled = pending.then(
37
+ () => undefined,
38
+ () => undefined,
39
+ );
40
+ writeTails.set(writeIdentity, settled);
41
+ void settled.then(() => {
42
+ if (writeTails.get(writeIdentity) === settled) {
43
+ writeTails.delete(writeIdentity);
44
+ }
45
+ });
46
+ return pending;
47
+ }
48
+
49
+ export function databaseWriteEffect<T>(
50
+ write: (db: Database) => T,
51
+ providedDb?: Database,
52
+ runtime: ServerRuntimeServices = defaultServerRuntimeServices,
53
+ ) {
54
+ return Effect.tryPromise({
55
+ try: () => enqueueDatabaseWrite(write, providedDb, runtime),
56
+ catch: (error) =>
57
+ error instanceof Error ? error : new Error(String(error)),
58
+ });
59
+ }
60
+
61
+ export async function drainDatabaseWrites() {
62
+ while (writeTails.size > 0) {
63
+ await Promise.all(writeTails.values());
64
+ }
65
+ }
66
+
67
+ export function resetDatabaseWriterForTests() {
68
+ writeTails = new Map();
69
+ }