@coffre/db 0.14.4 → 0.15.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 (218) hide show
  1. package/README.md +2 -1
  2. package/dist/change-records-_fKvahvs.js +55 -0
  3. package/dist/connect.d.ts +22 -10
  4. package/dist/connect.js +74 -12
  5. package/dist/database-CVteLhp1.d.ts +410 -0
  6. package/dist/{database-BadNeeN-.js → database-D55ENPX-.js} +46 -25
  7. package/dist/dialect.d.ts +4 -3
  8. package/dist/dialect.js +14 -5
  9. package/dist/grants.d.ts +4 -4
  10. package/dist/grants.js +4 -4
  11. package/dist/hyperdrive.d.ts +48 -23
  12. package/dist/hyperdrive.js +168 -91
  13. package/dist/index.d.ts +3 -3
  14. package/dist/index.js +1 -1
  15. package/dist/log-DaD7fyUq.d.ts +722 -0
  16. package/dist/log.d.ts +2 -596
  17. package/dist/log.js +224 -10
  18. package/dist/migrate.d.ts +43 -3
  19. package/dist/migrate.js +149 -29
  20. package/dist/migrations/postgres/0021_prune_vault_releases.sql +19 -0
  21. package/dist/migrations/postgres/0022_log_015.sql +672 -0
  22. package/dist/migrations/postgres/0023_change_path.sql +153 -0
  23. package/dist/migrations/postgres/0024_use_015.sql +556 -0
  24. package/dist/migrations/postgres/0025_app_change.sql +221 -0
  25. package/dist/migrations/postgres/0026_issue_lock.sql +106 -0
  26. package/dist/migrations/postgres/0027_retention.sql +118 -0
  27. package/dist/migrations/postgres/0028_use_stalls.sql +594 -0
  28. package/dist/migrations/postgres/0029_use_objects.sql +887 -0
  29. package/dist/migrations/postgres/0030_unarchive_restores.sql +259 -0
  30. package/dist/migrations/postgres/0031_app_read.sql +65 -0
  31. package/dist/migrations/postgres/0032_secrets_live_index.sql +4 -0
  32. package/dist/migrations/postgres/0033_use_generations.sql +582 -0
  33. package/dist/migrations/postgres/0034_vault_credentials.sql +153 -0
  34. package/dist/migrations/postgres/0035_first_days.sql +3 -0
  35. package/dist/migrations/postgres/0036_vault_issues.sql +91 -0
  36. package/dist/migrations/postgres/0037_vault_bindings.sql +271 -0
  37. package/dist/migrations/postgres/0040_binding_origin.sql +159 -0
  38. package/dist/migrations/postgres/0041_cutover.sql +132 -0
  39. package/dist/migrations/postgres/0042_sign_in_index.sql +7 -0
  40. package/dist/migrations/postgres/0043_issue_revoke_index.sql +4 -0
  41. package/dist/migrations/postgres/0044_use_by_name.sql +705 -0
  42. package/dist/migrations/postgres/0045_decide_first.sql +878 -0
  43. package/dist/migrations/postgres/0046_way_in_uses.sql +1149 -0
  44. package/dist/migrations/postgres/0047_cutover_finish.sql +268 -0
  45. package/dist/migrations/postgres/0048_use_batch.sql +59 -0
  46. package/dist/migrations/postgres/0049_reads.sql +171 -0
  47. package/dist/migrations/postgres/meta/0021_snapshot.json +3334 -0
  48. package/dist/migrations/postgres/meta/0022_snapshot.json +3831 -0
  49. package/dist/migrations/postgres/meta/0023_snapshot.json +3831 -0
  50. package/dist/migrations/postgres/meta/0024_snapshot.json +3831 -0
  51. package/dist/migrations/postgres/meta/0025_snapshot.json +3831 -0
  52. package/dist/migrations/postgres/meta/0026_snapshot.json +3831 -0
  53. package/dist/migrations/postgres/meta/0027_snapshot.json +3853 -0
  54. package/dist/migrations/postgres/meta/0028_snapshot.json +3853 -0
  55. package/dist/migrations/postgres/meta/0029_snapshot.json +3859 -0
  56. package/dist/migrations/postgres/meta/0030_snapshot.json +3859 -0
  57. package/dist/migrations/postgres/meta/0031_snapshot.json +3859 -0
  58. package/dist/migrations/postgres/meta/0032_snapshot.json +3875 -0
  59. package/dist/migrations/postgres/meta/0033_snapshot.json +3875 -0
  60. package/dist/migrations/postgres/meta/0034_snapshot.json +3875 -0
  61. package/dist/migrations/postgres/meta/0035_snapshot.json +3875 -0
  62. package/dist/migrations/postgres/meta/0036_snapshot.json +3875 -0
  63. package/dist/migrations/postgres/meta/0037_snapshot.json +3875 -0
  64. package/dist/migrations/postgres/meta/0038_snapshot.json +3881 -0
  65. package/dist/migrations/postgres/meta/0039_snapshot.json +3881 -0
  66. package/dist/migrations/postgres/meta/0040_snapshot.json +3931 -0
  67. package/dist/migrations/postgres/meta/0041_snapshot.json +3981 -0
  68. package/dist/migrations/postgres/meta/0042_snapshot.json +3981 -0
  69. package/dist/migrations/postgres/meta/0043_snapshot.json +3981 -0
  70. package/dist/migrations/postgres/meta/0044_snapshot.json +3991 -0
  71. package/dist/migrations/postgres/meta/0045_snapshot.json +3997 -0
  72. package/dist/migrations/postgres/meta/0046_snapshot.json +3997 -0
  73. package/dist/migrations/postgres/meta/0047_snapshot.json +4234 -0
  74. package/dist/migrations/postgres/meta/_journal.json +189 -0
  75. package/dist/migrations/sqlite/0018_log_015.sql +84 -0
  76. package/dist/migrations/sqlite/0019_retention.sql +2 -0
  77. package/dist/migrations/sqlite/0020_ended_references.sql +1 -0
  78. package/dist/migrations/sqlite/0021_secrets_live_index.sql +1 -0
  79. package/dist/migrations/sqlite/0022_binding_origin.sql +1 -0
  80. package/dist/migrations/sqlite/0023_sign_in_index.sql +2 -0
  81. package/dist/migrations/sqlite/0024_issue_revoke_index.sql +2 -0
  82. package/dist/migrations/sqlite/0025_way_in_uses.sql +42 -0
  83. package/dist/migrations/sqlite/0026_cutover_finish.sql +11 -0
  84. package/dist/migrations/sqlite/0027_reads.sql +38 -0
  85. package/dist/migrations/sqlite/meta/0018_snapshot.json +3529 -0
  86. package/dist/migrations/sqlite/meta/0019_snapshot.json +3538 -0
  87. package/dist/migrations/sqlite/meta/0020_snapshot.json +3545 -0
  88. package/dist/migrations/sqlite/meta/0021_snapshot.json +3553 -0
  89. package/dist/migrations/sqlite/meta/0022_snapshot.json +3560 -0
  90. package/dist/migrations/sqlite/meta/0023_snapshot.json +3579 -0
  91. package/dist/migrations/sqlite/meta/0024_snapshot.json +3598 -0
  92. package/dist/migrations/sqlite/meta/0025_snapshot.json +3609 -0
  93. package/dist/migrations/sqlite/meta/0026_snapshot.json +3616 -0
  94. package/dist/migrations/sqlite/meta/0027_snapshot.json +3834 -0
  95. package/dist/migrations/sqlite/meta/_journal.json +70 -0
  96. package/dist/platform-Y3gICK6n.d.ts +58 -0
  97. package/dist/portable.d.ts +2 -2
  98. package/dist/postgres-BI3xTnyw.js +1718 -0
  99. package/dist/postgres-platform-node.d.ts +14 -0
  100. package/dist/postgres-platform-node.js +21 -0
  101. package/dist/postgres-platform-workerd.d.ts +61 -0
  102. package/dist/postgres-platform-workerd.js +163 -0
  103. package/dist/{schema-z8GcN_Xu.d.ts → schema-DgoPqqsI.d.ts} +1329 -9
  104. package/dist/schema-sqlite.d.ts +1308 -8
  105. package/dist/schema-sqlite.js +156 -20
  106. package/dist/schema-version.d.ts +1 -1
  107. package/dist/schema-version.js +261 -2
  108. package/dist/schema.d.ts +2 -2
  109. package/dist/schema.js +229 -22
  110. package/package.json +13 -5
  111. package/src/active-subject.ts +9 -0
  112. package/src/change-records.ts +38 -0
  113. package/src/connect.ts +67 -13
  114. package/src/database.ts +67 -51
  115. package/src/dialect.ts +17 -8
  116. package/src/grants.ts +3 -3
  117. package/src/hyperdrive.ts +181 -104
  118. package/src/log.ts +244 -11
  119. package/src/migrate.ts +157 -30
  120. package/src/migrations/postgres/0021_prune_vault_releases.sql +19 -0
  121. package/src/migrations/postgres/0022_log_015.sql +672 -0
  122. package/src/migrations/postgres/0023_change_path.sql +153 -0
  123. package/src/migrations/postgres/0024_use_015.sql +556 -0
  124. package/src/migrations/postgres/0025_app_change.sql +221 -0
  125. package/src/migrations/postgres/0026_issue_lock.sql +106 -0
  126. package/src/migrations/postgres/0027_retention.sql +118 -0
  127. package/src/migrations/postgres/0028_use_stalls.sql +594 -0
  128. package/src/migrations/postgres/0029_use_objects.sql +887 -0
  129. package/src/migrations/postgres/0030_unarchive_restores.sql +259 -0
  130. package/src/migrations/postgres/0031_app_read.sql +65 -0
  131. package/src/migrations/postgres/0032_secrets_live_index.sql +4 -0
  132. package/src/migrations/postgres/0033_use_generations.sql +582 -0
  133. package/src/migrations/postgres/0034_vault_credentials.sql +153 -0
  134. package/src/migrations/postgres/0035_first_days.sql +3 -0
  135. package/src/migrations/postgres/0036_vault_issues.sql +91 -0
  136. package/src/migrations/postgres/0037_vault_bindings.sql +271 -0
  137. package/src/migrations/postgres/0040_binding_origin.sql +159 -0
  138. package/src/migrations/postgres/0041_cutover.sql +132 -0
  139. package/src/migrations/postgres/0042_sign_in_index.sql +7 -0
  140. package/src/migrations/postgres/0043_issue_revoke_index.sql +4 -0
  141. package/src/migrations/postgres/0044_use_by_name.sql +705 -0
  142. package/src/migrations/postgres/0045_decide_first.sql +878 -0
  143. package/src/migrations/postgres/0046_way_in_uses.sql +1149 -0
  144. package/src/migrations/postgres/0047_cutover_finish.sql +268 -0
  145. package/src/migrations/postgres/0048_use_batch.sql +59 -0
  146. package/src/migrations/postgres/0049_reads.sql +171 -0
  147. package/src/migrations/postgres/meta/0021_snapshot.json +3334 -0
  148. package/src/migrations/postgres/meta/0022_snapshot.json +3831 -0
  149. package/src/migrations/postgres/meta/0023_snapshot.json +3831 -0
  150. package/src/migrations/postgres/meta/0024_snapshot.json +3831 -0
  151. package/src/migrations/postgres/meta/0025_snapshot.json +3831 -0
  152. package/src/migrations/postgres/meta/0026_snapshot.json +3831 -0
  153. package/src/migrations/postgres/meta/0027_snapshot.json +3853 -0
  154. package/src/migrations/postgres/meta/0028_snapshot.json +3853 -0
  155. package/src/migrations/postgres/meta/0029_snapshot.json +3859 -0
  156. package/src/migrations/postgres/meta/0030_snapshot.json +3859 -0
  157. package/src/migrations/postgres/meta/0031_snapshot.json +3859 -0
  158. package/src/migrations/postgres/meta/0032_snapshot.json +3875 -0
  159. package/src/migrations/postgres/meta/0033_snapshot.json +3875 -0
  160. package/src/migrations/postgres/meta/0034_snapshot.json +3875 -0
  161. package/src/migrations/postgres/meta/0035_snapshot.json +3875 -0
  162. package/src/migrations/postgres/meta/0036_snapshot.json +3875 -0
  163. package/src/migrations/postgres/meta/0037_snapshot.json +3875 -0
  164. package/src/migrations/postgres/meta/0038_snapshot.json +3881 -0
  165. package/src/migrations/postgres/meta/0039_snapshot.json +3881 -0
  166. package/src/migrations/postgres/meta/0040_snapshot.json +3931 -0
  167. package/src/migrations/postgres/meta/0041_snapshot.json +3981 -0
  168. package/src/migrations/postgres/meta/0042_snapshot.json +3981 -0
  169. package/src/migrations/postgres/meta/0043_snapshot.json +3981 -0
  170. package/src/migrations/postgres/meta/0044_snapshot.json +3991 -0
  171. package/src/migrations/postgres/meta/0045_snapshot.json +3997 -0
  172. package/src/migrations/postgres/meta/0046_snapshot.json +3997 -0
  173. package/src/migrations/postgres/meta/0047_snapshot.json +4234 -0
  174. package/src/migrations/postgres/meta/_journal.json +189 -0
  175. package/src/migrations/sqlite/0018_log_015.sql +84 -0
  176. package/src/migrations/sqlite/0019_retention.sql +2 -0
  177. package/src/migrations/sqlite/0020_ended_references.sql +1 -0
  178. package/src/migrations/sqlite/0021_secrets_live_index.sql +1 -0
  179. package/src/migrations/sqlite/0022_binding_origin.sql +1 -0
  180. package/src/migrations/sqlite/0023_sign_in_index.sql +2 -0
  181. package/src/migrations/sqlite/0024_issue_revoke_index.sql +2 -0
  182. package/src/migrations/sqlite/0025_way_in_uses.sql +42 -0
  183. package/src/migrations/sqlite/0026_cutover_finish.sql +11 -0
  184. package/src/migrations/sqlite/0027_reads.sql +38 -0
  185. package/src/migrations/sqlite/meta/0018_snapshot.json +3529 -0
  186. package/src/migrations/sqlite/meta/0019_snapshot.json +3538 -0
  187. package/src/migrations/sqlite/meta/0020_snapshot.json +3545 -0
  188. package/src/migrations/sqlite/meta/0021_snapshot.json +3553 -0
  189. package/src/migrations/sqlite/meta/0022_snapshot.json +3560 -0
  190. package/src/migrations/sqlite/meta/0023_snapshot.json +3579 -0
  191. package/src/migrations/sqlite/meta/0024_snapshot.json +3598 -0
  192. package/src/migrations/sqlite/meta/0025_snapshot.json +3609 -0
  193. package/src/migrations/sqlite/meta/0026_snapshot.json +3616 -0
  194. package/src/migrations/sqlite/meta/0027_snapshot.json +3834 -0
  195. package/src/migrations/sqlite/meta/_journal.json +70 -0
  196. package/src/postgres-js/README.md +183 -0
  197. package/src/postgres-js/UNLICENSE +24 -0
  198. package/src/postgres-js/bytes.ts +89 -0
  199. package/src/postgres-js/connection.ts +882 -0
  200. package/src/postgres-js/errors.ts +84 -0
  201. package/src/postgres-js/index.ts +516 -0
  202. package/src/postgres-js/platform.node.ts +29 -0
  203. package/src/postgres-js/platform.ts +60 -0
  204. package/src/postgres-js/platform.workerd.ts +225 -0
  205. package/src/postgres-js/query.ts +123 -0
  206. package/src/postgres-js/queue.ts +42 -0
  207. package/src/postgres-js/result.ts +46 -0
  208. package/src/postgres-js/types.ts +52 -0
  209. package/src/postgres.ts +375 -26
  210. package/src/schema-version.ts +2 -2
  211. package/src/schema.sqlite.ts +200 -9
  212. package/src/schema.ts +294 -8
  213. package/src/verify-runtime.ts +18 -29
  214. package/LICENSE +0 -21
  215. package/dist/database-CFhTnnzM.d.ts +0 -73
  216. package/dist/postgres-Bmar8y0q.js +0 -29
  217. package/dist/relations-BfR3JjT1.js +0 -38
  218. package/src/relations.ts +0 -40
package/src/log.ts CHANGED
@@ -1,10 +1,14 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { GENESIS_HASH, sealEntry, type LogFields, type LogKey, type StoredEntry } from '@coffre/core/audit';
2
- import { and, asc, count, desc, eq, gt, gte, lt, lte, sql, type Placeholder, type SQL } from 'drizzle-orm';
3
+ import type { PreparedChange } from '@coffre/core/vault';
4
+ import { and, asc, count, desc, eq, gt, gte, isNotNull, isNull, lt, lte, sql, type Placeholder, type SQL } from 'drizzle-orm';
3
5
 
4
6
  import { afterCommit } from './commits.ts';
5
7
  import { tablesOf, type Queryable, type Transaction } from './database.ts';
6
8
  import { prepared } from './prepared.ts';
7
- import { clockMillis, engineOf, forUpdate } from './dialect.ts';
9
+ import { changedRows, clockMillis, engineOf, forUpdate, rowsOf } from './dialect.ts';
10
+
11
+ export { recorded, RECORDS, type ChangeRecord } from './change-records.ts';
8
12
 
9
13
  /**
10
14
  * Appending to the audit log, the one table both the app and the vault
@@ -59,6 +63,20 @@ export class LogHeadMismatch extends Error {
59
63
  }
60
64
  }
61
65
 
66
+ /**
67
+ * As many transactions as may already wait for the log's head do
68
+ * (`lockLogHeadQueued`): this one waits for nothing, and holds nothing.
69
+ * Its code is Postgres's for a lock not available, so every caller already
70
+ * answers it as one that gave up waiting: unavailable, ask again shortly.
71
+ */
72
+ export class HeadBusy extends Error {
73
+ readonly code = '55P03';
74
+ constructor() {
75
+ super("too many requests wait for the audit log's head: ask again shortly");
76
+ this.name = 'HeadBusy';
77
+ }
78
+ }
79
+
62
80
  /**
63
81
  * The log's head is behind one this process wrote: the database was rolled
64
82
  * back, or entries were cut off its end. That cannot be told from a restore,
@@ -136,6 +154,37 @@ export async function lockLogHead(tx: Transaction): Promise<{ nextSeq: bigint; h
136
154
  return head;
137
155
  }
138
156
 
157
+ /**
158
+ * Take the issue lock exclusive (migration 0026's, `1668244070, 1`): every
159
+ * issue in flight, a sign-in or a token's, ends first, and none starts
160
+ * until this transaction does. A write that moves what an issue's policy
161
+ * check compares (`issue_token`: the policy, a protected environment's
162
+ * slug) takes it, after its own rows and before the fence its append's
163
+ * trigger takes, as the checkpointer's barrier does: so the issue is
164
+ * ordered against it, and no use waits behind an issue (formal/Lifecycle.tla's
165
+ * IssuePolicyHeld, Migration015.tla's PolicyIssueFirst; T149, review r3).
166
+ * SQLite's transactions hold the whole file.
167
+ */
168
+ export async function holdIssues(tx: Transaction): Promise<void> {
169
+ if (engineOf(tx) === 'sqlite') return;
170
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(1668244070, 1)`);
171
+ }
172
+
173
+ /**
174
+ * The issue lock shared, then 0.15's fence exclusive (`1668244070, 0`), as
175
+ * 0.14's trigger takes them for an entry that changes access: what 0.15's
176
+ * cutover holds, its member rows and 0.14's head taken first, so that its
177
+ * own 0.14 entry's trigger waits on nothing (formal/Migration015.tla,
178
+ * CutoverIssueFirst; T132). Every use and every change waits for its
179
+ * commit; the checkpointer's barrier, which takes the issue lock exclusive
180
+ * first, too. SQLite's transactions hold the whole file.
181
+ */
182
+ export async function holdFence(tx: Transaction): Promise<void> {
183
+ if (engineOf(tx) === 'sqlite') return;
184
+ await tx.execute(sql`SELECT pg_advisory_xact_lock_shared(1668244070, 1)`);
185
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(1668244070, 0)`);
186
+ }
187
+
139
188
  /**
140
189
  * Lock the log's head if no one holds it, without waiting: whether it is
141
190
  * held now. For work any caller may do, of which one at a time is enough,
@@ -148,13 +197,47 @@ export async function lockLogHeadIfFree(tx: Transaction): Promise<boolean> {
148
197
  if (held) holding.add(tx);
149
198
  return held;
150
199
  }
151
- const { rows } = await (tx as unknown as { execute(query: SQL): Promise<{ rows: unknown[] }> }).execute(
152
- sql`SELECT next_seq FROM audit_chain_head WHERE only_row FOR UPDATE SKIP LOCKED`,
153
- );
200
+ const rows = await rowsOf(tx, sql`SELECT next_seq FROM audit_chain_head WHERE only_row FOR UPDATE SKIP LOCKED`);
154
201
  if (rows.length > 0) holding.add(tx);
155
202
  return rows.length > 0;
156
203
  }
157
204
 
205
+ /**
206
+ * Lock the log's head, waiting for it as `lockLogHead` does, if fewer than
207
+ * `waiters` other transactions hold one of its places in line; otherwise
208
+ * refuse at once (`HeadBusy`), having waited for nothing. A place is an
209
+ * advisory lock, tried, never waited for, and held to the end of the
210
+ * transaction, which holds the head by then: so at most `waiters`
211
+ * connections wait for the head, or hold it, through here, and a burst
212
+ * beyond them answers in one round trip rather than seconds later, holding
213
+ * a connection all the while. One statement: the place, then, only if one
214
+ * was free, the head; refused, one more says whether the head is there at
215
+ * all. SQLite's transactions hold the whole file.
216
+ */
217
+ export async function lockLogHeadQueued(tx: Transaction, waiters: number): Promise<{ nextSeq: bigint; headHash: Buffer }> {
218
+ if (engineOf(tx) === 'sqlite') return lockLogHead(tx);
219
+ const rows = await rowsOf<{ next_seq: string; head_hash: Buffer }>(tx, sql`
220
+ SELECT next_seq, head_hash FROM audit_chain_head
221
+ WHERE only_row AND (SELECT true FROM generate_series(1, ${waiters}::int) AS place WHERE pg_try_advisory_xact_lock(${HEAD_LINE}, place) LIMIT 1)
222
+ FOR UPDATE`);
223
+ const [head] = rows;
224
+ if (head === undefined) {
225
+ // No place, or no head at all: the second, which no one may write past, said as lockLogHead says it.
226
+ const [found] = await rowsOf<{ found: boolean }>(tx, sql`SELECT EXISTS (SELECT 1 FROM audit_chain_head WHERE only_row) AS found`);
227
+ if (found?.found !== true) throw new Error('audit_chain_head is missing; refusing to write an unchained entry');
228
+ throw new HeadBusy();
229
+ }
230
+ holding.add(tx);
231
+ return { nextSeq: BigInt(head.next_seq), headHash: head.head_hash };
232
+ }
233
+
234
+ /**
235
+ * The first key of the head's places in line, Postgres's advisory locks:
236
+ * the second is the place. A space apart from the members' queues and the
237
+ * migrator's key.
238
+ */
239
+ const HEAD_LINE = 0x636f6668;
240
+
158
241
  const lockedHead = prepared((db) => {
159
242
  const { auditChainHead } = tablesOf(db);
160
243
  return forUpdate(
@@ -180,7 +263,20 @@ export async function appendEntries(
180
263
  * nothing to read again.
181
264
  */
182
265
  locked?: HeldHead,
183
- ): Promise<Appended> {
266
+ /**
267
+ * 0.15's changes of the app's write these entries record (`appChanges`):
268
+ * on Postgres, made in the append's own statement, one round trip less
269
+ * under the head, which every sign-in waits for (T123). Their numbers
270
+ * come back as `changes`.
271
+ */
272
+ changes: readonly PreparedChange[] = [],
273
+ /**
274
+ * Whether the writer runs under 0.15's log: its entries carry `log015`,
275
+ * which no MAC covers and 0.14's code never sets. Once the log is cut
276
+ * over, migration 0047's trigger refuses a write's entry without it.
277
+ */
278
+ log015 = false,
279
+ ): Promise<Appended & { changes: (bigint | null)[] }> {
184
280
  if (entries.length === 0) throw new Error('appendEntries called with no entries');
185
281
  const { auditChainHead, auditLog } = tablesOf(tx);
186
282
 
@@ -208,7 +304,7 @@ export async function appendEntries(
208
304
 
209
305
  let seq = head.nextSeq;
210
306
  let prevHash = head.headHash;
211
- const rows: (typeof auditLog.$inferInsert)[] = [];
307
+ const rows: (Omit<typeof auditLog.$inferInsert, 'log015'> & { log015?: SQL })[] = [];
212
308
  for (const entry of entries) {
213
309
  const fields: LogFields = {
214
310
  seq,
@@ -231,22 +327,31 @@ export async function appendEntries(
231
327
  metadata: entry.metadata ?? '{}',
232
328
  };
233
329
  const { mac, hash } = sealEntry(key, prevHash, fields);
234
- rows.push({ ...fields, prevHash, mac, hash });
330
+ // A literal, not a parameter: a row's 21 parameters are what `ROWS_AT_ONCE` counts (review r5).
331
+ rows.push({ ...fields, prevHash, mac, hash, ...(log015 ? { log015: sql`true` } : {}) });
235
332
  prevHash = hash;
236
333
  seq += 1n;
237
334
  }
238
335
 
239
- if (engineOf(tx) === 'postgres' && rows.length <= ROWS_AT_ONCE) {
336
+ let numbers: (bigint | null)[] = [];
337
+ if (engineOf(tx) === 'postgres' && rows.length <= ROWS_AT_ONCE && changes.length > 0) {
338
+ // The entries, the head that names the last of them, and the changes, in one statement. The changes run in the
339
+ // select list, in their order; the inserts after it, and their trigger (the fence, for an entry that changes
340
+ // access) at its end: each takes the issue lock before the fence, so either order is the locks' (migration 0026).
341
+ numbers = await callChanges(tx, changes, sql`WITH appended AS (${tx.insert(auditLog).values(rows).getSQL()}),
342
+ moved AS (UPDATE audit_chain_head SET next_seq = ${seq}, head_hash = ${prevHash} WHERE only_row) `);
343
+ } else if (engineOf(tx) === 'postgres' && rows.length <= ROWS_AT_ONCE) {
240
344
  // The entries and the head that names the last of them, in one statement.
241
345
  await tx.execute(sql`WITH appended AS (${tx.insert(auditLog).values(rows).getSQL()})
242
346
  UPDATE audit_chain_head SET next_seq = ${seq}, head_hash = ${prevHash} WHERE only_row`);
243
347
  } else {
244
348
  await tx.insert(auditLog).values(rows);
245
349
  await tx.update(auditChainHead).set({ nextSeq: seq, headHash: prevHash }).where(eq(auditChainHead.onlyRow, true));
350
+ numbers = await appChanges(tx, changes);
246
351
  }
247
352
  const written = { nextSeq: seq, headHash: prevHash };
248
353
  afterCommit(tx, () => remember(key, written));
249
- return { seqStart: head.nextSeq, ...written, occurredAt: now };
354
+ return { seqStart: head.nextSeq, ...written, occurredAt: now, changes: numbers };
250
355
  }
251
356
 
252
357
  /** A head locked by a statement of the caller's, and what it read under the lock (`appendEntries`). */
@@ -270,7 +375,7 @@ async function readUnderHead(tx: Transaction, key: LogKey): Promise<HeldHead> {
270
375
  };
271
376
  }
272
377
 
273
- /** Entries one statement inserts at most, at 21 parameters each: Postgres takes 65,535. */
378
+ /** Entries one statement inserts at most, at 21 parameters each (`log015` a literal): Postgres takes 65,535. */
274
379
  const ROWS_AT_ONCE = 3000;
275
380
 
276
381
  /**
@@ -479,3 +584,131 @@ export async function newestAuthentic(page: (limit: number, before: bigint | und
479
584
  }
480
585
  return 'unreached';
481
586
  }
587
+
588
+ // --- 0.15's log: the app's changes -------------------------------------------
589
+
590
+ /**
591
+ * The app's changes to the objects its rows hold, as the vault sealed them
592
+ * (`Vault.prepare`), each `app_change` (migrations 0025, 0026 and 0030), in its
593
+ * transaction, once its rows and 0.14 entries are written: in order, in one
594
+ * round trip, or in the append's own (`appendEntries`). Each entry's number;
595
+ * 0 for a row of 0.14's, which logs nothing; null where a row's seal moved
596
+ * since the app read it, and nothing was done (ChangeCAS: read again). A row
597
+ * that is not its object's newest change's fails the call (`isStaleChange`),
598
+ * and its transaction with it.
599
+ *
600
+ * On SQLite, which has no functions, the same steps in the transaction,
601
+ * which holds the database's one write lock: the fence is implicit there
602
+ * (0.15's plan, "Postgres serves; SQLite is for tests").
603
+ */
604
+ export async function appChanges(tx: Transaction, calls: readonly PreparedChange[]): Promise<(bigint | null)[]> {
605
+ if (calls.length === 0) return [];
606
+ if (engineOf(tx) === 'sqlite') {
607
+ const numbers: (bigint | null)[] = [];
608
+ for (const call of calls) numbers.push(await changeOnSqlite(tx, call, bytes));
609
+ return numbers;
610
+ }
611
+ return callChanges(tx, calls, sql``);
612
+ }
613
+
614
+ const bytes = (b64: string | null) => (b64 === null ? null : Buffer.from(b64, 'base64'));
615
+
616
+ /** `app_change`, once per call, in order, in one statement: after `before`, a `WITH` of the caller's. */
617
+ async function callChanges(tx: Transaction, calls: readonly PreparedChange[], before: SQL): Promise<(bigint | null)[]> {
618
+ const rows = sql.join(calls.map((c, i) => sql`(${i}::int, ${c.kind}::text, ${c.id}::text, ${c.requestId}::text, ${c.at}::bigint, ${c.actor}::text, ${c.payload}::text, ${bytes(c.sealFrom)}::bytea, ${bytes(c.seal)}::bytea, ${bytes(c.mac)}::bytea, ${bytes(c.restores)}::bytea)`), sql`, `);
619
+ // A volatile function in the select list runs after the sort: each call in `ord`'s order, each from the seal the one before leaves.
620
+ const numbers = await rowsOf<{ ord: number; number: string | number | null }>(tx, sql`${before}
621
+ SELECT c.ord, app_change(c.kind, c.id, c.request, c.at, c.actor, c.payload, c.seal_from, c.seal, c.mac, c.restores) AS number
622
+ FROM (VALUES ${rows}) AS c(ord, kind, id, request, at, actor, payload, seal_from, seal, mac, restores)
623
+ ORDER BY c.ord`);
624
+ if (numbers.some(({ ord }, i) => Number(ord) !== i)) throw new Error('app_change answered out of order');
625
+ return numbers.map(({ number }) => (number === null ? null : BigInt(number)));
626
+ }
627
+
628
+ /**
629
+ * An object's key in 0.15's log, `log_changes.object`: the first 16 bytes
630
+ * of SHA-256 over its kind and id, as a uuid (`log_object` in SQL). The
631
+ * vault's (`objectKey`), and the app's, which finds a credential's sign-in
632
+ * by it.
633
+ */
634
+ export function logObject(kind: string, id: string): string {
635
+ const hex = createHash('sha256').update(`coffre.object.v1\0${kind}\0${id}`).digest('hex');
636
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
637
+ }
638
+
639
+ /** Whether `error` is `app_change`'s refusal of a row that is not its object's newest change's: Postgres's `data_corrupted`. */
640
+ export function isStaleChange(error: unknown): boolean {
641
+ for (let cause = error; typeof cause === 'object' && cause !== null; cause = (cause as { cause?: unknown }).cause) {
642
+ if (cause instanceof StaleRow || (cause as { code?: unknown }).code === 'XX001') return true;
643
+ }
644
+ return false;
645
+ }
646
+
647
+ class StaleRow extends Error {}
648
+
649
+ const APP_OPS: Record<PreparedChange['kind'], readonly string[]> = {
650
+ credential: ['credential.issue', 'credential.revoke', 'credential.renew', 'credential.scope'],
651
+ connection: ['connection.make', 'connection.revoke'],
652
+ secret: ['secret.write', 'secret.delete'],
653
+ environment: ['environment.create', 'environment.rename', 'environment.archive', 'project.archive', 'environment.delete'],
654
+ };
655
+
656
+ /** `app_change` on SQLite. */
657
+ async function changeOnSqlite(tx: Transaction, call: PreparedChange, bytes: (b64: string | null) => Buffer | null): Promise<bigint | null> {
658
+ const { logChanges, logUses } = tablesOf(tx);
659
+ const table = appTable(tx, call.kind);
660
+ const [row] = await tx.select({ seal: table.seal }).from(table).where(eq(table.id, call.id));
661
+ const held = row?.seal ?? null;
662
+ const [newest] = await tx.select({ seal: logChanges.seal }).from(logChanges).where(eq(logChanges.object, call.object)).orderBy(desc(logChanges.number)).limit(1);
663
+ if (call.requestId === null) {
664
+ if (held !== null || newest !== undefined) throw new StaleRow(`the ${call.kind} object's row is not its newest change's: stale`);
665
+ return 0n;
666
+ }
667
+ const seal = bytes(call.seal);
668
+ const [prior] = await tx.select({ number: logChanges.number, object: logChanges.object, payload: logChanges.payload, seal: logChanges.seal }).from(logChanges).where(eq(logChanges.requestId, call.requestId));
669
+ if (prior !== undefined) {
670
+ if (prior.object !== call.object || prior.payload !== call.payload || !sameBytes(prior.seal ?? null, seal)) {
671
+ throw Object.assign(new Error(`request ${call.requestId} names another change`), { code: 'SQLITE_CONSTRAINT_UNIQUE' });
672
+ }
673
+ return BigInt(prior.number);
674
+ }
675
+ const payload = JSON.parse(call.payload ?? 'null') as { op?: unknown; ref?: { kind?: unknown; id?: unknown } } | null;
676
+ if (typeof payload?.op !== 'string' || !(APP_OPS[call.kind] ?? []).includes(payload.op)) throw new Error(`the app makes no change ${String(payload?.op)} to a ${call.kind}`);
677
+ if (payload.ref?.kind !== call.kind || payload.ref.id !== call.id) throw new Error(`a change names the object it is to: ${call.kind}, ${call.id}`);
678
+ // ChangeChecksSeal.
679
+ if (!sameBytes(held, newest?.seal ?? null)) throw new StaleRow(`the ${call.kind} object's row is not its newest change's: stale`);
680
+ // RestorePinned: an unarchive restores the object its key's log last sealed, still so (0030).
681
+ const restores = bytes(call.restores);
682
+ if (restores !== null) {
683
+ if (call.kind !== 'secret' || payload.op !== 'secret.write' || call.sealFrom !== null) throw new Error("only a key's object made again from none restores one");
684
+ const [kept] = await tx.select({ seal: logChanges.seal }).from(logChanges).where(and(eq(logChanges.object, call.object), isNotNull(logChanges.seal))).orderBy(desc(logChanges.number)).limit(1);
685
+ if (!sameBytes(restores, kept?.seal ?? null)) throw new StaleRow('the secret object an unarchive restores is not its newest sealed one: stale');
686
+ }
687
+ // ChangeCAS.
688
+ const from = bytes(call.sealFrom);
689
+ const done = changedRows(await tx.update(table).set({ seal }).where(and(eq(table.id, call.id), from === null ? isNull(table.seal) : eq(table.seal, from))));
690
+ if (done === 0) return null;
691
+ // One sequence for both tables, as `log_number` is on Postgres.
692
+ const [{ last }] = await tx.select({ last: sql<number | string | null>`max(n)` }).from(
693
+ sql`(SELECT max(${logChanges.number}) AS n FROM ${logChanges} UNION ALL SELECT max(${logUses.number}) FROM ${logUses})`,
694
+ );
695
+ const number = BigInt(last ?? 0) + 1n;
696
+ await tx.insert(logChanges).values({ number, at: call.at, requestId: call.requestId, object: call.object, actor: call.actor, payload: call.payload!, seal, mac: bytes(call.mac)! });
697
+ return number;
698
+ }
699
+
700
+ /** The table whose rows hold an object of this kind, the app's. */
701
+ function appTable(tx: Transaction, kind: PreparedChange['kind']) {
702
+ const { credentials, mcpConnections, secrets, environments } = tablesOf(tx);
703
+ switch (kind) {
704
+ case 'credential': return credentials;
705
+ case 'connection': return mcpConnections;
706
+ case 'secret': return secrets;
707
+ case 'environment': return environments;
708
+ default: throw new Error(`no object kind ${String(kind)}`);
709
+ }
710
+ }
711
+
712
+ function sameBytes(a: Buffer | null, b: Buffer | null): boolean {
713
+ return a === null || b === null ? a === b : a.equals(b);
714
+ }
package/src/migrate.ts CHANGED
@@ -1,11 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { fileURLToPath } from 'node:url';
4
- import type { PoolClient } from 'pg';
5
-
6
4
  import { engineOfUrl } from './connect.ts';
7
5
  import type { Engine } from './dialect.ts';
8
- import { postgresConnection } from './postgres.ts';
6
+ import { postgresSession, quoteIdentifier, type PostgresClient } from './postgres.ts';
9
7
 
10
8
  /**
11
9
  * Bring a database up to date with its engine's migration tree:
@@ -20,20 +18,151 @@ import { postgresConnection } from './postgres.ts';
20
18
 
21
19
  const MIGRATION_LOCK_KEY = '7165058122361679213';
22
20
 
23
- /** Reassert database privileges that pg_dump and CREATE DATABASE TEMPLATE do not carry. */
24
- export async function restrictDatabase(client: Pick<PoolClient, 'query'>): Promise<void> {
25
- await client.query(`DO $$ BEGIN
21
+ /** The logins that run coffre's own statements: the app's, the vault's, and 0.15's hot path's. */
22
+ const RUNTIME_LOGINS = ['coffre_runtime', 'coffre_vault_runtime', 'coffre_vault_hot'];
23
+
24
+ /**
25
+ * Reassert database privileges that pg_dump and CREATE DATABASE TEMPLATE do not carry, the hot path's login's
26
+ * group and settings, and the runtime logins' `jit = off`.
27
+ */
28
+ export async function restrictDatabase(client: Pick<PostgresClient, 'unsafe'>): Promise<void> {
29
+ await client.unsafe(`DO $$ BEGIN
26
30
  EXECUTE format(
27
- 'REVOKE CREATE, TEMPORARY ON DATABASE %I FROM PUBLIC, coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime, coffre_backup',
31
+ 'REVOKE CREATE, TEMPORARY ON DATABASE %I FROM PUBLIC, coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime, coffre_vault_hot, coffre_backup',
28
32
  current_database()
29
33
  );
30
34
  END $$`);
35
+ await holdHotLogin(client);
36
+ await turnJitOff(client, RUNTIME_LOGINS);
37
+ }
38
+
39
+ /**
40
+ * What 0022 gives `coffre_vault_hot`, given again when it lacks it: its
41
+ * membership in coffre_vault, inherited, and its lock and statement
42
+ * timeouts and generic plans. A role dropped and made again, as `coffre
43
+ * setup` makes a missing one, has none of them, and 0022 never runs
44
+ * twice. So whatever 0022 refuses, this refuses too: a role made again
45
+ * may be anyone's. Unsafe attributes, objects it owns, a membership in
46
+ * another role, one of any grantor that may set or grant coffre_vault
47
+ * (which would let it hand the vault's rights to the app's login), and a
48
+ * setting of its own for this database alone, which would outrank the
49
+ * server-wide one at login. Then only what differs, and refused, saying
50
+ * what to run, to a login that may not change the role or grant the group.
51
+ */
52
+ export async function holdHotLogin(client: Pick<PostgresClient, 'unsafe'>): Promise<void> {
53
+ await client.unsafe(`DO $$ DECLARE
54
+ held text[];
55
+ BEGIN
56
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'coffre_vault_hot') THEN
57
+ RAISE EXCEPTION 'coffre_vault_hot does not exist, though 0022 made it: run coffre setup, which makes it a login, or have a login with CREATEROLE run CREATE ROLE coffre_vault_hot NOLOGIN, then migrate again';
58
+ END IF;
59
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'coffre_vault_hot' AND (rolsuper OR rolreplication OR rolbypassrls)) THEN
60
+ RAISE EXCEPTION 'coffre_vault_hot has unsafe role attributes: SUPERUSER, REPLICATION and BYPASSRLS must be off';
61
+ END IF;
62
+ IF EXISTS (SELECT 1 FROM pg_shdepend WHERE refclassid = 'pg_authid'::regclass AND refobjid = 'coffre_vault_hot'::regrole AND deptype = 'o') THEN
63
+ RAISE EXCEPTION 'coffre_vault_hot owns database objects';
64
+ END IF;
65
+ IF EXISTS (SELECT 1 FROM pg_auth_members WHERE member = 'coffre_vault_hot'::regrole AND roleid <> 'coffre_vault'::regrole) THEN
66
+ RAISE EXCEPTION 'coffre_vault_hot is a member of a role other than coffre_vault: revoke its memberships';
67
+ END IF;
68
+ IF EXISTS (
69
+ SELECT 1 FROM pg_auth_members WHERE member = 'coffre_vault_hot'::regrole AND roleid = 'coffre_vault'::regrole AND (set_option OR admin_option)
70
+ ) THEN
71
+ RAISE EXCEPTION 'coffre_vault_hot may set or grant coffre_vault: have its grantor run REVOKE coffre_vault FROM coffre_vault_hot, then migrate again';
72
+ END IF;
73
+ IF EXISTS (
74
+ SELECT 1 FROM pg_db_role_setting s, unnest(s.setconfig) AS setting
75
+ WHERE s.setrole = 'coffre_vault_hot'::regrole AND s.setdatabase = (SELECT oid FROM pg_database WHERE datname = current_database())
76
+ AND split_part(setting, '=', 1) IN ('lock_timeout', 'statement_timeout', 'plan_cache_mode')
77
+ ) THEN
78
+ RAISE EXCEPTION 'coffre_vault_hot has its own lock_timeout, statement_timeout or plan_cache_mode in this database, over the hot path''s: run ALTER ROLE coffre_vault_hot IN DATABASE % RESET ALL, then migrate again', quote_ident(current_database());
79
+ END IF;
80
+ -- The role is the server's, as turnJitOff's logins are: a run on another database may be changing it too, and fails the second change.
81
+ FOR attempt IN 1..5 LOOP
82
+ BEGIN
83
+ SELECT setconfig INTO held FROM pg_db_role_setting WHERE setrole = 'coffre_vault_hot'::regrole AND setdatabase = 0;
84
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'coffre_vault_hot' AND (NOT rolinherit OR rolcreatedb OR rolcreaterole)) THEN
85
+ ALTER ROLE coffre_vault_hot INHERIT NOCREATEDB NOCREATEROLE;
86
+ END IF;
87
+ IF NOT coalesce(held @> ARRAY['lock_timeout=1s'], false) THEN ALTER ROLE coffre_vault_hot SET lock_timeout = '1s'; END IF;
88
+ IF NOT coalesce(held @> ARRAY['statement_timeout=2s'], false) THEN ALTER ROLE coffre_vault_hot SET statement_timeout = '2s'; END IF;
89
+ IF NOT coalesce(held @> ARRAY['plan_cache_mode=force_generic_plan'], false) THEN ALTER ROLE coffre_vault_hot SET plan_cache_mode = force_generic_plan; END IF;
90
+ IF NOT EXISTS (
91
+ SELECT 1 FROM pg_auth_members WHERE member = 'coffre_vault_hot'::regrole AND roleid = 'coffre_vault'::regrole AND inherit_option
92
+ ) THEN
93
+ GRANT coffre_vault TO coffre_vault_hot WITH ADMIN FALSE, INHERIT TRUE, SET FALSE;
94
+ END IF;
95
+ EXIT;
96
+ EXCEPTION
97
+ WHEN insufficient_privilege THEN
98
+ RAISE EXCEPTION '% may not make coffre_vault_hot what 0.15 needs (%): have a login with CREATEROLE and ADMIN on both roles run ALTER ROLE coffre_vault_hot INHERIT NOCREATEDB NOCREATEROLE; ALTER ROLE coffre_vault_hot SET lock_timeout = ''1s''; ALTER ROLE coffre_vault_hot SET statement_timeout = ''2s''; ALTER ROLE coffre_vault_hot SET plan_cache_mode = force_generic_plan; GRANT coffre_vault TO coffre_vault_hot WITH ADMIN FALSE, INHERIT TRUE, SET FALSE first', current_user, SQLERRM;
99
+ WHEN internal_error OR unique_violation THEN
100
+ IF attempt = 5 THEN RAISE; END IF;
101
+ PERFORM pg_sleep(0.2);
102
+ END;
103
+ END LOOP;
104
+ END $$`);
105
+ }
106
+
107
+ /**
108
+ * Set `jit = off` on `logins`, when they lack it.
109
+ *
110
+ * Past `jit_above_cost` (100,000 by default) Postgres compiles a plan's
111
+ * expressions at every execution of it: 150 ms where a use costs under 2 ms
112
+ * (0029), and a statement's estimate grows with its tables. The functions that
113
+ * run as their owner say no JIT themselves (0029); the setting here covers
114
+ * every other statement of these logins, now and to come. A role's settings
115
+ * apply to the role that logs in, never through a group, and a pooler such as
116
+ * Hyperdrive may not pass a startup option on, so it is the login's own, on
117
+ * the server. It is the server's, not the database's, as the logins are (and
118
+ * as 0022 sets the hot login's): made once by a login with CREATEROLE and
119
+ * ADMIN on them, and found set by any other that migrates after.
120
+ *
121
+ * Only when it differs, whatever way the boolean is spelled: altering a role
122
+ * takes CREATEROLE and its ADMIN, which a login that only migrates may lack.
123
+ * The row is the server's, and the migration lock each database's, so a run
124
+ * on another database may be changing it too: Postgres fails the second
125
+ * change (a concurrent update, or a duplicate key), and it looks again once
126
+ * the first has committed. A setting of the login's for this database alone
127
+ * outranks the server-wide one at login, so it is refused, as 0022 does.
128
+ */
129
+ export async function turnJitOff(client: Pick<PostgresClient, 'unsafe'>, logins: readonly string[]): Promise<void> {
130
+ // Whether the login has a jit setting, in `database` (0 for the server's), that is `value`; the cast is only of a jit row's.
131
+ const jit = (database: string, value: 'TRUE' | 'FALSE') => `EXISTS (
132
+ SELECT 1 FROM pg_db_role_setting s, unnest(s.setconfig) AS setting
133
+ WHERE s.setrole = login::regrole AND s.setdatabase = ${database}
134
+ AND (CASE WHEN split_part(setting, '=', 1) = 'jit' THEN substr(setting, 5)::boolean END) IS ${value})`;
135
+ await client.unsafe(
136
+ `DO $$ DECLARE
137
+ login text;
138
+ here oid := (SELECT oid FROM pg_database WHERE datname = current_database());
139
+ BEGIN
140
+ FOREACH login IN ARRAY ARRAY[${logins.map((login) => `'${login}'`).join(', ')}] LOOP
141
+ IF ${jit('here', 'TRUE')} THEN
142
+ RAISE EXCEPTION '% has its own jit setting in this database, over the server-wide one: run ALTER ROLE % IN DATABASE % RESET jit, then migrate again', login, login, quote_ident(current_database());
143
+ END IF;
144
+ FOR attempt IN 1..5 LOOP
145
+ EXIT WHEN ${jit('0', 'FALSE')};
146
+ BEGIN
147
+ EXECUTE format('ALTER ROLE %I SET jit = off', login);
148
+ EXIT;
149
+ EXCEPTION
150
+ WHEN insufficient_privilege THEN
151
+ RAISE EXCEPTION '% may not set jit = off on %, whose statements Postgres would compile at every run: have a login with CREATEROLE and ADMIN on it run ALTER ROLE % SET jit = off, then migrate again', current_user, login, login;
152
+ WHEN internal_error OR unique_violation THEN
153
+ IF attempt = 5 THEN RAISE; END IF;
154
+ PERFORM pg_sleep(0.2);
155
+ END;
156
+ END LOOP;
157
+ END LOOP;
158
+ END $$`,
159
+ );
31
160
  }
32
161
 
33
162
  /** Why `login` cannot be the backup login, before anything runs, or null when it may. */
34
163
  export function backupLoginProblem(login: string): string | null {
35
164
  if (login === '') return 'the backup login is empty: name the role to grant coffre_backup';
36
- if (['coffre_backup', 'coffre_app', 'coffre_vault', 'coffre_runtime', 'coffre_vault_runtime'].includes(login)) {
165
+ if (['coffre_backup', 'coffre_app', 'coffre_vault', 'coffre_runtime', 'coffre_vault_runtime', 'coffre_vault_hot'].includes(login)) {
37
166
  return `the backup login is ${login}, one of coffre's own roles: name the login that backs up`;
38
167
  }
39
168
  return null;
@@ -259,50 +388,48 @@ function verifyHistory(
259
388
  }
260
389
 
261
390
  async function postgresMigrator(url: string): Promise<Migrator> {
262
- const [{ default: pg }, { drizzle }, { migrate }] = await Promise.all([
263
- import('pg'),
264
- import('drizzle-orm/node-postgres'),
265
- import('drizzle-orm/node-postgres/migrator'),
391
+ const [{ createDatabase }, { migrate }] = await Promise.all([
392
+ import('./database.ts'),
393
+ import('drizzle-orm/postgres-js/migrator'),
266
394
  ]);
267
- const pool = new pg.Pool({ ...postgresConnection(url), application_name: 'coffre-migrations', max: 1 });
268
- const client = await pool.connect();
395
+ // One session: the advisory lock and lock_timeout are the session's, and a statement after it is lost fails.
396
+ const client = postgresSession(url, { application_name: 'coffre-migrations' });
269
397
  return {
270
398
  async applied() {
271
- const relation = await client.query<{ relation: string | null }>(
399
+ const [found] = await client.unsafe<{ relation: string | null }[]>(
272
400
  "SELECT to_regclass('drizzle.__drizzle_migrations')::text AS relation",
273
401
  );
274
- if (!relation.rows[0]?.relation) return null;
275
- const result = await client.query<AppliedMigration>(
402
+ if (!found?.relation) return null;
403
+ return [...await client.unsafe<AppliedMigration[]>(
276
404
  'SELECT hash, created_at::text FROM drizzle.__drizzle_migrations ORDER BY created_at, id',
277
- );
278
- return result.rows;
405
+ )];
279
406
  },
280
407
  async lock() {
281
- await client.query("SET lock_timeout = '5min'");
282
- await client.query('SELECT pg_advisory_lock($1::bigint)', [MIGRATION_LOCK_KEY]);
408
+ await client.unsafe("SET lock_timeout = '5min'");
409
+ await client.unsafe('SELECT pg_advisory_lock($1::bigint)', [MIGRATION_LOCK_KEY]);
283
410
  },
284
411
  async unlock() {
285
- await client.query('SELECT pg_advisory_unlock($1::bigint)', [MIGRATION_LOCK_KEY]);
412
+ await client.unsafe('SELECT pg_advisory_unlock($1::bigint)', [MIGRATION_LOCK_KEY]);
286
413
  },
287
- migrate: (migrationsFolder) => migrate(drizzle(client), { migrationsFolder }),
414
+ // The migrator uses the database's dialect and session alone, never its relational config.
415
+ migrate: (migrationsFolder) => migrate(createDatabase(client) as unknown as Parameters<typeof migrate>[0], { migrationsFolder }),
288
416
  async restrict(backupLogin) {
289
417
  await restrictDatabase(client);
290
418
  if (backupLogin !== undefined) {
291
419
  // Compared as text: as a name, Postgres would cut it to 63 bytes, and grant whichever role bears the cut.
292
- const { rowCount } = await client.query('SELECT 1 FROM pg_roles WHERE rolname::text = $1', [backupLogin]);
293
- if (rowCount === 0) throw new Error(`role ${client.escapeIdentifier(backupLogin)} does not exist: make it, coffre never does`);
294
- await client.query(`GRANT coffre_backup TO ${client.escapeIdentifier(backupLogin)} WITH ADMIN FALSE, INHERIT TRUE, SET FALSE`);
420
+ const found = await client.unsafe('SELECT 1 FROM pg_roles WHERE rolname::text = $1', [backupLogin]);
421
+ if (found.length === 0) throw new Error(`role ${quoteIdentifier(backupLogin)} does not exist: make it, coffre never does`);
422
+ await client.unsafe(`GRANT coffre_backup TO ${quoteIdentifier(backupLogin)} WITH ADMIN FALSE, INHERIT TRUE, SET FALSE`);
295
423
  }
296
424
  },
297
425
  async notOwner({ relations, functions }) {
298
- const { rows } = await client.query<{ owner: string; member: boolean; login: string; grants_inherit: boolean; inherits: boolean }>(FOREIGN_OWNERS, [relations, functions]);
426
+ const rows = await client.unsafe<{ owner: string; member: boolean; login: string; grants_inherit: boolean; inherits: boolean }[]>(FOREIGN_OWNERS, [relations, functions]);
299
427
  if (rows.length === 0) return null;
300
- const [{ login, grants_inherit: grantsInherit, inherits }] = rows as [(typeof rows)[number]];
428
+ const [{ login, grants_inherit: grantsInherit, inherits }] = rows as unknown as [(typeof rows)[number]];
301
429
  return new NotOwner(login, rows.map(({ owner, member }) => ({ name: owner, member })), { grantsInherit, inherits });
302
430
  },
303
431
  async close() {
304
- client.release();
305
- await pool.end();
432
+ await client.end();
306
433
  },
307
434
  };
308
435
  }
@@ -0,0 +1,19 @@
1
+ -- Releases pruned (W199, T111): the vault's checkpoint forgets the releases no window can count again, those over a day and an hour old by the database's clock, at most at_most a call. A bulk limit's window is a day at most (the vault's config), and the hour is a margin for the clock stepping back: no caller chooses what is kept, so the vault's login, which still deletes no row itself, cannot forget a release a window counts. The release before never calls it.
2
+ CREATE FUNCTION prune_vault_releases(at_most integer) RETURNS integer
3
+ LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, pg_temp SET lock_timeout = '3s' AS $$
4
+ -- The bound in the DELETE's own WHERE too: a row a read moved on since the subquery's snapshot is checked again,
5
+ -- as it is now, and kept.
6
+ WITH bound AS (SELECT (extract(epoch from clock_timestamp()) * 1000)::bigint - 25 * 3600000 AS before),
7
+ pruned AS (
8
+ DELETE FROM public.vault_releases
9
+ WHERE released_at < (SELECT before FROM bound)
10
+ AND ctid IN (
11
+ -- Any of them, each past every window; a row an append holds is left to the next run, rather than waited for.
12
+ SELECT ctid FROM public.vault_releases WHERE released_at < (SELECT before FROM bound) LIMIT at_most FOR UPDATE SKIP LOCKED
13
+ )
14
+ RETURNING 1
15
+ )
16
+ SELECT count(*)::integer FROM pruned;
17
+ $$;--> statement-breakpoint
18
+ REVOKE ALL ON FUNCTION prune_vault_releases(integer) FROM PUBLIC;--> statement-breakpoint
19
+ GRANT EXECUTE ON FUNCTION prune_vault_releases(integer) TO coffre_vault;