@alteran/astro 0.7.7 → 0.8.2
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/README.md +25 -25
- package/migrations/0010_eminent_klaw.sql +37 -0
- package/migrations/0011_chief_darwin.sql +31 -0
- package/migrations/0012_backfill_blob_usage.sql +39 -0
- package/migrations/meta/0010_snapshot.json +790 -0
- package/migrations/meta/0011_snapshot.json +813 -0
- package/migrations/meta/_journal.json +22 -1
- package/package.json +24 -41
- package/src/db/blob.ts +323 -0
- package/src/db/dal.ts +224 -78
- package/src/db/repo.ts +205 -25
- package/src/db/schema.ts +14 -5
- package/src/handlers/debug.ts +4 -3
- package/src/lib/appview/auth-policy.ts +7 -24
- package/src/lib/appview/proxy.ts +56 -23
- package/src/lib/appview/types.ts +1 -6
- package/src/lib/auth-scope.ts +399 -0
- package/src/lib/auth.ts +40 -39
- package/src/lib/commit.ts +37 -15
- package/src/lib/did-document.ts +4 -5
- package/src/lib/jwt.ts +3 -1
- package/src/lib/mime.ts +9 -0
- package/src/lib/oauth/resource.ts +49 -0
- package/src/lib/preference-policy.ts +45 -0
- package/src/lib/preferences.ts +0 -4
- package/src/lib/public-host.ts +127 -0
- package/src/lib/ratelimit.ts +37 -12
- package/src/lib/relay.ts +7 -27
- package/src/lib/repo-write-blob-constraints.ts +141 -0
- package/src/lib/repo-write-data.ts +195 -0
- package/src/lib/repo-write-error.ts +46 -0
- package/src/lib/repo-write-validation.ts +463 -0
- package/src/lib/session-tokens.ts +22 -5
- package/src/lib/unsupported-routes.ts +32 -0
- package/src/lib/util.ts +57 -2
- package/src/pages/.well-known/atproto-did.ts +15 -3
- package/src/pages/.well-known/did.json.ts +13 -7
- package/src/pages/debug/db/bootstrap.ts +4 -3
- package/src/pages/debug/gc/blobs.ts +11 -8
- package/src/pages/debug/record.ts +11 -0
- package/src/pages/xrpc/[...nsid].ts +17 -9
- package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +9 -3
- package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +17 -4
- package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +4 -2
- package/src/pages/xrpc/chat.bsky.convo.getLog.ts +4 -2
- package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +4 -2
- package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +10 -6
- package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +4 -3
- package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +13 -5
- package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +4 -2
- package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +4 -2
- package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +12 -36
- package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +90 -139
- package/src/pages/xrpc/com.atproto.repo.createRecord.ts +74 -47
- package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +119 -46
- package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +21 -20
- package/src/pages/xrpc/com.atproto.repo.getRecord.ts +6 -1
- package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +4 -2
- package/src/pages/xrpc/com.atproto.repo.putRecord.ts +84 -47
- package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +199 -78
- package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +4 -2
- package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +88 -21
- package/src/pages/xrpc/com.atproto.server.getSession.ts +3 -13
- package/src/pages/xrpc/com.atproto.sync.getBlob.ts +92 -74
- package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +45 -23
- package/src/services/car.ts +13 -0
- package/src/services/repo/apply-prepared-writes.ts +185 -0
- package/src/services/repo/blob-refs.ts +48 -0
- package/src/services/repo/blockstore-ops.ts +59 -17
- package/src/services/repo/list-blobs.ts +43 -0
- package/src/services/repo-manager.ts +221 -78
- package/src/worker/runtime.ts +1 -1
- 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
|
|
3
|
+
import { record, type NewRecordRow } from './schema';
|
|
3
4
|
import type { Env } from '../env';
|
|
4
|
-
import { eq, inArray
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
45
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
41
|
-
const
|
|
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(
|
|
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').
|
|
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
|
package/src/handlers/debug.ts
CHANGED
|
@@ -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
|
|
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
|
|