@danypops/tickets 0.12.0 → 0.14.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.
package/src/rpc/server.ts CHANGED
@@ -11,11 +11,19 @@ import type { VehicleRegistry } from "@danypops/vehicle-server";
11
11
  import { createVehicleHttpApp } from "@danypops/vehicle-server/http";
12
12
  import type { Logger } from "@danypops/vehicle-server/logging";
13
13
  import { errorResponse, healthResponse, jsonResponse, readyResponse, requireBearerToken } from "@danypops/vehicle-server/rpc-http";
14
+ import {
15
+ isSessionRegistered,
16
+ registerSessionIdentity,
17
+ releaseSessionIdentity,
18
+ verifySessionSecret,
19
+ } from "@danypops/vehicle-server/session-identity";
14
20
  import { parseRef } from "../issue/issue.js";
15
21
  import type { TicketService } from "../issue/service.js";
16
22
  import { FocusError, type FocusStore } from "../sqlite/focus.js";
17
23
  import type { Ledger } from "../sqlite/ledger.js";
18
24
  import { SavedQueryNotFoundError, type SavedQueryStore } from "../sqlite/saved-queries.js";
25
+ import { SessionAuthError, type SqliteSessionIdentityStore } from "../sqlite/session-identity.js";
26
+ import type { WatchStore } from "../sqlite/watches.js";
19
27
  import type { StagePayload, StageStore } from "../stage/store.js";
20
28
  import { statusForKnownTicketError } from "./error-status.js";
21
29
  import { type StagePushResult, TICKET_OPERATIONS, type TicketOperation, type TicketOpInputs, type TicketOpOutputs } from "./ops.js";
@@ -25,6 +33,7 @@ export interface TicketsAppDeps {
25
33
  ledger: Ledger;
26
34
  focusStore: FocusStore;
27
35
  queries: SavedQueryStore;
36
+ sessionIdentity: SqliteSessionIdentityStore;
28
37
  token: string;
29
38
  version: string;
30
39
  logger?: Logger;
@@ -45,6 +54,20 @@ export interface TicketsAppDeps {
45
54
  */
46
55
  vehicleRegistry: VehicleRegistry;
47
56
  stageStore: StageStore;
57
+ watches: WatchStore;
58
+ }
59
+
60
+ /**
61
+ * The subset of a Vehicle call's own identity a handful of handlers read for a sensible default --
62
+ * see issue.subscribe/query.subscribe below, which default subscriberId/projectRoot from these
63
+ * when the caller doesn't pass them explicitly, mirroring @danypops/pipes' own ci.subscribe.
64
+ * Always undefined-safe: absent entirely for the raw HTTP /api/v1/ops dispatch (which has no Pi
65
+ * session behind it), present when the call came through agent-tools/tickets-vehicle.ts's
66
+ * Vehicle registration (see its own VehicleOperationContext.callerSessionId/callerProjectRoot).
67
+ */
68
+ export interface HandlerCallContext {
69
+ callerSessionId?: string;
70
+ callerProjectRoot?: string;
48
71
  }
49
72
 
50
73
  // Narrower than TicketsAppDeps on purpose: no real handler reads
@@ -53,6 +76,7 @@ export interface TicketsAppDeps {
53
76
  export type Handler<Op extends TicketOperation> = (
54
77
  deps: Omit<TicketsAppDeps, "vehicleRegistry">,
55
78
  input: TicketOpInputs[Op],
79
+ callContext?: HandlerCallContext,
56
80
  ) => Promise<TicketOpOutputs[Op]>;
57
81
 
58
82
  /**
@@ -61,6 +85,28 @@ export type Handler<Op extends TicketOperation> = (
61
85
  * VehicleRegistry projection -- never reimplemented a second time for the
62
86
  * newer surface.
63
87
  */
88
+ /**
89
+ * Resolves which Focus scope a focus.* call actually targets, and enforces the one place a
90
+ * caller-supplied session id is behavior-affecting in this daemon (see sqlite/session-identity.ts's
91
+ * own doc comment): an EXPLICIT input.sessionId must present the matching sessionSecret if that
92
+ * session id is registered; an unregistered one (or the implicit callContext.callerSessionId
93
+ * default -- never model-settable, since it's not part of any operation's declared input schema)
94
+ * passes through unarmored. input.sessionId always wins over callContext.callerSessionId, the
95
+ * same precedence issue.subscribe/query.subscribe already established for subscriberId.
96
+ */
97
+ function resolveFocusScope(
98
+ deps: Pick<TicketsAppDeps, "sessionIdentity">,
99
+ input: { sessionId?: string; sessionSecret?: string },
100
+ callContext: HandlerCallContext | undefined,
101
+ ): string | undefined {
102
+ const explicit = input.sessionId;
103
+ if (explicit === undefined) return callContext?.callerSessionId;
104
+ if (isSessionRegistered(deps.sessionIdentity, explicit) && !verifySessionSecret(deps.sessionIdentity, explicit, input.sessionSecret)) {
105
+ throw new SessionAuthError(`session "${explicit}" is registered but the given secret does not match`);
106
+ }
107
+ return explicit;
108
+ }
109
+
64
110
  export const TICKET_OP_HANDLERS: { [Op in TicketOperation]: Handler<Op> } = {
65
111
  "backends.list": async (deps) => ({ backends: deps.service.backendCapabilities() }),
66
112
  "issue.list": async (deps, input) => ({ issues: await deps.service.list(input.backend, input.filter) }),
@@ -76,7 +122,8 @@ export const TICKET_OP_HANDLERS: { [Op in TicketOperation]: Handler<Op> } = {
76
122
  "issue.merge": async (deps, input) => ({ issue: await deps.service.merge(input.ref, input.method) }),
77
123
  "ledger.search": async (deps, input) => ({ issues: deps.ledger.search(input.query, input.limit, input.backend) }),
78
124
  "ledger.stats": async (deps) => ({ backends: deps.ledger.stats() }),
79
- "focus.set": async (deps, input) => {
125
+ "focus.set": async (deps, input, callContext) => {
126
+ const scope = resolveFocusScope(deps, input, callContext);
80
127
  // Ledger-first: focusing a ticket already pooled locally needs no live
81
128
  // backend call. Otherwise fall back to a live get (also validates the
82
129
  // ref actually exists) and opportunistically warm the ledger with it,
@@ -86,12 +133,25 @@ export const TICKET_OP_HANDLERS: { [Op in TicketOperation]: Handler<Op> } = {
86
133
  const issue = cached ?? (await deps.service.get(input.ref));
87
134
  if (!cached) deps.ledger.upsert(parseRef(input.ref).backend, issue);
88
135
  if (!issue.url) throw new FocusError(`issue "${input.ref}" has no URL from its backend; cannot focus without a full link`);
89
- return { focus: deps.focusStore.set(input.ref, issue.title, issue.url) };
136
+ return { focus: deps.focusStore.set(input.ref, issue.title, issue.url, scope) };
137
+ },
138
+ "focus.get": async (deps, input, callContext) => ({
139
+ focus: deps.focusStore.get(resolveFocusScope(deps, input, callContext)) ?? null,
140
+ }),
141
+ "focus.pause": async (deps, input, callContext) => ({
142
+ focus: deps.focusStore.pause(input.reason, resolveFocusScope(deps, input, callContext)),
143
+ }),
144
+ "focus.unpause": async (deps, input, callContext) => ({
145
+ focus: deps.focusStore.unpause(resolveFocusScope(deps, input, callContext)),
146
+ }),
147
+ "focus.clear": async (deps, input, callContext) => ({
148
+ cleared: deps.focusStore.clear(resolveFocusScope(deps, input, callContext)),
149
+ }),
150
+ "session.register": async (deps, input) => registerSessionIdentity(deps.sessionIdentity, input.sessionId),
151
+ "session.release": async (deps, input) => {
152
+ releaseSessionIdentity(deps.sessionIdentity, input.sessionId, input.sessionSecret);
153
+ return { released: true };
90
154
  },
91
- "focus.get": async (deps) => ({ focus: deps.focusStore.get() ?? null }),
92
- "focus.pause": async (deps, input) => ({ focus: deps.focusStore.pause(input.reason) }),
93
- "focus.unpause": async (deps) => ({ focus: deps.focusStore.unpause() }),
94
- "focus.clear": async (deps) => ({ cleared: deps.focusStore.clear() }),
95
155
  "discover.fields": async (deps, input) => ({ mappings: await deps.service.discoverFields(input.backend) }),
96
156
  "discover.statuses": async (deps, input) => ({ mappings: await deps.service.discoverStatuses(input.backend) }),
97
157
  "discover.template": async (deps, input) => ({
@@ -109,6 +169,37 @@ export const TICKET_OP_HANDLERS: { [Op in TicketOperation]: Handler<Op> } = {
109
169
  if (!saved) throw new SavedQueryNotFoundError(input.name);
110
170
  return { issues: await deps.service.runQuery(saved.backend, saved.query, input.limit) };
111
171
  },
172
+ "issue.subscribe": async (deps, input, callContext) => {
173
+ const subscriberId = input.subscriberId ?? callContext?.callerSessionId ?? "";
174
+ const projectRoot = input.projectRoot ?? callContext?.callerProjectRoot;
175
+ deps.watches.subscribeIssue(input.ref, { subscriberId, scheduleMs: input.scheduleMs, projectRoot });
176
+ return { subscribed: true };
177
+ },
178
+ "issue.unsubscribe": async (deps, input, callContext) => {
179
+ deps.watches.unsubscribeIssue(input.ref, input.subscriberId ?? callContext?.callerSessionId ?? "");
180
+ return { unsubscribed: true };
181
+ },
182
+ "issue.subscribed": async (deps, input, callContext) => ({
183
+ watches: deps.watches.issueSubscriptionsFor(input.subscriberId ?? callContext?.callerSessionId ?? ""),
184
+ }),
185
+ "query.subscribe": async (deps, input, callContext) => {
186
+ const subscriberId = input.subscriberId ?? callContext?.callerSessionId ?? "";
187
+ const projectRoot = input.projectRoot ?? callContext?.callerProjectRoot;
188
+ deps.watches.subscribeQuery(input.name, { subscriberId, scheduleMs: input.scheduleMs, projectRoot });
189
+ return { subscribed: true };
190
+ },
191
+ "query.unsubscribe": async (deps, input, callContext) => {
192
+ deps.watches.unsubscribeQuery(input.name, input.subscriberId ?? callContext?.callerSessionId ?? "");
193
+ return { unsubscribed: true };
194
+ },
195
+ "query.subscribed": async (deps, input, callContext) => ({
196
+ watches: deps.watches.queryWatchSubscriptionsFor(input.subscriberId ?? callContext?.callerSessionId ?? ""),
197
+ }),
198
+ "watch.events": async (deps, input, callContext) => {
199
+ const subscriberId = input.subscriberId ?? callContext?.callerSessionId ?? "";
200
+ const events = deps.watches.eventsSince(subscriberId, input.sinceId ?? 0, input.limit);
201
+ return { events, lastId: events.at(-1)?.id ?? input.sinceId ?? 0 };
202
+ },
112
203
  "stage.add": async (deps, input) => ({ item: deps.stageStore.add(input.payload) }),
113
204
  "stage.list": async (deps) => ({ items: deps.stageStore.list() }),
114
205
  "stage.show": async (deps, input) => ({ item: deps.stageStore.show(input.id) }),
@@ -1,12 +1,17 @@
1
1
  /**
2
- * Focus — the single ticket currently being worked on, independent of any
3
- * one CLI invocation or tool call. Unlike the Ledger (a cache of every issue
4
- * the daemon has ever seen), Focus is a pointer: one ref, its resolved title
2
+ * Focus — the ticket currently being worked on, independent of any one CLI
3
+ * invocation or tool call. Unlike the Ledger (a cache of every issue the
4
+ * daemon has ever seen), Focus is a pointer: one ref, its resolved title
5
5
  * and full web URL, and whether work on it is active or paused. Persisted
6
- * so it survives daemon restarts. A singleton by design — there is never
7
- * more than one ticket in focus at a time, so setting focus always replaces
8
- * whatever was there, the same way switching your attention to a different
9
- * ticket in real life replaces what you were just looking at.
6
+ * so it survives daemon restarts.
7
+ *
8
+ * One Focus per *scope*, not a single global singleton -- mirrors Papyrus's
9
+ * own Task Focus (stores/task-focus-store.ts / stores/sqlite-task-focus-store.ts)
10
+ * one domain over: a scope defaults to "global" for a caller that doesn't
11
+ * supply one (the bare CLI, legacy behavior, exactly today's pre-scoping
12
+ * shape), but is normally the requesting Pi session's own id, so two
13
+ * concurrent agents/terminals each get their own Focus instead of
14
+ * clobbering a shared one.
10
15
  */
11
16
  import type { Database } from "bun:sqlite";
12
17
  import type { Migration } from "@danypops/vehicle-server/storage";
@@ -28,6 +33,28 @@ export const FOCUS_MIGRATIONS: Migration[] = [
28
33
  `);
29
34
  },
30
35
  },
36
+ {
37
+ // Re-keys ticket_focus from a hardcoded id=1 singleton to one row per scope. Focus is a
38
+ // pointer, not historical data -- dropping and recreating (rather than an in-place
39
+ // ALTER TABLE + backfill) is a deliberate, acceptable loss of whatever was focused before
40
+ // this migration runs, the same way Papyrus's own equivalent migration didn't attempt to
41
+ // carry a pre-scoping global focus forward into some arbitrary scope.
42
+ version: 5,
43
+ up: (db) => {
44
+ db.exec("DROP TABLE IF EXISTS ticket_focus;");
45
+ db.exec(`
46
+ CREATE TABLE ticket_focus (
47
+ scope TEXT PRIMARY KEY,
48
+ ref TEXT NOT NULL,
49
+ title TEXT NOT NULL,
50
+ url TEXT NOT NULL,
51
+ status TEXT NOT NULL,
52
+ pause_reason TEXT,
53
+ updated_at TEXT NOT NULL
54
+ );
55
+ `);
56
+ },
57
+ },
31
58
  ];
32
59
 
33
60
  export type FocusStatus = "active" | "paused";
@@ -49,7 +76,24 @@ export class FocusError extends Error {
49
76
  }
50
77
  }
51
78
 
79
+ export const FOCUS_DEFAULT_SCOPE = "global";
80
+ export const FOCUS_SCOPE_MAX_LENGTH = 128;
81
+ /** Bounds distinct concurrent focus scopes (sessions); the least-recently-updated scope is evicted beyond this, mirroring Papyrus's TASK_FOCUS_MAX_SCOPES. */
82
+ export const FOCUS_MAX_SCOPES = 200;
83
+ /** A scope untouched this long is eligible for time-based reaping (see FocusStore.reapStale), independent of FOCUS_MAX_SCOPES's own eviction. 30 days, matching Papyrus's TASK_FOCUS_STALE_AFTER_MS convention. */
84
+ export const FOCUS_STALE_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
85
+
86
+ /** An absent/empty scope defaults to "global" -- the bare CLI / legacy single-focus behavior; a real scope (normally a Pi session id) passes through unchanged. */
87
+ export function normalizeFocusScope(scope: string | undefined): string {
88
+ const value = scope && scope.length > 0 ? scope : FOCUS_DEFAULT_SCOPE;
89
+ if (value.length > FOCUS_SCOPE_MAX_LENGTH) {
90
+ throw new FocusError(`focus scope must be at most ${FOCUS_SCOPE_MAX_LENGTH} characters`);
91
+ }
92
+ return value;
93
+ }
94
+
52
95
  interface FocusRow {
96
+ scope: string;
53
97
  ref: string;
54
98
  title: string;
55
99
  url: string;
@@ -72,55 +116,72 @@ function rowToState(row: FocusRow): TicketFocusState {
72
116
  export class FocusStore {
73
117
  constructor(private readonly db: Database) {}
74
118
 
75
- get(): TicketFocusState | undefined {
119
+ get(scope?: string): TicketFocusState | undefined {
76
120
  const row = this.db
77
- .query("SELECT ref, title, url, status, pause_reason, updated_at FROM ticket_focus WHERE id = 1")
78
- .get() as FocusRow | null;
121
+ .query("SELECT scope, ref, title, url, status, pause_reason, updated_at FROM ticket_focus WHERE scope = $scope")
122
+ .get({ $scope: normalizeFocusScope(scope) }) as FocusRow | null;
79
123
  return row ? rowToState(row) : undefined;
80
124
  }
81
125
 
82
126
  /** Always lands "active" and drops any prior pause reason: switching focus onto a different ticket is not the same as resuming a pause on the old one. */
83
- set(ref: string, title: string, url: string): TicketFocusState {
127
+ set(ref: string, title: string, url: string, scope?: string): TicketFocusState {
128
+ const key = normalizeFocusScope(scope);
84
129
  const updatedAt = new Date().toISOString();
130
+ this.evictOldestBeyondCap(key);
85
131
  this.db
86
132
  .query(
87
- `INSERT INTO ticket_focus (id, ref, title, url, status, pause_reason, updated_at)
88
- VALUES (1, $ref, $title, $url, 'active', NULL, $updatedAt)
89
- ON CONFLICT(id) DO UPDATE SET
133
+ `INSERT INTO ticket_focus (scope, ref, title, url, status, pause_reason, updated_at)
134
+ VALUES ($scope, $ref, $title, $url, 'active', NULL, $updatedAt)
135
+ ON CONFLICT(scope) DO UPDATE SET
90
136
  ref = excluded.ref, title = excluded.title, url = excluded.url,
91
137
  status = 'active', pause_reason = NULL, updated_at = excluded.updated_at`,
92
138
  )
93
- .run({ $ref: ref, $title: title, $url: url, $updatedAt: updatedAt });
139
+ .run({ $scope: key, $ref: ref, $title: title, $url: url, $updatedAt: updatedAt });
94
140
  return { ref, title, url, status: "active", updatedAt };
95
141
  }
96
142
 
97
- pause(reason?: string): TicketFocusState {
98
- const current = this.get();
143
+ pause(reason?: string, scope?: string): TicketFocusState {
144
+ const current = this.get(scope);
99
145
  if (!current) throw new FocusError("no ticket is currently focused");
100
146
  if (current.status === "paused") throw new FocusError(`focus on "${current.ref}" is already paused`);
101
- return this.transition("paused", reason);
147
+ return this.transition("paused", reason, scope);
102
148
  }
103
149
 
104
- unpause(): TicketFocusState {
105
- const current = this.get();
150
+ unpause(scope?: string): TicketFocusState {
151
+ const current = this.get(scope);
106
152
  if (!current) throw new FocusError("no ticket is currently focused");
107
153
  if (current.status === "active") throw new FocusError(`focus on "${current.ref}" is already active`);
108
- return this.transition("active", undefined);
154
+ return this.transition("active", undefined, scope);
109
155
  }
110
156
 
111
- /** Returns whether a focus existed to clear (idempotent either way). */
112
- clear(): boolean {
113
- const existed = this.get() !== undefined;
114
- this.db.exec("DELETE FROM ticket_focus WHERE id = 1");
157
+ /** Returns whether a focus existed in this scope to clear (idempotent either way). */
158
+ clear(scope?: string): boolean {
159
+ const existed = this.get(scope) !== undefined;
160
+ this.db.query("DELETE FROM ticket_focus WHERE scope = $scope").run({ $scope: normalizeFocusScope(scope) });
115
161
  return existed;
116
162
  }
117
163
 
118
- private transition(status: FocusStatus, reason: string | undefined): TicketFocusState {
164
+ /** Deletes every scope's row whose updatedAt is strictly before olderThanIso (see FOCUS_STALE_AFTER_MS). Returns how many rows were removed. */
165
+ reapStale(olderThanIso: string): number {
166
+ return this.db.query("DELETE FROM ticket_focus WHERE updated_at < $cutoff").run({ $cutoff: olderThanIso }).changes;
167
+ }
168
+
169
+ private transition(status: FocusStatus, reason: string | undefined, scope: string | undefined): TicketFocusState {
170
+ const key = normalizeFocusScope(scope);
119
171
  const updatedAt = new Date().toISOString();
120
172
  this.db
121
- .query("UPDATE ticket_focus SET status = $status, pause_reason = $reason, updated_at = $updatedAt WHERE id = 1")
122
- .run({ $status: status, $reason: reason ?? null, $updatedAt: updatedAt });
123
- // Non-null: transition() is only ever called right after get() confirmed a row exists.
124
- return this.get()!;
173
+ .query("UPDATE ticket_focus SET status = $status, pause_reason = $reason, updated_at = $updatedAt WHERE scope = $scope")
174
+ .run({ $scope: key, $status: status, $reason: reason ?? null, $updatedAt: updatedAt });
175
+ // Non-null: transition() is only ever called right after get() confirmed a row exists for this scope.
176
+ return this.get(key)!;
177
+ }
178
+
179
+ /** Evicts the least-recently-updated scope once a brand-new scope would push the total beyond FOCUS_MAX_SCOPES. A no-op for a scope that already has a row (set() on an existing scope never counts as growth). */
180
+ private evictOldestBeyondCap(key: string): void {
181
+ const exists = this.db.query("SELECT 1 FROM ticket_focus WHERE scope = $scope").get({ $scope: key });
182
+ if (exists) return;
183
+ const count = (this.db.query("SELECT COUNT(*) AS count FROM ticket_focus").get() as { count: number }).count;
184
+ if (count < FOCUS_MAX_SCOPES) return;
185
+ this.db.exec("DELETE FROM ticket_focus WHERE scope = (SELECT scope FROM ticket_focus ORDER BY updated_at ASC LIMIT 1)");
125
186
  }
126
187
  }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Sqlite-backed SessionIdentityStore adapter for @danypops/vehicle-server's own
3
+ * generic session-identity primitive (secret generation/hashing/constant-time
4
+ * verify) -- this file only wires that primitive to sqlite, the same split
5
+ * Papyrus's own stores/sqlite-session-identity-store.ts makes.
6
+ *
7
+ * Hardens the one place a caller-supplied session id becomes BEHAVIOR-affecting
8
+ * in this daemon: focus.set/pause/unpause/clear, once a caller passes an EXPLICIT
9
+ * sessionId (see rpc/server.ts's own TICKET_OP_HANDLERS) -- this daemon, like
10
+ * Papyrus's, authenticates every client with one shared bearer token, so a bare
11
+ * session id alone is not a credential once it can redirect/pause/clear someone
12
+ * else's live Focus. Opt-in armor: a session id that was never registered
13
+ * (the implicit callContext.callerSessionId default from a real Vehicle tool
14
+ * call, or a bare CLI caller) passes through unarmored, exactly as today.
15
+ */
16
+ import type { Database } from "bun:sqlite";
17
+ import type { SessionIdentityRecord, SessionIdentityStore } from "@danypops/vehicle-server/session-identity";
18
+ import type { Migration } from "@danypops/vehicle-server/storage";
19
+
20
+ /** An explicit sessionId claim (see rpc/server.ts's resolveFocusScope) against a REGISTERED session id, with a missing or wrong sessionSecret. Maps to HTTP 401, not 400 -- this is an authorization failure, not a validation one. */
21
+ export class SessionAuthError extends Error {
22
+ constructor(message: string) {
23
+ super(message);
24
+ this.name = "SessionAuthError";
25
+ }
26
+ }
27
+
28
+ export const SESSION_IDENTITY_MIGRATIONS: Migration[] = [
29
+ {
30
+ version: 6,
31
+ up: (db) => {
32
+ db.exec(`
33
+ CREATE TABLE session_identities (
34
+ session_id TEXT PRIMARY KEY,
35
+ secret_hash TEXT NOT NULL,
36
+ registered_at TEXT NOT NULL,
37
+ last_seen_at TEXT NOT NULL
38
+ );
39
+ `);
40
+ },
41
+ },
42
+ ];
43
+
44
+ interface SessionIdentityRow {
45
+ session_id: string;
46
+ secret_hash: string;
47
+ registered_at: string;
48
+ last_seen_at: string;
49
+ }
50
+
51
+ function rowToRecord(row: SessionIdentityRow): SessionIdentityRecord {
52
+ return { sessionId: row.session_id, secretHash: row.secret_hash, registeredAt: row.registered_at, lastSeenAt: row.last_seen_at };
53
+ }
54
+
55
+ export class SqliteSessionIdentityStore implements SessionIdentityStore {
56
+ constructor(private readonly db: Database) {}
57
+
58
+ find(sessionId: string): SessionIdentityRecord | undefined {
59
+ const row = this.db
60
+ .query("SELECT session_id, secret_hash, registered_at, last_seen_at FROM session_identities WHERE session_id = $sessionId")
61
+ .get({ $sessionId: sessionId }) as SessionIdentityRow | null;
62
+ return row ? rowToRecord(row) : undefined;
63
+ }
64
+
65
+ upsert(record: SessionIdentityRecord): void {
66
+ this.db
67
+ .query(
68
+ `INSERT INTO session_identities (session_id, secret_hash, registered_at, last_seen_at)
69
+ VALUES ($sessionId, $secretHash, $registeredAt, $lastSeenAt)
70
+ ON CONFLICT(session_id) DO UPDATE SET
71
+ secret_hash = excluded.secret_hash, registered_at = excluded.registered_at, last_seen_at = excluded.last_seen_at`,
72
+ )
73
+ .run({
74
+ $sessionId: record.sessionId,
75
+ $secretHash: record.secretHash,
76
+ $registeredAt: record.registeredAt,
77
+ $lastSeenAt: record.lastSeenAt,
78
+ });
79
+ }
80
+
81
+ remove(sessionId: string): void {
82
+ this.db.query("DELETE FROM session_identities WHERE session_id = $sessionId").run({ $sessionId: sessionId });
83
+ }
84
+
85
+ touch(sessionId: string, lastSeenAt: string): void {
86
+ this.db
87
+ .query("UPDATE session_identities SET last_seen_at = $lastSeenAt WHERE session_id = $sessionId")
88
+ .run({ $sessionId: sessionId, $lastSeenAt: lastSeenAt });
89
+ }
90
+
91
+ count(): number {
92
+ return (this.db.query("SELECT COUNT(*) AS count FROM session_identities").get() as { count: number }).count;
93
+ }
94
+ }