@akira-tl/forgerelay 0.3.5 → 0.3.7

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.
@@ -1,13 +1,32 @@
1
1
  import { and, desc, eq } from "drizzle-orm";
2
2
  import { openDatabase } from "./db/client.js";
3
- import { workspaceConversationBindings, workspaceSessions, } from "./db/schema.js";
3
+ import { workspaceContextDeliveries, workspaceConversationBindings, workspaceSessions, } from "./db/schema.js";
4
+ const DEFAULT_TOUCH_FLUSH_INTERVAL_MS = 5 * 60 * 1_000;
4
5
  export class SqliteWorkspaceStore {
5
6
  database;
6
- constructor(stateDir) {
7
+ now;
8
+ touchFlushTimer;
9
+ pendingSessionTouches = new Map();
10
+ pendingConversationTouches = new Map();
11
+ constructor(stateDir, options = {}) {
7
12
  this.database = openDatabase(stateDir);
13
+ this.now = options.now ?? (() => new Date());
14
+ const touchFlushIntervalMs = options.touchFlushIntervalMs ?? DEFAULT_TOUCH_FLUSH_INTERVAL_MS;
15
+ if (!Number.isInteger(touchFlushIntervalMs) || touchFlushIntervalMs < 1) {
16
+ throw new Error("Workspace touch flush interval must be a positive integer.");
17
+ }
18
+ this.touchFlushTimer = setInterval(() => {
19
+ try {
20
+ this.flushTouches();
21
+ }
22
+ catch (error) {
23
+ console.warn(`ForgeRelay workspace touch flush failed: ${errorMessage(error)}`);
24
+ }
25
+ }, touchFlushIntervalMs);
26
+ this.touchFlushTimer.unref();
8
27
  }
9
28
  createSession(input) {
10
- const now = new Date().toISOString();
29
+ const now = this.now().toISOString();
11
30
  const session = {
12
31
  id: input.id,
13
32
  root: input.root,
@@ -47,19 +66,18 @@ export class SqliteWorkspaceStore {
47
66
  .from(workspaceSessions)
48
67
  .where(eq(workspaceSessions.id, id))
49
68
  .get();
50
- return row ? rowToWorkspaceSession(row) : undefined;
69
+ if (!row)
70
+ return undefined;
71
+ return applySessionTouch(rowToWorkspaceSession(row), this.pendingSessionTouches.get(id));
51
72
  }
52
73
  touchSession(id) {
53
- this.database.db
54
- .update(workspaceSessions)
55
- .set({ lastUsedAt: new Date().toISOString() })
56
- .where(eq(workspaceSessions.id, id))
57
- .run();
74
+ this.pendingSessionTouches.set(id, this.now().toISOString());
58
75
  }
59
76
  setSessionStatus(id, status) {
77
+ this.pendingSessionTouches.delete(id);
60
78
  this.database.db
61
79
  .update(workspaceSessions)
62
- .set({ status, lastUsedAt: new Date().toISOString() })
80
+ .set({ status, lastUsedAt: this.now().toISOString() })
63
81
  .where(eq(workspaceSessions.id, id))
64
82
  .run();
65
83
  }
@@ -77,9 +95,13 @@ export class SqliteWorkspaceStore {
77
95
  : conditions.length === 1
78
96
  ? query.where(conditions[0]).all()
79
97
  : query.where(and(...conditions)).all();
80
- return rows.map(rowToWorkspaceSession);
98
+ return rows
99
+ .map(rowToWorkspaceSession)
100
+ .map((session) => applySessionTouch(session, this.pendingSessionTouches.get(session.id)))
101
+ .sort((left, right) => right.lastUsedAt.localeCompare(left.lastUsedAt));
81
102
  }
82
103
  deleteSession(id) {
104
+ this.pendingSessionTouches.delete(id);
83
105
  this.database.db
84
106
  .delete(workspaceSessions)
85
107
  .where(eq(workspaceSessions.id, id))
@@ -90,7 +112,8 @@ export class SqliteWorkspaceStore {
90
112
  .select()
91
113
  .from(workspaceConversationBindings)
92
114
  .all()
93
- .map(rowToWorkspaceConversationBinding);
115
+ .map(rowToWorkspaceConversationBinding)
116
+ .map((binding) => applyConversationTouch(binding, this.pendingConversationTouches.get(conversationTouchKey(binding.conversationScopeId, binding.targetKey))?.lastUsedAt));
94
117
  }
95
118
  getConversationBinding(conversationScopeId, targetKey) {
96
119
  const row = this.database.db
@@ -98,10 +121,13 @@ export class SqliteWorkspaceStore {
98
121
  .from(workspaceConversationBindings)
99
122
  .where(and(eq(workspaceConversationBindings.conversationScopeId, conversationScopeId), eq(workspaceConversationBindings.targetKey, targetKey)))
100
123
  .get();
101
- return row ? rowToWorkspaceConversationBinding(row) : undefined;
124
+ if (!row)
125
+ return undefined;
126
+ const binding = rowToWorkspaceConversationBinding(row);
127
+ return applyConversationTouch(binding, this.pendingConversationTouches.get(conversationTouchKey(conversationScopeId, targetKey))?.lastUsedAt);
102
128
  }
103
129
  setConversationBinding(input) {
104
- const now = new Date().toISOString();
130
+ const now = this.now().toISOString();
105
131
  const row = this.database.db
106
132
  .insert(workspaceConversationBindings)
107
133
  .values({
@@ -126,28 +152,122 @@ export class SqliteWorkspaceStore {
126
152
  if (!row) {
127
153
  throw new Error("Conversation workspace binding upsert returned no row.");
128
154
  }
155
+ this.pendingConversationTouches.delete(conversationTouchKey(input.conversationScopeId, input.targetKey));
129
156
  return rowToWorkspaceConversationBinding(row);
130
157
  }
131
158
  touchConversationBinding(conversationScopeId, targetKey) {
132
- this.database.db
133
- .update(workspaceConversationBindings)
134
- .set({ lastUsedAt: new Date().toISOString() })
135
- .where(and(eq(workspaceConversationBindings.conversationScopeId, conversationScopeId), eq(workspaceConversationBindings.targetKey, targetKey)))
136
- .run();
159
+ this.pendingConversationTouches.set(conversationTouchKey(conversationScopeId, targetKey), {
160
+ conversationScopeId,
161
+ targetKey,
162
+ lastUsedAt: this.now().toISOString(),
163
+ });
137
164
  }
138
165
  deleteConversationBinding(conversationScopeId, targetKey) {
166
+ this.pendingConversationTouches.delete(conversationTouchKey(conversationScopeId, targetKey));
139
167
  this.database.db
140
168
  .delete(workspaceConversationBindings)
141
169
  .where(and(eq(workspaceConversationBindings.conversationScopeId, conversationScopeId), eq(workspaceConversationBindings.targetKey, targetKey)))
142
170
  .run();
143
171
  }
172
+ listContextDeliveries() {
173
+ return this.database.db
174
+ .select()
175
+ .from(workspaceContextDeliveries)
176
+ .orderBy(desc(workspaceContextDeliveries.deliveredAt))
177
+ .all()
178
+ .map(rowToWorkspaceContextDelivery);
179
+ }
180
+ getContextDelivery(conversationScopeId, targetKey) {
181
+ const row = this.database.db
182
+ .select()
183
+ .from(workspaceContextDeliveries)
184
+ .where(and(eq(workspaceContextDeliveries.conversationScopeId, conversationScopeId), eq(workspaceContextDeliveries.targetKey, targetKey)))
185
+ .get();
186
+ return row ? rowToWorkspaceContextDelivery(row) : undefined;
187
+ }
188
+ setContextDelivery(input) {
189
+ const deliveredAt = new Date().toISOString();
190
+ const row = this.database.db
191
+ .insert(workspaceContextDeliveries)
192
+ .values({ ...input, deliveredAt })
193
+ .onConflictDoUpdate({
194
+ target: [
195
+ workspaceContextDeliveries.conversationScopeId,
196
+ workspaceContextDeliveries.targetKey,
197
+ ],
198
+ set: {
199
+ contextFingerprint: input.contextFingerprint,
200
+ deliveredAt,
201
+ },
202
+ })
203
+ .returning()
204
+ .get();
205
+ if (!row)
206
+ throw new Error("Workspace context delivery upsert returned no row.");
207
+ return rowToWorkspaceContextDelivery(row);
208
+ }
209
+ deleteContextDelivery(conversationScopeId, targetKey) {
210
+ this.database.db
211
+ .delete(workspaceContextDeliveries)
212
+ .where(and(eq(workspaceContextDeliveries.conversationScopeId, conversationScopeId), eq(workspaceContextDeliveries.targetKey, targetKey)))
213
+ .run();
214
+ }
215
+ get pendingTouchCount() {
216
+ return this.pendingSessionTouches.size + this.pendingConversationTouches.size;
217
+ }
218
+ flushTouches() {
219
+ if (this.pendingTouchCount === 0)
220
+ return;
221
+ const sessionTouches = [...this.pendingSessionTouches.entries()];
222
+ const conversationTouches = [...this.pendingConversationTouches.values()];
223
+ const updateSession = this.database.sqlite.prepare("UPDATE workspace_sessions SET last_used_at = ? WHERE id = ?");
224
+ const updateConversation = this.database.sqlite.prepare("UPDATE workspace_conversation_bindings SET last_used_at = ? WHERE conversation_scope_id = ? AND target_key = ?");
225
+ const flush = this.database.sqlite.transaction(() => {
226
+ for (const [workspaceId, lastUsedAt] of sessionTouches) {
227
+ updateSession.run(lastUsedAt, workspaceId);
228
+ }
229
+ for (const touch of conversationTouches) {
230
+ updateConversation.run(touch.lastUsedAt, touch.conversationScopeId, touch.targetKey);
231
+ }
232
+ });
233
+ flush();
234
+ for (const [workspaceId, lastUsedAt] of sessionTouches) {
235
+ if (this.pendingSessionTouches.get(workspaceId) === lastUsedAt) {
236
+ this.pendingSessionTouches.delete(workspaceId);
237
+ }
238
+ }
239
+ for (const touch of conversationTouches) {
240
+ const key = conversationTouchKey(touch.conversationScopeId, touch.targetKey);
241
+ if (this.pendingConversationTouches.get(key)?.lastUsedAt === touch.lastUsedAt) {
242
+ this.pendingConversationTouches.delete(key);
243
+ }
244
+ }
245
+ }
144
246
  close() {
145
- this.database.close();
247
+ clearInterval(this.touchFlushTimer);
248
+ try {
249
+ this.flushTouches();
250
+ }
251
+ finally {
252
+ this.database.close();
253
+ }
146
254
  }
147
255
  }
148
256
  export function createWorkspaceStore(stateDir) {
149
257
  return new SqliteWorkspaceStore(stateDir);
150
258
  }
259
+ function applySessionTouch(session, lastUsedAt) {
260
+ return lastUsedAt ? { ...session, lastUsedAt } : session;
261
+ }
262
+ function applyConversationTouch(binding, lastUsedAt) {
263
+ return lastUsedAt ? { ...binding, lastUsedAt } : binding;
264
+ }
265
+ function conversationTouchKey(conversationScopeId, targetKey) {
266
+ return JSON.stringify([conversationScopeId, targetKey]);
267
+ }
268
+ function errorMessage(error) {
269
+ return error instanceof Error ? error.message : String(error);
270
+ }
151
271
  function rowToWorkspaceSession(row) {
152
272
  return {
153
273
  id: row.id,
@@ -173,3 +293,11 @@ function rowToWorkspaceConversationBinding(row) {
173
293
  lastUsedAt: row.lastUsedAt,
174
294
  };
175
295
  }
296
+ function rowToWorkspaceContextDelivery(row) {
297
+ return {
298
+ conversationScopeId: row.conversationScopeId,
299
+ targetKey: row.targetKey,
300
+ contextFingerprint: row.contextFingerprint,
301
+ deliveredAt: row.deliveredAt,
302
+ };
303
+ }