@alteran/astro 0.7.6 → 0.8.1

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 (75) hide show
  1. package/README.md +25 -25
  2. package/migrations/0010_eminent_klaw.sql +37 -0
  3. package/migrations/0011_chief_darwin.sql +31 -0
  4. package/migrations/0012_backfill_blob_usage.sql +39 -0
  5. package/migrations/meta/0010_snapshot.json +790 -0
  6. package/migrations/meta/0011_snapshot.json +813 -0
  7. package/migrations/meta/_journal.json +22 -1
  8. package/package.json +24 -41
  9. package/src/db/blob.ts +323 -0
  10. package/src/db/dal.ts +224 -78
  11. package/src/db/repo.ts +205 -25
  12. package/src/db/schema.ts +14 -5
  13. package/src/handlers/debug.ts +4 -3
  14. package/src/lib/appview/auth-policy.ts +7 -24
  15. package/src/lib/appview/proxy.ts +56 -23
  16. package/src/lib/appview/types.ts +1 -6
  17. package/src/lib/auth-scope.ts +399 -0
  18. package/src/lib/auth.ts +40 -39
  19. package/src/lib/commit.ts +37 -15
  20. package/src/lib/did-document.ts +4 -5
  21. package/src/lib/jwt.ts +3 -1
  22. package/src/lib/mime.ts +9 -0
  23. package/src/lib/oauth/observability.ts +53 -12
  24. package/src/lib/oauth/resource.ts +49 -0
  25. package/src/lib/preference-policy.ts +45 -0
  26. package/src/lib/preferences.ts +0 -4
  27. package/src/lib/public-host.ts +127 -0
  28. package/src/lib/ratelimit.ts +37 -12
  29. package/src/lib/relay.ts +7 -27
  30. package/src/lib/repo-write-blob-constraints.ts +141 -0
  31. package/src/lib/repo-write-data.ts +195 -0
  32. package/src/lib/repo-write-error.ts +46 -0
  33. package/src/lib/repo-write-validation.ts +463 -0
  34. package/src/lib/session-tokens.ts +22 -5
  35. package/src/lib/unsupported-routes.ts +32 -0
  36. package/src/lib/util.ts +57 -2
  37. package/src/pages/.well-known/atproto-did.ts +15 -3
  38. package/src/pages/.well-known/did.json.ts +13 -7
  39. package/src/pages/debug/db/bootstrap.ts +4 -3
  40. package/src/pages/debug/gc/blobs.ts +11 -8
  41. package/src/pages/debug/record.ts +11 -0
  42. package/src/pages/oauth/token.ts +78 -33
  43. package/src/pages/xrpc/[...nsid].ts +17 -9
  44. package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +9 -3
  45. package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +17 -4
  46. package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +4 -2
  47. package/src/pages/xrpc/chat.bsky.convo.getLog.ts +4 -2
  48. package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +4 -2
  49. package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +10 -6
  50. package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +4 -3
  51. package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +13 -5
  52. package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +4 -2
  53. package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +4 -2
  54. package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +12 -36
  55. package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +90 -139
  56. package/src/pages/xrpc/com.atproto.repo.createRecord.ts +74 -47
  57. package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +119 -46
  58. package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +21 -20
  59. package/src/pages/xrpc/com.atproto.repo.getRecord.ts +6 -1
  60. package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +4 -2
  61. package/src/pages/xrpc/com.atproto.repo.putRecord.ts +84 -47
  62. package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +199 -78
  63. package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +4 -2
  64. package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +88 -21
  65. package/src/pages/xrpc/com.atproto.server.getSession.ts +3 -13
  66. package/src/pages/xrpc/com.atproto.sync.getBlob.ts +92 -74
  67. package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +45 -23
  68. package/src/services/car.ts +13 -0
  69. package/src/services/repo/apply-prepared-writes.ts +185 -0
  70. package/src/services/repo/blob-refs.ts +48 -0
  71. package/src/services/repo/blockstore-ops.ts +59 -17
  72. package/src/services/repo/list-blobs.ts +43 -0
  73. package/src/services/repo-manager.ts +221 -78
  74. package/src/worker/runtime.ts +1 -1
  75. package/src/worker/sequencer/upgrade.ts +4 -1
package/src/db/dal.ts CHANGED
@@ -1,22 +1,35 @@
1
+ import type { D1PreparedStatement } from '@cloudflare/workers-types';
1
2
  import { getDb } from './client';
2
- import { record, type NewRecordRow, blob_ref, blob_usage, blob_quota } from './schema';
3
+ import { record, type NewRecordRow } from './schema';
3
4
  import type { Env } from '../env';
4
- import { eq, inArray, and, sql } from 'drizzle-orm';
5
+ import { eq, inArray } from 'drizzle-orm';
5
6
  import { type AccountState, toRow, fromRow } from '../lib/account-state';
6
7
 
8
+ export {
9
+ blobKeyHasUsage,
10
+ checkBlobQuota,
11
+ deleteBlobByKey,
12
+ deleteUnreferencedBlobKeys,
13
+ deleteUnreferencedBlobRef,
14
+ getBlobQuota,
15
+ getRecordBlobKeys,
16
+ listOrphanBlobKeys,
17
+ listOrphanBlobRefs,
18
+ putBlobRef,
19
+ registerBlobRefWithQuota,
20
+ sweepEligibleUnreferencedBlobKeys,
21
+ updateBlobQuota,
22
+ } from './blob';
23
+ export type { BlobKeyRef, BlobRefMetadata, BlobRegistrationResult } from './blob';
24
+
25
+ export type CommitGuard = {
26
+ did: string;
27
+ commitCid: string;
28
+ rev: string;
29
+ };
30
+
7
31
  export async function putRecord(env: Env, row: NewRecordRow) {
8
- const db = getDb(env);
9
- const toInsert: NewRecordRow = {
10
- ...row,
11
- createdAt: row.createdAt ?? Date.now(),
12
- };
13
- await db.insert(record).values(toInsert).onConflictDoUpdate({
14
- target: record.uri,
15
- set: {
16
- cid: sql.raw(`excluded.${record.cid.name}`),
17
- json: sql.raw(`excluded.${record.json.name}`)
18
- }
19
- });
32
+ await env.ALTERAN_DB.batch(putRecordStatements(env, row));
20
33
  }
21
34
 
22
35
  export async function getRecord(env: Env, uri: string) {
@@ -26,8 +39,7 @@ export async function getRecord(env: Env, uri: string) {
26
39
  }
27
40
 
28
41
  export async function deleteRecord(env: Env, uri: string) {
29
- const db = getDb(env);
30
- await db.delete(record).where(eq(record.uri, uri)).run();
42
+ await env.ALTERAN_DB.batch(deleteRecordStatements(env, uri));
31
43
  }
32
44
 
33
45
  export async function listRecords(env: Env) {
@@ -41,82 +53,216 @@ export async function getRecordsByCids(env: Env, cids: string[]) {
41
53
  return db.select().from(record).where(inArray(record.cid, cids)).all();
42
54
  }
43
55
 
44
- export async function putBlobRef(env: Env, did: string, cid: string, key: string, mime: string, size: number) {
45
- const db = getDb(env);
46
- await db
47
- .insert(blob_ref)
48
- .values({ did, cid, key, mime, size })
49
- .onConflictDoUpdate({
50
- target: blob_ref.cid,
51
- set: {
52
- did: sql.raw(`excluded.${blob_ref.did.name}`),
53
- key: sql.raw(`excluded.${blob_ref.key.name}`),
54
- mime: sql.raw(`excluded.${blob_ref.mime.name}`),
55
- size: sql.raw(`excluded.${blob_ref.size.name}`)
56
- }
57
- });
56
+ export async function setRecordBlobUsage(env: Env, did: string, uri: string, keys: string[]) {
57
+ await env.ALTERAN_DB.batch(setRecordBlobUsageStatements(env, did, uri, keys));
58
58
  }
59
59
 
60
- export async function setRecordBlobUsage(env: Env, uri: string, keys: string[]) {
61
- const db = getDb(env);
62
- // remove existing usage for this record
63
- await db.delete(blob_usage).where(eq(blob_usage.recordUri, uri)).run();
64
- // insert new usage
65
- for (const key of keys) {
66
- await db.insert(blob_usage).values({ recordUri: uri, key }).run();
60
+ export async function repairRecordBlobUsageForCurrentRecord(
61
+ env: Env,
62
+ did: string,
63
+ uri: string,
64
+ cid: string,
65
+ keys: string[],
66
+ expectedCommitCid: string,
67
+ ): Promise<RecordBlobUsageRepairResult> {
68
+ const uniqueKeys = Array.from(new Set(keys));
69
+ const blobPrecondition = blobKeyPreconditionSql(uniqueKeys, did);
70
+ const statements: D1PreparedStatement[] = [
71
+ env.ALTERAN_DB.prepare(
72
+ `UPDATE repo_root
73
+ SET commit_cid = commit_cid
74
+ WHERE did = ?
75
+ AND commit_cid = ?
76
+ AND EXISTS (
77
+ SELECT 1 FROM record
78
+ WHERE uri = ? AND did = ? AND cid = ?
79
+ )${blobPrecondition.clause}`,
80
+ ).bind(did, expectedCommitCid, uri, did, cid, ...blobPrecondition.binds),
81
+ env.ALTERAN_DB.prepare(
82
+ `DELETE FROM blob_usage
83
+ WHERE did = ?
84
+ AND record_uri = ?
85
+ AND EXISTS (
86
+ SELECT 1 FROM repo_root WHERE did = ? AND commit_cid = ?
87
+ )
88
+ AND EXISTS (
89
+ SELECT 1 FROM record WHERE uri = ? AND did = ? AND cid = ?
90
+ )${blobPrecondition.clause}`,
91
+ ).bind(did, uri, did, expectedCommitCid, uri, did, cid, ...blobPrecondition.binds),
92
+ ];
93
+ for (const key of uniqueKeys) {
94
+ statements.push(
95
+ env.ALTERAN_DB.prepare(
96
+ `INSERT OR IGNORE INTO blob_usage (did, record_uri, key, cid, repo_rev)
97
+ SELECT ?, ?, ?, b.cid, root.rev
98
+ FROM blob b
99
+ INNER JOIN repo_root root
100
+ ON root.did = ? AND root.commit_cid = ?
101
+ INNER JOIN record current_record
102
+ ON current_record.uri = ? AND current_record.did = ? AND current_record.cid = ?
103
+ WHERE b.did = ? AND b.key = ?${blobPrecondition.clause}`,
104
+ ).bind(did, uri, key, did, expectedCommitCid, uri, did, cid, did, key, ...blobPrecondition.binds),
105
+ );
67
106
  }
107
+ const results = await env.ALTERAN_DB.batch(statements);
108
+ if (changedRows(results[0]) === 1) return { tag: 'repaired' };
109
+ if (uniqueKeys.length > 0 && await hasMissingBlobKey(env, did, uniqueKeys)) {
110
+ return { tag: 'blobNotFound' };
111
+ }
112
+ return { tag: 'conflict' };
68
113
  }
69
114
 
70
- export async function listOrphanBlobKeys(env: Env): Promise<string[]> {
71
- const db = getDb(env);
72
- // select keys in blob that are not referenced in blob_usage
73
- const all = await db.select().from(blob_ref).all();
74
- const used = new Set((await db.select().from(blob_usage).all()).map((u) => u.key));
75
- return all.map((b) => b.key).filter((k) => !used.has(k));
76
- }
115
+ export type RecordBlobUsageRepairResult =
116
+ | { tag: 'repaired' }
117
+ | { tag: 'blobNotFound' }
118
+ | { tag: 'conflict' };
77
119
 
78
- export async function deleteBlobByKey(env: Env, key: string) {
79
- const db = getDb(env);
80
- await db.delete(blob_ref).where(eq(blob_ref.key, key)).run();
120
+ export function putRecordStatements(
121
+ env: Env,
122
+ row: NewRecordRow,
123
+ guard?: CommitGuard,
124
+ ): D1PreparedStatement[] {
125
+ const toInsert: NewRecordRow = {
126
+ ...row,
127
+ createdAt: row.createdAt ?? Date.now(),
128
+ };
129
+ if (guard) {
130
+ return [
131
+ env.ALTERAN_DB.prepare(
132
+ `INSERT OR IGNORE INTO record (uri, did, cid, json, created_at)
133
+ SELECT ?, ?, ?, ?, ?
134
+ WHERE EXISTS (
135
+ SELECT 1 FROM repo_root WHERE did = ? AND commit_cid = ?
136
+ )`,
137
+ ).bind(
138
+ toInsert.uri,
139
+ toInsert.did,
140
+ toInsert.cid,
141
+ toInsert.json,
142
+ toInsert.createdAt ?? 0,
143
+ guard.did,
144
+ guard.commitCid,
145
+ ),
146
+ env.ALTERAN_DB.prepare(
147
+ `UPDATE record
148
+ SET cid = ?, json = ?
149
+ WHERE uri = ?
150
+ AND EXISTS (
151
+ SELECT 1 FROM repo_root WHERE did = ? AND commit_cid = ?
152
+ )`,
153
+ ).bind(toInsert.cid, toInsert.json, toInsert.uri, guard.did, guard.commitCid),
154
+ ];
155
+ }
156
+ return [
157
+ env.ALTERAN_DB.prepare(
158
+ `INSERT INTO record (uri, did, cid, json, created_at)
159
+ VALUES (?, ?, ?, ?, ?)
160
+ ON CONFLICT(uri) DO UPDATE SET
161
+ cid = excluded.cid,
162
+ json = excluded.json`,
163
+ ).bind(
164
+ toInsert.uri,
165
+ toInsert.did,
166
+ toInsert.cid,
167
+ toInsert.json,
168
+ toInsert.createdAt ?? 0,
169
+ ),
170
+ ];
81
171
  }
82
172
 
83
- export async function getBlobQuota(env: Env, did: string) {
84
- const db = getDb(env);
85
- const quota = await db.select().from(blob_quota).where(eq(blob_quota.did, did)).get();
86
- return quota ?? { did, total_bytes: 0, blob_count: 0, updated_at: Date.now() };
173
+ export function deleteRecordStatements(
174
+ env: Env,
175
+ uri: string,
176
+ guard?: CommitGuard,
177
+ ): D1PreparedStatement[] {
178
+ if (guard) {
179
+ return [
180
+ env.ALTERAN_DB.prepare(
181
+ `DELETE FROM record
182
+ WHERE uri = ?
183
+ AND EXISTS (
184
+ SELECT 1 FROM repo_root WHERE did = ? AND commit_cid = ?
185
+ )`,
186
+ ).bind(uri, guard.did, guard.commitCid),
187
+ ];
188
+ }
189
+ return [
190
+ env.ALTERAN_DB.prepare('DELETE FROM record WHERE uri = ?').bind(uri),
191
+ ];
87
192
  }
88
193
 
89
- export async function updateBlobQuota(env: Env, did: string, bytesAdded: number, countAdded: number) {
90
- const db = getDb(env);
91
- const current = await getBlobQuota(env, did);
92
-
93
- const newTotalBytes = current.total_bytes + bytesAdded;
94
- const newBlobCount = current.blob_count + countAdded;
95
- const now = Date.now();
194
+ export function setRecordBlobUsageStatements(
195
+ env: Env,
196
+ did: string,
197
+ uri: string,
198
+ keys: string[],
199
+ guard?: CommitGuard,
200
+ ): D1PreparedStatement[] {
201
+ const statements: D1PreparedStatement[] = [
202
+ guard
203
+ ? env.ALTERAN_DB.prepare(
204
+ `DELETE FROM blob_usage
205
+ WHERE did = ?
206
+ AND record_uri = ?
207
+ AND EXISTS (
208
+ SELECT 1 FROM repo_root WHERE did = ? AND commit_cid = ?
209
+ )`,
210
+ ).bind(did, uri, guard.did, guard.commitCid)
211
+ : env.ALTERAN_DB.prepare('DELETE FROM blob_usage WHERE did = ? AND record_uri = ?').bind(did, uri),
212
+ ];
213
+ for (const key of new Set(keys)) {
214
+ if (guard) {
215
+ statements.push(
216
+ env.ALTERAN_DB.prepare(
217
+ `INSERT OR IGNORE INTO blob_usage (did, record_uri, key, cid, repo_rev)
218
+ SELECT ?, ?, ?, b.cid, ?
219
+ FROM blob b
220
+ WHERE b.did = ?
221
+ AND b.key = ?
222
+ AND EXISTS (
223
+ SELECT 1 FROM repo_root WHERE did = ? AND commit_cid = ?
224
+ )`,
225
+ ).bind(did, uri, key, guard.rev, did, key, guard.did, guard.commitCid),
226
+ );
227
+ } else {
228
+ statements.push(
229
+ env.ALTERAN_DB.prepare(
230
+ `INSERT INTO blob_usage (did, record_uri, key, cid, repo_rev)
231
+ SELECT ?, ?, ?, b.cid, COALESCE((SELECT rev FROM repo_root WHERE did = ?), '')
232
+ FROM blob b
233
+ WHERE b.did = ? AND b.key = ?`,
234
+ ).bind(did, uri, key, did, did, key),
235
+ );
236
+ }
237
+ }
238
+ return statements;
239
+ }
96
240
 
97
- await db
98
- .insert(blob_quota)
99
- .values({
100
- did,
101
- total_bytes: newTotalBytes,
102
- blob_count: newBlobCount,
103
- updated_at: now,
104
- })
105
- .onConflictDoUpdate({
106
- target: blob_quota.did,
107
- set: {
108
- total_bytes: sql.raw(`excluded.${blob_quota.total_bytes.name}`),
109
- blob_count: sql.raw(`excluded.${blob_quota.blob_count.name}`),
110
- updated_at: sql.raw(`excluded.${blob_quota.updated_at.name}`),
111
- },
112
- });
241
+ function changedRows(result: unknown): number {
242
+ const meta = (result as { meta?: Record<string, unknown> } | undefined)?.meta;
243
+ const changes = meta?.changes ?? meta?.rows_written ?? meta?.rowsWritten;
244
+ return typeof changes === 'number' ? changes : 0;
113
245
  }
114
246
 
115
- export async function checkBlobQuota(env: Env, did: string, additionalBytes: number): Promise<boolean> {
116
- const quota = await getBlobQuota(env, did);
117
- const maxBytes = parseInt(env.PDS_BLOB_QUOTA_BYTES || '10737418240', 10);
247
+ function blobKeyPreconditionSql(keys: string[], did: string): { sql: string; clause: string; binds: Array<string | number> } {
248
+ if (keys.length === 0) return { sql: '1 = 1', clause: '', binds: [] };
249
+ const placeholders = keys.map(() => '?').join(', ');
250
+ const sql = `(SELECT COUNT(DISTINCT key) FROM blob WHERE did = ? AND key IN (${placeholders})) = ?`;
251
+ return {
252
+ sql,
253
+ clause: ` AND ${sql}`,
254
+ binds: [did, ...keys, keys.length],
255
+ };
256
+ }
118
257
 
119
- return (quota.total_bytes + additionalBytes) <= maxBytes;
258
+ async function hasMissingBlobKey(env: Env, did: string, keys: string[]): Promise<boolean> {
259
+ const precondition = blobKeyPreconditionSql(keys, did);
260
+ const row = await env.ALTERAN_DB.prepare(
261
+ `SELECT ${precondition.sql} AS ok`,
262
+ )
263
+ .bind(...precondition.binds)
264
+ .first<{ ok: number }>();
265
+ return row?.ok !== 1;
120
266
  }
121
267
 
122
268
  // Account state management for migration support. Reads/writes route through
package/src/db/repo.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { Env } from '../env';
2
+ import type { D1PreparedStatement } from '@cloudflare/workers-types';
2
3
  import { drizzle } from 'drizzle-orm/d1';
3
- import { eq, sql } from 'drizzle-orm';
4
+ import { eq } from 'drizzle-orm';
4
5
  import { repo_root, commit_log } from './schema';
6
+ import type { CommitGuard } from './dal';
5
7
  import { RepoManager } from '../services/repo-manager';
6
8
  import { createCommit, signCommit, commitCid, generateTid, serializeCommit } from '../lib/commit';
7
9
  import { CID } from 'multiformats/cid';
@@ -9,18 +11,57 @@ import { resolveSecret } from '../lib/secrets';
9
11
  import { encodeBlocksForCommit } from '../services/car';
10
12
  import { ServerMisconfigured } from '../lib/errors';
11
13
 
14
+ export class RepoCommitConflictError extends Error {
15
+ constructor(message = 'repo head changed') {
16
+ super(message);
17
+ this.name = 'RepoCommitConflictError';
18
+ }
19
+ }
20
+
21
+ export class RepoBlobNotFoundError extends Error {
22
+ constructor(message = 'blob not found') {
23
+ super(message);
24
+ this.name = 'RepoBlobNotFoundError';
25
+ }
26
+ }
27
+
12
28
  export async function getRoot(env: Env) {
13
29
  const db = drizzle(env.ALTERAN_DB);
14
30
  const did = (await resolveSecret(env.PDS_DID)) ?? 'did:example:single-user';
15
31
  return db.select().from(repo_root).where(eq(repo_root.did, did)).get();
16
32
  }
17
33
 
34
+ export async function assertRepoHead(
35
+ env: Env,
36
+ did: string,
37
+ expectedCommitCid: string | null | undefined,
38
+ ): Promise<void> {
39
+ if (expectedCommitCid === undefined) return;
40
+ if (expectedCommitCid === null) {
41
+ const row = await env.ALTERAN_DB.prepare(
42
+ 'SELECT 1 FROM repo_root WHERE did = ? LIMIT 1',
43
+ ).bind(did).first();
44
+ if (row) throw new RepoCommitConflictError();
45
+ return;
46
+ }
47
+ const result = await env.ALTERAN_DB.prepare(
48
+ `UPDATE repo_root
49
+ SET commit_cid = commit_cid
50
+ WHERE did = ? AND commit_cid = ?`,
51
+ ).bind(did, expectedCommitCid).run();
52
+ if (changedRows(result) !== 1) throw new RepoCommitConflictError();
53
+ }
54
+
18
55
  /**
19
56
  * Bump the repository root to a new revision with signed commit
20
57
  */
21
58
  export async function bumpRoot(env: Env, prevMstRoot?: CID, currentMstRoot?: CID, opts?: {
22
59
  ops?: import('../lib/firehose/frames').RepoOp[];
23
60
  newMstBlocks?: Array<[CID, Uint8Array]>;
61
+ newRecordBlocks?: Array<readonly [CID, Uint8Array]>;
62
+ sideEffectStatements?: (guard: CommitGuard) => D1PreparedStatement[];
63
+ expectedCommitCid?: string | null;
64
+ requiredBlobKeys?: string[];
24
65
  }): Promise<{
25
66
  commitCid: string;
26
67
  rev: string;
@@ -37,8 +78,13 @@ export async function bumpRoot(env: Env, prevMstRoot?: CID, currentMstRoot?: CID
37
78
  // without REPO_SIGNING_KEY; prod always requires the configured key.
38
79
  const signingKey = await getSigningKey(env);
39
80
 
40
- const row = await db.select().from(repo_root).where(eq(repo_root.did, did)).get();
41
- const prevCommitCid = row?.commitCid ? CID.parse(row.commitCid) : null;
81
+ const expectedCommitCid = opts && 'expectedCommitCid' in opts ? opts.expectedCommitCid : undefined;
82
+ const row = expectedCommitCid === undefined
83
+ ? await db.select().from(repo_root).where(eq(repo_root.did, did)).get()
84
+ : undefined;
85
+ const prevCommitCid = expectedCommitCid === undefined
86
+ ? (row?.commitCid ? CID.parse(row.commitCid) : null)
87
+ : (expectedCommitCid ? CID.parse(expectedCommitCid) : null);
42
88
 
43
89
  // Prefer caller-provided pointer to avoid an extra MST load on the
44
90
  // batched-write path that already knows the new root.
@@ -61,24 +107,6 @@ export async function bumpRoot(env: Env, prevMstRoot?: CID, currentMstRoot?: CID
61
107
  const cid = await commitCid(signedCommit);
62
108
  const cidString = cid.toString();
63
109
 
64
- // sql.raw('excluded.X') references the just-inserted VALUES so the upsert
65
- // updates with the new value rather than a stale parameter.
66
- await db
67
- .insert(repo_root)
68
- .values({
69
- did,
70
- commitCid: cidString,
71
- rev, // Store TID as text
72
- })
73
- .onConflictDoUpdate({
74
- target: repo_root.did,
75
- set: {
76
- commitCid: sql.raw('excluded.commit_cid'),
77
- rev: sql.raw('excluded.rev'),
78
- },
79
- })
80
- .run();
81
-
82
110
  // Serialize commit for storage
83
111
  const commitBytes = serializeCommit(signedCommit);
84
112
  const commitData = JSON.stringify({
@@ -93,19 +121,171 @@ export async function bumpRoot(env: Env, prevMstRoot?: CID, currentMstRoot?: CID
93
121
  for (const b of signedCommit.sig) s += String.fromCharCode(b);
94
122
  const sigBase64 = btoa(s);
95
123
 
96
- // Append to commit log
97
- await appendCommit(env, cidString, rev, commitData, sigBase64);
98
-
99
124
  // Encode blocks as CAR for firehose
100
- const blocksBytes = await encodeBlocksForCommit(env, cid, mstRootCid, ops, opts?.newMstBlocks);
125
+ const blocksBytes = await encodeBlocksForCommit(
126
+ env,
127
+ cid,
128
+ mstRootCid,
129
+ ops,
130
+ opts?.newMstBlocks,
131
+ commitBytes,
132
+ opts?.newRecordBlocks,
133
+ );
101
134
  // Encode to base64 (workers-safe)
102
135
  let blocksBase64 = '';
103
136
  for (const b of blocksBytes) blocksBase64 += String.fromCharCode(b);
104
137
  blocksBase64 = btoa(blocksBase64);
105
138
 
139
+ const ts = Date.now();
140
+ const guard = { did, commitCid: cidString, rev };
141
+ const requiredBlobKeys = Array.from(new Set(opts?.requiredBlobKeys ?? []));
142
+ const rootStatement = rootMutationStatement(env, did, cidString, rev, expectedCommitCid, requiredBlobKeys);
143
+ const blockStatements = blockstoreStatements(env, did, cidString, [
144
+ ...(opts?.newRecordBlocks ?? []),
145
+ ...(opts?.newMstBlocks ?? []),
146
+ ]);
147
+ const statements = [
148
+ rootStatement,
149
+ ...blockStatements,
150
+ ...(opts?.sideEffectStatements?.(guard) ?? []),
151
+ env.ALTERAN_DB.prepare(
152
+ `INSERT INTO commit_log (cid, rev, data, sig, ts)
153
+ SELECT ?, ?, ?, ?, ?
154
+ WHERE EXISTS (
155
+ SELECT 1 FROM repo_root WHERE did = ? AND commit_cid = ?
156
+ )`,
157
+ ).bind(cidString, rev, commitData, sigBase64, ts, did, cidString),
158
+ ];
159
+
160
+ const results = await env.ALTERAN_DB.batch(statements);
161
+ if ((expectedCommitCid !== undefined || requiredBlobKeys.length > 0) && changedRows(results[0]) !== 1) {
162
+ if (requiredBlobKeys.length > 0 && await hasMissingBlobKey(env, requiredBlobKeys)) {
163
+ throw new RepoBlobNotFoundError();
164
+ }
165
+ throw new RepoCommitConflictError();
166
+ }
167
+
106
168
  return { commitCid: cidString, rev, ops, mstRoot: mstRootCid, commitData, sig: sigBase64, blocks: blocksBase64 };
107
169
  }
108
170
 
171
+ function blockstoreStatements(
172
+ env: Env,
173
+ did: string,
174
+ commitCid: string,
175
+ blocks: Array<readonly [CID, Uint8Array]>,
176
+ ): D1PreparedStatement[] {
177
+ const seen = new Set<string>();
178
+ const statements: D1PreparedStatement[] = [];
179
+ for (const [cid, bytes] of blocks) {
180
+ const cidString = cid.toString();
181
+ if (seen.has(cidString)) continue;
182
+ seen.add(cidString);
183
+ statements.push(
184
+ env.ALTERAN_DB.prepare(
185
+ `INSERT OR REPLACE INTO blockstore (cid, bytes)
186
+ SELECT ?, ?
187
+ WHERE EXISTS (
188
+ SELECT 1 FROM repo_root WHERE did = ? AND commit_cid = ?
189
+ )`,
190
+ ).bind(cidString, bytesToBase64(bytes), did, commitCid),
191
+ );
192
+ }
193
+ return statements;
194
+ }
195
+
196
+ function bytesToBase64(bytes: Uint8Array): string {
197
+ let binary = '';
198
+ const chunkSize = 0x8000;
199
+ for (let offset = 0; offset < bytes.length; offset += chunkSize) {
200
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
201
+ }
202
+ return btoa(binary);
203
+ }
204
+
205
+ function rootMutationStatement(
206
+ env: Env,
207
+ did: string,
208
+ commitCid: string,
209
+ rev: string,
210
+ expectedCommitCid: string | null | undefined,
211
+ requiredBlobKeys: string[] = [],
212
+ ): D1PreparedStatement {
213
+ const blobPrecondition = blobPreconditionSql(requiredBlobKeys, did);
214
+ if (expectedCommitCid === null) {
215
+ return env.ALTERAN_DB.prepare(
216
+ `INSERT INTO repo_root (did, commit_cid, rev)
217
+ SELECT ?, ?, ?
218
+ WHERE NOT EXISTS (
219
+ SELECT 1 FROM repo_root WHERE did = ?
220
+ )${blobPrecondition.clause}`,
221
+ ).bind(did, commitCid, rev, did, ...blobPrecondition.binds);
222
+ }
223
+ if (typeof expectedCommitCid === 'string') {
224
+ return env.ALTERAN_DB.prepare(
225
+ `UPDATE repo_root
226
+ SET commit_cid = ?, rev = ?
227
+ WHERE did = ? AND commit_cid = ?${blobPrecondition.clause}`,
228
+ ).bind(commitCid, rev, did, expectedCommitCid, ...blobPrecondition.binds);
229
+ }
230
+ if (requiredBlobKeys.length > 0) {
231
+ return env.ALTERAN_DB.prepare(
232
+ `INSERT INTO repo_root (did, commit_cid, rev)
233
+ SELECT ?, ?, ?
234
+ WHERE ${blobPrecondition.sql}
235
+ ON CONFLICT(did) DO UPDATE SET
236
+ commit_cid = excluded.commit_cid,
237
+ rev = excluded.rev
238
+ WHERE ${blobPrecondition.sql}`,
239
+ ).bind(
240
+ did,
241
+ commitCid,
242
+ rev,
243
+ ...blobPrecondition.binds,
244
+ ...blobPrecondition.binds,
245
+ );
246
+ }
247
+ return env.ALTERAN_DB.prepare(
248
+ `INSERT INTO repo_root (did, commit_cid, rev)
249
+ VALUES (?, ?, ?)
250
+ ON CONFLICT(did) DO UPDATE SET
251
+ commit_cid = excluded.commit_cid,
252
+ rev = excluded.rev`,
253
+ ).bind(did, commitCid, rev);
254
+ }
255
+
256
+ function blobPreconditionSql(requiredBlobKeys: string[], did?: string): { sql: string; clause: string; binds: Array<string | number> } {
257
+ if (requiredBlobKeys.length === 0) {
258
+ return { sql: '1 = 1', clause: '', binds: [] };
259
+ }
260
+ if (!did) {
261
+ throw new Error('did is required for blob preconditions');
262
+ }
263
+ const placeholders = requiredBlobKeys.map(() => '?').join(', ');
264
+ const sql = `(SELECT COUNT(DISTINCT key) FROM blob WHERE did = ? AND key IN (${placeholders})) = ?`;
265
+ return {
266
+ sql,
267
+ clause: ` AND ${sql}`,
268
+ binds: [did, ...requiredBlobKeys, requiredBlobKeys.length],
269
+ };
270
+ }
271
+
272
+ async function hasMissingBlobKey(env: Env, requiredBlobKeys: string[]): Promise<boolean> {
273
+ const did = (await resolveSecret(env.PDS_DID)) ?? 'did:example:single-user';
274
+ const precondition = blobPreconditionSql(requiredBlobKeys, did);
275
+ const row = await env.ALTERAN_DB.prepare(
276
+ `SELECT ${precondition.sql} AS ok`,
277
+ )
278
+ .bind(...precondition.binds)
279
+ .first<{ ok: number }>();
280
+ return row?.ok !== 1;
281
+ }
282
+
283
+ function changedRows(result: unknown): number {
284
+ const meta = (result as { meta?: Record<string, unknown> } | undefined)?.meta;
285
+ const changes = meta?.changes ?? meta?.rows_written ?? meta?.rowsWritten;
286
+ return typeof changes === 'number' ? changes : 0;
287
+ }
288
+
109
289
  export async function appendCommit(env: Env, cid: string, rev: string, data: string, sig: string) {
110
290
  const db = drizzle(env.ALTERAN_DB);
111
291
  const ts = Date.now();
package/src/db/schema.ts CHANGED
@@ -78,21 +78,30 @@ export const record = sqliteTable('record', {
78
78
  }));
79
79
 
80
80
  export const blob_ref = sqliteTable('blob', {
81
- cid: text('cid').primaryKey().notNull(),
81
+ cid: text('cid').notNull(),
82
82
  did: text('did').notNull(),
83
83
  key: text('key').notNull(),
84
84
  mime: text('mime').notNull(),
85
85
  size: integer('size').notNull(),
86
- });
86
+ uploadedAt: integer('uploaded_at', { mode: 'number' }).notNull().default(0),
87
+ }, (table) => ({
88
+ pk: primaryKey({ columns: [table.did, table.cid] }),
89
+ keyIdx: index('blob_key_idx').on(table.key),
90
+ }));
87
91
 
88
92
  export const blob_usage = sqliteTable('blob_usage', {
93
+ did: text('did').notNull(),
89
94
  recordUri: text('record_uri').notNull(),
90
95
  key: text('key').notNull(),
96
+ cid: text('cid').notNull(),
97
+ repoRev: text('repo_rev').notNull(),
91
98
  }, (table) => ({
92
- // Composite primary key on recordUri and key
93
- pk: primaryKey({ columns: [table.recordUri, table.key] }),
99
+ // Composite primary key on did, recordUri, and key
100
+ pk: primaryKey({ columns: [table.did, table.recordUri, table.key] }),
94
101
  // Index for GC queries (finding blobs by record)
95
- recordUriIdx: index('blob_usage_record_uri_idx').on(table.recordUri),
102
+ recordUriIdx: index('blob_usage_record_uri_idx').on(table.did, table.recordUri),
103
+ didKeyIdx: index('blob_usage_did_key_idx').on(table.did, table.key),
104
+ didRepoRevCidIdx: index('blob_usage_did_repo_rev_cid_idx').on(table.did, table.repoRev, table.cid),
96
105
  }));
97
106
 
98
107
  // Commit log stores full commit history for firehose and sync
@@ -5,11 +5,12 @@ export async function POST_db_bootstrap(ctx: APIContext) {
5
5
  const env: any = (ctx.locals as any).runtime?.env ?? (ctx.locals as any) ?? (globalThis as any);
6
6
  const db = env.ALTERAN_DB;
7
7
  await db.exec("CREATE TABLE IF NOT EXISTS record (uri TEXT PRIMARY KEY, cid TEXT NOT NULL, json TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')));");
8
- await db.exec("CREATE TABLE IF NOT EXISTS blob (cid TEXT PRIMARY KEY, key TEXT NOT NULL, mime TEXT NOT NULL, size INTEGER NOT NULL);");
9
- await db.exec("CREATE TABLE IF NOT EXISTS blob_usage (record_uri TEXT NOT NULL, key TEXT NOT NULL);");
8
+ await db.exec("CREATE TABLE IF NOT EXISTS blob (cid TEXT NOT NULL, did TEXT NOT NULL, key TEXT NOT NULL, mime TEXT NOT NULL, size INTEGER NOT NULL, uploaded_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (did, cid));");
9
+ await db.exec("CREATE TABLE IF NOT EXISTS blob_usage (did TEXT NOT NULL, record_uri TEXT NOT NULL, key TEXT NOT NULL, PRIMARY KEY (did, record_uri, key));");
10
10
  await db.exec("CREATE TABLE IF NOT EXISTS repo_root (did TEXT PRIMARY KEY, commit_cid TEXT NOT NULL, rev INTEGER NOT NULL);");
11
11
  await db.exec("CREATE INDEX IF NOT EXISTS record_cid_idx ON record(cid);");
12
- await db.exec("CREATE INDEX IF NOT EXISTS blob_usage_record_idx ON blob_usage(record_uri);");
12
+ await db.exec("CREATE INDEX IF NOT EXISTS blob_usage_record_idx ON blob_usage(did, record_uri);");
13
+ await db.exec("CREATE INDEX IF NOT EXISTS blob_usage_did_key_idx ON blob_usage(did, key);");
13
14
  return new Response('ok');
14
15
  }
15
16