atproto user agency toolkit for individuals and groups
1/**
2 * SQLite persistence for StoredPolicy objects.
3 *
4 * Stores policies in a `policies` table with JSON columns for complex
5 * nested fields (target, replication, sync, retention).
6 */
7
8import type Database from "better-sqlite3";
9import type {
10 StoredPolicy,
11 PolicyState,
12 PolicyType,
13 PolicySource,
14 ConsentStatus,
15 PolicyTarget,
16 ReplicationGoals,
17 SyncConfig,
18 RetentionConfig,
19} from "./types.js";
20
21export class PolicyStorage {
22 constructor(private db: Database.Database) {}
23
24 /**
25 * Create the policies table if it doesn't exist.
26 */
27 initSchema(): void {
28 this.db.exec(`
29 CREATE TABLE IF NOT EXISTS policies (
30 id TEXT PRIMARY KEY,
31 name TEXT NOT NULL,
32 type TEXT NOT NULL,
33 state TEXT NOT NULL DEFAULT 'active',
34 source TEXT NOT NULL DEFAULT 'manual',
35 consent TEXT NOT NULL DEFAULT 'unconsented',
36 target TEXT NOT NULL,
37 replication TEXT NOT NULL,
38 sync TEXT NOT NULL,
39 retention TEXT NOT NULL,
40 priority INTEGER NOT NULL DEFAULT 50,
41 enabled INTEGER NOT NULL DEFAULT 1,
42 created_by TEXT NOT NULL DEFAULT 'system',
43 counterparty_did TEXT,
44 local_offer_uri TEXT,
45 remote_offer_uri TEXT,
46 created_at TEXT NOT NULL DEFAULT (datetime('now')),
47 activated_at TEXT,
48 suspended_at TEXT,
49 terminated_at TEXT,
50 expires_at TEXT
51 )
52 `);
53 }
54
55 /**
56 * Insert or update a policy.
57 */
58 upsertPolicy(policy: StoredPolicy): void {
59 this.db.prepare(`
60 INSERT INTO policies (
61 id, name, type, state, source, consent,
62 target, replication, sync, retention,
63 priority, enabled, created_by,
64 counterparty_did, local_offer_uri, remote_offer_uri,
65 created_at, activated_at, suspended_at, terminated_at, expires_at
66 ) VALUES (
67 ?, ?, ?, ?, ?, ?,
68 ?, ?, ?, ?,
69 ?, ?, ?,
70 ?, ?, ?,
71 ?, ?, ?, ?, ?
72 )
73 ON CONFLICT(id) DO UPDATE SET
74 name = excluded.name,
75 type = excluded.type,
76 state = excluded.state,
77 source = excluded.source,
78 consent = excluded.consent,
79 target = excluded.target,
80 replication = excluded.replication,
81 sync = excluded.sync,
82 retention = excluded.retention,
83 priority = excluded.priority,
84 enabled = excluded.enabled,
85 created_by = excluded.created_by,
86 counterparty_did = excluded.counterparty_did,
87 local_offer_uri = excluded.local_offer_uri,
88 remote_offer_uri = excluded.remote_offer_uri,
89 activated_at = excluded.activated_at,
90 suspended_at = excluded.suspended_at,
91 terminated_at = excluded.terminated_at,
92 expires_at = excluded.expires_at
93 `).run(
94 policy.id,
95 policy.name,
96 policy.type,
97 policy.state,
98 policy.source,
99 policy.consent,
100 JSON.stringify(policy.target),
101 JSON.stringify(policy.replication),
102 JSON.stringify(policy.sync),
103 JSON.stringify(policy.retention),
104 policy.priority,
105 policy.enabled ? 1 : 0,
106 policy.createdBy,
107 policy.counterpartyDid ?? null,
108 policy.localOfferUri ?? null,
109 policy.remoteOfferUri ?? null,
110 policy.createdAt,
111 policy.activatedAt,
112 policy.suspendedAt,
113 policy.terminatedAt,
114 policy.expiresAt,
115 );
116 }
117
118 /**
119 * Load policies, optionally filtered by state(s).
120 */
121 loadPolicies(states?: PolicyState[]): StoredPolicy[] {
122 let rows: PolicyRow[];
123 if (states && states.length > 0) {
124 const placeholders = states.map(() => "?").join(", ");
125 rows = this.db
126 .prepare(`SELECT * FROM policies WHERE state IN (${placeholders})`)
127 .all(...states) as PolicyRow[];
128 } else {
129 rows = this.db.prepare("SELECT * FROM policies").all() as PolicyRow[];
130 }
131 return rows.map(rowToStoredPolicy);
132 }
133
134 /**
135 * Load only active policies (state = 'active').
136 */
137 loadActivePolicies(): StoredPolicy[] {
138 return this.loadPolicies(["active"]);
139 }
140
141 /**
142 * Get a single policy by ID.
143 */
144 getPolicy(id: string): StoredPolicy | null {
145 const row = this.db
146 .prepare("SELECT * FROM policies WHERE id = ?")
147 .get(id) as PolicyRow | undefined;
148 return row ? rowToStoredPolicy(row) : null;
149 }
150
151 /**
152 * Delete a policy by ID.
153 */
154 deletePolicy(id: string): boolean {
155 const result = this.db
156 .prepare("DELETE FROM policies WHERE id = ?")
157 .run(id);
158 return result.changes > 0;
159 }
160
161 /**
162 * Transition a policy to a new state, updating the corresponding timestamp.
163 */
164 transitionState(id: string, newState: PolicyState): boolean {
165 const now = new Date().toISOString();
166 let timestampCol: string | null = null;
167 switch (newState) {
168 case "active":
169 timestampCol = "activated_at";
170 break;
171 case "suspended":
172 timestampCol = "suspended_at";
173 break;
174 case "terminated":
175 timestampCol = "terminated_at";
176 break;
177 }
178
179 let sql: string;
180 if (timestampCol) {
181 sql = `UPDATE policies SET state = ?, ${timestampCol} = ? WHERE id = ?`;
182 const result = this.db.prepare(sql).run(newState, now, id);
183 return result.changes > 0;
184 } else {
185 sql = "UPDATE policies SET state = ? WHERE id = ?";
186 const result = this.db.prepare(sql).run(newState, id);
187 return result.changes > 0;
188 }
189 }
190
191 /**
192 * Check if a policy with the given ID exists.
193 */
194 hasPolicy(id: string): boolean {
195 const row = this.db
196 .prepare("SELECT 1 FROM policies WHERE id = ?")
197 .get(id);
198 return row !== undefined;
199 }
200
201 /**
202 * Delete all policies.
203 * Used during full disconnect to wipe the node clean.
204 */
205 deleteAll(): void {
206 this.db.prepare("DELETE FROM policies").run();
207 }
208
209 /**
210 * Count policies, optionally filtered by state.
211 */
212 count(state?: PolicyState): number {
213 if (state) {
214 const row = this.db
215 .prepare("SELECT COUNT(*) as cnt FROM policies WHERE state = ?")
216 .get(state) as { cnt: number };
217 return row.cnt;
218 }
219 const row = this.db
220 .prepare("SELECT COUNT(*) as cnt FROM policies")
221 .get() as { cnt: number };
222 return row.cnt;
223 }
224}
225
226// ============================================
227// Internal row mapping
228// ============================================
229
230interface PolicyRow {
231 id: string;
232 name: string;
233 type: string;
234 state: string;
235 source: string;
236 consent: string;
237 target: string;
238 replication: string;
239 sync: string;
240 retention: string;
241 priority: number;
242 enabled: number;
243 created_by: string;
244 counterparty_did: string | null;
245 local_offer_uri: string | null;
246 remote_offer_uri: string | null;
247 created_at: string;
248 activated_at: string | null;
249 suspended_at: string | null;
250 terminated_at: string | null;
251 expires_at: string | null;
252}
253
254function rowToStoredPolicy(row: PolicyRow): StoredPolicy {
255 return {
256 id: row.id,
257 name: row.name,
258 type: row.type as PolicyType,
259 state: row.state as PolicyState,
260 source: row.source as PolicySource,
261 consent: row.consent as ConsentStatus,
262 target: JSON.parse(row.target) as PolicyTarget,
263 replication: JSON.parse(row.replication) as ReplicationGoals,
264 sync: JSON.parse(row.sync) as SyncConfig,
265 retention: JSON.parse(row.retention) as RetentionConfig,
266 priority: row.priority,
267 enabled: row.enabled === 1,
268 createdBy: row.created_by,
269 counterpartyDid: row.counterparty_did ?? undefined,
270 localOfferUri: row.local_offer_uri ?? undefined,
271 remoteOfferUri: row.remote_offer_uri ?? undefined,
272 createdAt: row.created_at,
273 activatedAt: row.activated_at,
274 suspendedAt: row.suspended_at,
275 terminatedAt: row.terminated_at,
276 expiresAt: row.expires_at,
277 };
278}