@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.
- 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/observability.ts +53 -12
- 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/oauth/token.ts +78 -33
- 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
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
|
+
import { ensureValidDid } from '@atproto/syntax';
|
|
3
|
+
import { CID } from 'multiformats/cid';
|
|
2
4
|
import { getDb } from '../../db/client';
|
|
3
5
|
import { blob_ref } from '../../db/schema';
|
|
4
|
-
import { eq } from 'drizzle-orm';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { putBlobRef } from '../../db/dal';
|
|
8
|
-
import { isAccountActive } from '../../db/dal';
|
|
6
|
+
import { and, eq } from 'drizzle-orm';
|
|
7
|
+
import { blobKeyHasUsage, isAccountActive } from '../../db/dal';
|
|
8
|
+
import { resolveSecret } from '../../lib/secrets';
|
|
9
9
|
|
|
10
10
|
export const prerender = false;
|
|
11
11
|
|
|
@@ -23,98 +23,50 @@ export async function GET({ locals, url }: APIContext) {
|
|
|
23
23
|
const { env } = locals.runtime;
|
|
24
24
|
|
|
25
25
|
try {
|
|
26
|
-
const configuredDid =
|
|
27
|
-
const did = url.searchParams.get('did')
|
|
26
|
+
const configuredDid = await resolveSecret(env.PDS_DID);
|
|
27
|
+
const did = url.searchParams.get('did');
|
|
28
28
|
const cid = url.searchParams.get('cid');
|
|
29
29
|
if (!did || !cid) {
|
|
30
|
-
return
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
);
|
|
30
|
+
return jsonError('InvalidRequest', 'did and cid parameters are required');
|
|
31
|
+
}
|
|
32
|
+
if (!isValidDid(did) || !isValidCid(cid)) {
|
|
33
|
+
return jsonError('InvalidRequest', 'did and cid parameters must be valid');
|
|
34
|
+
}
|
|
35
|
+
if (!configuredDid || did !== configuredDid) {
|
|
36
|
+
return jsonError('RepoNotFound', 'Repo not found');
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
const active = await isAccountActive(env, did);
|
|
40
40
|
if (!active) {
|
|
41
|
-
return
|
|
42
|
-
JSON.stringify({ error: 'AccountInactive', message: 'Account is not active' }),
|
|
43
|
-
{ status: 403, headers: { 'Content-Type': 'application/json' } }
|
|
44
|
-
);
|
|
41
|
+
return jsonError('RepoDeactivated', 'Repo is deactivated');
|
|
45
42
|
}
|
|
46
43
|
|
|
47
44
|
const db = getDb(env);
|
|
48
45
|
|
|
49
|
-
|
|
50
|
-
let blobMeta = await db
|
|
46
|
+
const blobMeta = await db
|
|
51
47
|
.select()
|
|
52
48
|
.from(blob_ref)
|
|
53
|
-
.where(eq(blob_ref.cid, cid))
|
|
49
|
+
.where(and(eq(blob_ref.did, did), eq(blob_ref.cid, cid)))
|
|
54
50
|
.get();
|
|
55
51
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
let size: number | null = blobMeta?.size ?? null;
|
|
59
|
-
|
|
60
|
-
// Fallback for older uploads: derive R2 key from CID (raw/sha256) if DB row missing
|
|
61
|
-
if (!key) {
|
|
62
|
-
try {
|
|
63
|
-
const link = CID.parse(cid);
|
|
64
|
-
// blob CIDs are raw (0x55) with sha256 multihash
|
|
65
|
-
if (link.multihash.code !== sha256.code) {
|
|
66
|
-
return new Response(
|
|
67
|
-
JSON.stringify({ error: 'InvalidRequest', message: 'Unsupported multihash' }),
|
|
68
|
-
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
// Recreate legacy R2 key scheme used by store.put()
|
|
72
|
-
const digest = link.multihash.digest; // Uint8Array
|
|
73
|
-
// base64url encode
|
|
74
|
-
let s = '';
|
|
75
|
-
for (const b of digest) s += String.fromCharCode(b);
|
|
76
|
-
const b64url = btoa(s).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
77
|
-
key = `blobs/by-cid/${b64url}`;
|
|
78
|
-
} catch {
|
|
79
|
-
key = null;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (!key) {
|
|
84
|
-
return new Response(
|
|
85
|
-
JSON.stringify({ error: 'InvalidRequest', message: 'Blob not found' }),
|
|
86
|
-
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
87
|
-
);
|
|
52
|
+
if (!blobMeta || !(await blobKeyHasUsage(env, did, blobMeta.key))) {
|
|
53
|
+
return blobNotFound();
|
|
88
54
|
}
|
|
89
55
|
|
|
90
56
|
// Fetch blob from R2
|
|
91
57
|
const r2 = env.ALTERAN_BLOBS;
|
|
92
|
-
const object = await r2.get(key);
|
|
58
|
+
const object = await r2.get(blobMeta.key);
|
|
93
59
|
|
|
94
|
-
if (!object) {
|
|
95
|
-
return
|
|
96
|
-
JSON.stringify({ error: 'InvalidRequest', message: 'Blob not found' }),
|
|
97
|
-
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
98
|
-
);
|
|
60
|
+
if (!object || object.size !== Number(blobMeta.size)) {
|
|
61
|
+
return blobNotFound();
|
|
99
62
|
}
|
|
100
63
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
size = object.size ?? size ?? 0;
|
|
104
|
-
await putBlobRef(env, did, cid, key, mime, Number(size ?? 0));
|
|
105
|
-
} catch (backfillError) {
|
|
106
|
-
// Backfill is opportunistic; serving the blob is the priority.
|
|
107
|
-
console.warn('getBlob backfill failed:', backfillError);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// workers-types' ReadableStream lacks the DOM-types readMany member; the
|
|
112
|
-
// shapes are wire-compatible at runtime, so widen through unknown.
|
|
113
|
-
return new Response(object.body as unknown as ReadableStream<Uint8Array>, {
|
|
64
|
+
const body = await responseBody(object);
|
|
65
|
+
return new Response(body, {
|
|
114
66
|
status: 200,
|
|
115
67
|
headers: {
|
|
116
|
-
'Content-Type': mime,
|
|
117
|
-
|
|
68
|
+
'Content-Type': blobMeta.mime,
|
|
69
|
+
'Content-Length': String(blobMeta.size),
|
|
118
70
|
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
119
71
|
'x-content-type-options': 'nosniff',
|
|
120
72
|
'content-security-policy': "default-src 'none'; sandbox",
|
|
@@ -131,3 +83,69 @@ export async function GET({ locals, url }: APIContext) {
|
|
|
131
83
|
);
|
|
132
84
|
}
|
|
133
85
|
}
|
|
86
|
+
|
|
87
|
+
function blobNotFound(): Response {
|
|
88
|
+
return jsonError('BlobNotFound', 'Blob not found');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function jsonError(error: string, message: string, status = 400): Response {
|
|
92
|
+
return new Response(
|
|
93
|
+
JSON.stringify({ error, message }),
|
|
94
|
+
{ status, headers: { 'Content-Type': 'application/json' } },
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isValidDid(did: string): boolean {
|
|
99
|
+
try {
|
|
100
|
+
ensureValidDid(did);
|
|
101
|
+
return true;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isValidCid(cid: string): boolean {
|
|
108
|
+
try {
|
|
109
|
+
CID.parse(cid);
|
|
110
|
+
return true;
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function responseBody(object: {
|
|
117
|
+
body: unknown;
|
|
118
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
119
|
+
}): Promise<BodyInit> {
|
|
120
|
+
try {
|
|
121
|
+
return localReadableStream(object.body as ReadableStream<Uint8Array>);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
// Miniflare exposes R2 bodies via a workerd object that can't be cloned
|
|
124
|
+
// across the worker boundary; fall back to a buffered read when that happens.
|
|
125
|
+
if (error instanceof Error && error.message.includes('can not be cloned')) {
|
|
126
|
+
return object.arrayBuffer();
|
|
127
|
+
}
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function localReadableStream(source: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
|
133
|
+
const reader = source.getReader();
|
|
134
|
+
return new ReadableStream<Uint8Array>({
|
|
135
|
+
async pull(controller) {
|
|
136
|
+
try {
|
|
137
|
+
const chunk = await reader.read();
|
|
138
|
+
if (chunk.done) {
|
|
139
|
+
controller.close();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
controller.enqueue(new Uint8Array(chunk.value));
|
|
143
|
+
} catch (error) {
|
|
144
|
+
controller.error(error);
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
cancel(reason) {
|
|
148
|
+
return reader.cancel(reason);
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { ensureValidDid, isValidTid } from '@atproto/syntax';
|
|
3
|
+
import { isAccountActive } from '../../db/dal';
|
|
4
|
+
import { listBlobCids } from '../../services/repo/list-blobs';
|
|
5
|
+
import { resolveSecret } from '../../lib/secrets';
|
|
5
6
|
|
|
6
7
|
export const prerender = false;
|
|
7
8
|
|
|
@@ -12,31 +13,29 @@ export const prerender = false;
|
|
|
12
13
|
export async function GET({ locals, url }: APIContext) {
|
|
13
14
|
const { env } = locals.runtime;
|
|
14
15
|
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
16
|
+
const configuredDid = await resolveSecret(env.PDS_DID);
|
|
17
|
+
const did = url.searchParams.get('did') ?? undefined;
|
|
18
|
+
const since = url.searchParams.get('since') ?? undefined;
|
|
19
|
+
const cursor = url.searchParams.get('cursor') ?? undefined;
|
|
20
|
+
const limit = parseLimit(url.searchParams.get('limit'));
|
|
21
|
+
if (!did) return jsonError('InvalidRequest', 'did is required');
|
|
22
|
+
if (!isValidDid(did)) return jsonError('InvalidRequest', 'did must be valid');
|
|
23
|
+
if (!limit) return jsonError('InvalidRequest', 'limit must be an integer between 1 and 1000');
|
|
24
|
+
if (since && !isValidTid(since)) return jsonError('InvalidRequest', 'since must be a valid TID');
|
|
25
|
+
if (!configuredDid || did !== configuredDid) return jsonError('RepoNotFound', 'Repo not found');
|
|
18
26
|
|
|
19
27
|
try {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
.where(and(eq(blob_ref.did, did), gt(blob_ref.cid, since)))
|
|
27
|
-
.limit(limit)
|
|
28
|
-
.all()
|
|
29
|
-
: await db
|
|
30
|
-
.select()
|
|
31
|
-
.from(blob_ref)
|
|
32
|
-
.where(eq(blob_ref.did, did))
|
|
33
|
-
.limit(limit)
|
|
34
|
-
.all();
|
|
28
|
+
const active = await isAccountActive(env, did);
|
|
29
|
+
if (!active) {
|
|
30
|
+
return jsonError('RepoDeactivated', 'Repo is deactivated');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const blobs = await listBlobCids(env, { did, since, cursor, limit });
|
|
35
34
|
|
|
36
35
|
return new Response(
|
|
37
36
|
JSON.stringify({
|
|
38
|
-
cids: blobs.
|
|
39
|
-
|
|
37
|
+
cids: blobs.cids,
|
|
38
|
+
...(blobs.cursor ? { cursor: blobs.cursor } : {}),
|
|
40
39
|
}),
|
|
41
40
|
{
|
|
42
41
|
status: 200,
|
|
@@ -51,3 +50,26 @@ export async function GET({ locals, url }: APIContext) {
|
|
|
51
50
|
);
|
|
52
51
|
}
|
|
53
52
|
}
|
|
53
|
+
|
|
54
|
+
function parseLimit(value: string | null): number | null {
|
|
55
|
+
if (value === null || value === '') return 500;
|
|
56
|
+
if (!/^\d+$/.test(value)) return null;
|
|
57
|
+
const limit = Number(value);
|
|
58
|
+
return Number.isInteger(limit) && limit >= 1 && limit <= 1000 ? limit : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function jsonError(error: string, message: string): Response {
|
|
62
|
+
return new Response(
|
|
63
|
+
JSON.stringify({ error, message }),
|
|
64
|
+
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isValidDid(did: string): boolean {
|
|
69
|
+
try {
|
|
70
|
+
ensureValidDid(did);
|
|
71
|
+
return true;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/services/car.ts
CHANGED
|
@@ -174,10 +174,15 @@ export async function encodeBlocksForCommit(
|
|
|
174
174
|
mstRoot: CID,
|
|
175
175
|
ops: Array<{ path: string; cid: CID | null }>,
|
|
176
176
|
newMstBlocks?: Array<[CID, Uint8Array]>,
|
|
177
|
+
commitBlock?: Uint8Array,
|
|
178
|
+
newRecordBlocks?: Array<readonly [CID, Uint8Array]>,
|
|
177
179
|
): Promise<Uint8Array> {
|
|
178
180
|
const blockstore = new D1Blockstore(env);
|
|
179
181
|
const blocks: { cid: CID; bytes: Uint8Array }[] = [];
|
|
180
182
|
const seen = new Set<string>();
|
|
183
|
+
const stagedRecordBlocks = new Map(
|
|
184
|
+
(newRecordBlocks ?? []).map(([cid, bytes]) => [cid.toString(), bytes]),
|
|
185
|
+
);
|
|
181
186
|
|
|
182
187
|
// Helper to add block if not already seen
|
|
183
188
|
const addBlock = async (cid: CID) => {
|
|
@@ -185,6 +190,14 @@ export async function encodeBlocksForCommit(
|
|
|
185
190
|
if (seen.has(cidStr)) return;
|
|
186
191
|
seen.add(cidStr);
|
|
187
192
|
let bytes = await blockstore.get(cid);
|
|
193
|
+
if (!bytes) {
|
|
194
|
+
bytes = stagedRecordBlocks.get(cidStr) ?? null;
|
|
195
|
+
}
|
|
196
|
+
if (!bytes) {
|
|
197
|
+
if (cidStr === commitCid.toString() && commitBlock) {
|
|
198
|
+
bytes = commitBlock;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
188
201
|
if (!bytes) {
|
|
189
202
|
// Attempt to reconstruct commit block from commit_log if this is the commit cid
|
|
190
203
|
if (cidStr === commitCid.toString()) {
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { CID } from 'multiformats/cid';
|
|
2
|
+
import type { Env } from '../../env';
|
|
3
|
+
import { bumpRoot } from '../../db/repo';
|
|
4
|
+
import {
|
|
5
|
+
deleteRecordStatements,
|
|
6
|
+
getRecordBlobKeys,
|
|
7
|
+
putRecordStatements,
|
|
8
|
+
setRecordBlobUsageStatements,
|
|
9
|
+
type BlobKeyRef,
|
|
10
|
+
} from '../../db/dal';
|
|
11
|
+
import type { RepoOp } from '../../lib/firehose/frames';
|
|
12
|
+
import { RepoWriteError } from '../../lib/repo-write-error';
|
|
13
|
+
import type { MST } from '../../lib/mst';
|
|
14
|
+
import type { PreparedWrite, ValidationStatus } from '../../lib/repo-write-validation';
|
|
15
|
+
import {
|
|
16
|
+
collectUnstoredMstBlocks,
|
|
17
|
+
encodeRecordBlock,
|
|
18
|
+
type EncodedBlock,
|
|
19
|
+
} from './blockstore-ops';
|
|
20
|
+
import { assertBlobKeysAvailable } from './blob-refs';
|
|
21
|
+
|
|
22
|
+
export interface BatchCommitResult {
|
|
23
|
+
commit: {
|
|
24
|
+
cid: string;
|
|
25
|
+
rev: string;
|
|
26
|
+
} | null;
|
|
27
|
+
commitCid?: string;
|
|
28
|
+
rev?: string;
|
|
29
|
+
ops: RepoOp[];
|
|
30
|
+
commitData?: string;
|
|
31
|
+
sig?: string;
|
|
32
|
+
blocks?: string;
|
|
33
|
+
results: Array<{
|
|
34
|
+
$type: string;
|
|
35
|
+
uri?: string;
|
|
36
|
+
cid?: string;
|
|
37
|
+
validationStatus?: ValidationStatus;
|
|
38
|
+
}>;
|
|
39
|
+
dereferencedBlobKeys: BlobKeyRef[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type SideEffect =
|
|
43
|
+
| { action: 'put'; uri: string; cid: string; record: unknown; blobKeys: string[] }
|
|
44
|
+
| { action: 'delete'; uri: string };
|
|
45
|
+
|
|
46
|
+
export async function applyPreparedWritesToRepo(
|
|
47
|
+
env: Env,
|
|
48
|
+
did: string,
|
|
49
|
+
root: MST,
|
|
50
|
+
writes: PreparedWrite[],
|
|
51
|
+
expectedCommitCid?: string | null,
|
|
52
|
+
): Promise<BatchCommitResult> {
|
|
53
|
+
let mst = root;
|
|
54
|
+
const prevMstRoot = await mst.getPointer();
|
|
55
|
+
const ops: RepoOp[] = [];
|
|
56
|
+
const sideEffects: SideEffect[] = [];
|
|
57
|
+
const results: BatchCommitResult['results'] = [];
|
|
58
|
+
const recordBlocks: EncodedBlock[] = [];
|
|
59
|
+
|
|
60
|
+
for (const write of writes) {
|
|
61
|
+
const path = `${write.collection}/${write.rkey}`;
|
|
62
|
+
const uri = `at://${did}/${path}`;
|
|
63
|
+
if (write.action === 'delete') {
|
|
64
|
+
const prev = await mst.get(path);
|
|
65
|
+
if (!prev) throw new RepoWriteError('InvalidRequest', 'record does not exist');
|
|
66
|
+
mst = await mst.delete(path);
|
|
67
|
+
ops.push({ action: 'delete', path, cid: null, prev });
|
|
68
|
+
sideEffects.push({ action: 'delete', uri });
|
|
69
|
+
results.push({ $type: 'com.atproto.repo.applyWrites#deleteResult' });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const prev = await mst.get(path);
|
|
74
|
+
if (write.action === 'update') {
|
|
75
|
+
if (!prev) throw new RepoWriteError('InvalidRequest', 'record does not exist');
|
|
76
|
+
const recordBlock = await encodeRecordBlock(write.record);
|
|
77
|
+
const [recordCid] = recordBlock;
|
|
78
|
+
recordBlocks.push(recordBlock);
|
|
79
|
+
mst = await mst.update(path, recordCid);
|
|
80
|
+
ops.push({ action: 'update', path, cid: recordCid, prev });
|
|
81
|
+
addPutEffect(sideEffects, results, uri, recordCid, write);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (prev) throw new RepoWriteError('InvalidRequest', 'record already exists');
|
|
86
|
+
const recordBlock = await encodeRecordBlock(write.record);
|
|
87
|
+
const [recordCid] = recordBlock;
|
|
88
|
+
recordBlocks.push(recordBlock);
|
|
89
|
+
mst = await mst.add(path, recordCid);
|
|
90
|
+
ops.push({ action: 'create', path, cid: recordCid });
|
|
91
|
+
addPutEffect(sideEffects, results, uri, recordCid, write);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const dereferencedBlobKeys = await findDereferencedBlobKeys(env, did, sideEffects);
|
|
95
|
+
const currentRoot = await mst.getPointer();
|
|
96
|
+
const newMstBlocks = await collectUnstoredMstBlocks(mst);
|
|
97
|
+
const requiredBlobKeys = sideEffects.flatMap((effect) =>
|
|
98
|
+
effect.action === 'put' ? effect.blobKeys : [],
|
|
99
|
+
);
|
|
100
|
+
await assertBlobKeysAvailable(env, did, requiredBlobKeys);
|
|
101
|
+
|
|
102
|
+
const { commitCid, rev, commitData, sig, blocks } = await bumpRoot(
|
|
103
|
+
env,
|
|
104
|
+
prevMstRoot ?? undefined,
|
|
105
|
+
currentRoot,
|
|
106
|
+
{
|
|
107
|
+
ops,
|
|
108
|
+
newMstBlocks: Array.from(newMstBlocks),
|
|
109
|
+
newRecordBlocks: recordBlocks,
|
|
110
|
+
expectedCommitCid,
|
|
111
|
+
requiredBlobKeys,
|
|
112
|
+
sideEffectStatements: (guard) => sideEffects.flatMap((effect) => {
|
|
113
|
+
if (effect.action === 'delete') {
|
|
114
|
+
return [
|
|
115
|
+
...deleteRecordStatements(env, effect.uri, guard),
|
|
116
|
+
...setRecordBlobUsageStatements(env, did, effect.uri, [], guard),
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
return [
|
|
120
|
+
...putRecordStatements(env, {
|
|
121
|
+
uri: effect.uri,
|
|
122
|
+
did,
|
|
123
|
+
cid: effect.cid,
|
|
124
|
+
json: JSON.stringify(effect.record),
|
|
125
|
+
}, guard),
|
|
126
|
+
...setRecordBlobUsageStatements(env, did, effect.uri, effect.blobKeys, guard),
|
|
127
|
+
];
|
|
128
|
+
}),
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
commit: { cid: commitCid, rev },
|
|
134
|
+
commitCid,
|
|
135
|
+
rev,
|
|
136
|
+
ops,
|
|
137
|
+
commitData,
|
|
138
|
+
sig,
|
|
139
|
+
blocks,
|
|
140
|
+
results,
|
|
141
|
+
dereferencedBlobKeys: dereferencedBlobKeys.map((key) => ({ did, key })),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function addPutEffect(
|
|
146
|
+
sideEffects: SideEffect[],
|
|
147
|
+
results: BatchCommitResult['results'],
|
|
148
|
+
uri: string,
|
|
149
|
+
recordCid: CID,
|
|
150
|
+
write: Exclude<PreparedWrite, { action: 'delete' }>,
|
|
151
|
+
): void {
|
|
152
|
+
const cid = recordCid.toString();
|
|
153
|
+
sideEffects.push({
|
|
154
|
+
action: 'put',
|
|
155
|
+
uri,
|
|
156
|
+
cid,
|
|
157
|
+
record: write.record,
|
|
158
|
+
blobKeys: write.blobKeys,
|
|
159
|
+
});
|
|
160
|
+
results.push({
|
|
161
|
+
$type: `com.atproto.repo.applyWrites#${write.action}Result`,
|
|
162
|
+
uri,
|
|
163
|
+
cid,
|
|
164
|
+
validationStatus: write.validationStatus,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function findDereferencedBlobKeys(
|
|
169
|
+
env: Env,
|
|
170
|
+
did: string,
|
|
171
|
+
sideEffects: SideEffect[],
|
|
172
|
+
): Promise<string[]> {
|
|
173
|
+
const previousBlobKeysByUri = new Map<string, string[]>();
|
|
174
|
+
const finalBlobKeysByUri = new Map<string, string[]>();
|
|
175
|
+
for (const effect of sideEffects) {
|
|
176
|
+
if (!previousBlobKeysByUri.has(effect.uri)) {
|
|
177
|
+
previousBlobKeysByUri.set(effect.uri, await getRecordBlobKeys(env, did, effect.uri));
|
|
178
|
+
}
|
|
179
|
+
finalBlobKeysByUri.set(effect.uri, effect.action === 'put' ? effect.blobKeys : []);
|
|
180
|
+
}
|
|
181
|
+
return Array.from(previousBlobKeysByUri).flatMap(([uri, previousKeys]) => {
|
|
182
|
+
const finalKeys = finalBlobKeysByUri.get(uri) ?? [];
|
|
183
|
+
return previousKeys.filter((key) => !finalKeys.includes(key));
|
|
184
|
+
});
|
|
185
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Env } from '../../env';
|
|
2
|
+
import { RepoWriteError } from '../../lib/repo-write-error';
|
|
3
|
+
import { collectBlobRefs } from '../../lib/repo-write-data';
|
|
4
|
+
|
|
5
|
+
export async function assertBlobKeysAvailable(env: Env, did: string, keys: string[]): Promise<void> {
|
|
6
|
+
for (const key of new Set(keys)) {
|
|
7
|
+
const row = await env.ALTERAN_DB.prepare(
|
|
8
|
+
'SELECT cid, size FROM blob WHERE did = ? AND key = ? LIMIT 1',
|
|
9
|
+
)
|
|
10
|
+
.bind(did, key)
|
|
11
|
+
.first<{ cid: string; size: number }>();
|
|
12
|
+
if (!row) {
|
|
13
|
+
throw new RepoWriteError('BlobNotFound', 'blob not found');
|
|
14
|
+
}
|
|
15
|
+
const object = await env.ALTERAN_BLOBS.head(key);
|
|
16
|
+
if (!object || object.size !== Number(row.size)) {
|
|
17
|
+
throw new RepoWriteError('BlobNotFound', `blob not found: ${row.cid}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function resolveRecordBlobKeys(env: Env, did: string, record: unknown): Promise<string[]> {
|
|
23
|
+
const refs: Array<{ cid: string; mimeType: string; size: number }> = [];
|
|
24
|
+
collectBlobRefs(record, refs);
|
|
25
|
+
const keys = new Set<string>();
|
|
26
|
+
for (const ref of refs) {
|
|
27
|
+
const row = await env.ALTERAN_DB.prepare(
|
|
28
|
+
'SELECT key, mime, size FROM blob WHERE did = ? AND cid = ? LIMIT 1',
|
|
29
|
+
)
|
|
30
|
+
.bind(did, ref.cid)
|
|
31
|
+
.first<{ key: string; mime: string; size: number }>();
|
|
32
|
+
if (!row) {
|
|
33
|
+
throw new RepoWriteError('BlobNotFound', `blob not found: ${ref.cid}`);
|
|
34
|
+
}
|
|
35
|
+
if (row.mime !== ref.mimeType) {
|
|
36
|
+
throw new RepoWriteError('InvalidMimeType', `blob mime type mismatch: ${ref.cid}`);
|
|
37
|
+
}
|
|
38
|
+
if (Number(row.size) !== ref.size) {
|
|
39
|
+
throw new RepoWriteError('InvalidSize', `blob size mismatch: ${ref.cid}`);
|
|
40
|
+
}
|
|
41
|
+
const object = await env.ALTERAN_BLOBS.head(row.key);
|
|
42
|
+
if (!object || object.size !== Number(row.size)) {
|
|
43
|
+
throw new RepoWriteError('BlobNotFound', `blob not found: ${ref.cid}`);
|
|
44
|
+
}
|
|
45
|
+
keys.add(row.key);
|
|
46
|
+
}
|
|
47
|
+
return Array.from(keys);
|
|
48
|
+
}
|
|
@@ -1,29 +1,71 @@
|
|
|
1
1
|
import { CID } from 'multiformats/cid';
|
|
2
2
|
import * as dagCbor from '@ipld/dag-cbor';
|
|
3
|
+
import { fromString as bytesFromString } from 'uint8arrays/from-string';
|
|
3
4
|
import { cidForCbor } from '../../lib/mst/util';
|
|
4
|
-
import type {
|
|
5
|
+
import type { MST, BlockMap } from '../../lib/mst';
|
|
6
|
+
import { RepoWriteError } from '../../lib/repo-write-error';
|
|
5
7
|
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
+
export type EncodedBlock = readonly [CID, Uint8Array];
|
|
9
|
+
|
|
10
|
+
export function recordToIpld(record: unknown, depth = 0): unknown {
|
|
11
|
+
if (depth > 100) {
|
|
12
|
+
throw new RepoWriteError('InvalidRequest', 'record is too deeply nested');
|
|
13
|
+
}
|
|
14
|
+
if (Array.isArray(record)) {
|
|
15
|
+
return record.map((item) => recordToIpld(item, depth + 1));
|
|
16
|
+
}
|
|
17
|
+
if (!record || typeof record !== 'object') {
|
|
18
|
+
return record;
|
|
19
|
+
}
|
|
20
|
+
if (record instanceof Uint8Array || CID.asCID(record)) {
|
|
21
|
+
return record;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const obj = record as Record<string, unknown>;
|
|
25
|
+
const keys = Object.keys(obj);
|
|
26
|
+
if (keys.length === 1 && typeof obj.$link === 'string') {
|
|
27
|
+
return CID.parse(obj.$link);
|
|
28
|
+
}
|
|
29
|
+
if (keys.length === 1 && typeof obj.$bytes === 'string') {
|
|
30
|
+
return bytesFromString(obj.$bytes, 'base64');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const converted: Record<string, unknown> = Object.create(null);
|
|
34
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
35
|
+
if (key === '__proto__') {
|
|
36
|
+
throw new RepoWriteError('InvalidRequest', 'record contains a forbidden object key');
|
|
37
|
+
}
|
|
38
|
+
converted[key] = recordToIpld(value, depth + 1);
|
|
39
|
+
}
|
|
40
|
+
return converted;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function cidForRecord(record: unknown): Promise<CID> {
|
|
44
|
+
try {
|
|
45
|
+
return cidForCbor(recordToIpld(record));
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error instanceof RepoWriteError) throw error;
|
|
48
|
+
throw new RepoWriteError('InvalidRequest', 'record is not dag-cbor encodable');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function encodeRecordBlock(
|
|
8
53
|
record: unknown,
|
|
9
|
-
): Promise<
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
54
|
+
): Promise<EncodedBlock> {
|
|
55
|
+
try {
|
|
56
|
+
const ipldRecord = recordToIpld(record);
|
|
57
|
+
const bytes = dagCbor.encode(ipldRecord);
|
|
58
|
+
const cid = await cidForCbor(ipldRecord);
|
|
59
|
+
return [cid, bytes] as const;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error instanceof RepoWriteError) throw error;
|
|
62
|
+
throw new RepoWriteError('InvalidRequest', 'record is not dag-cbor encodable');
|
|
63
|
+
}
|
|
14
64
|
}
|
|
15
65
|
|
|
16
|
-
export async function
|
|
17
|
-
blockstore: D1Blockstore,
|
|
66
|
+
export async function collectUnstoredMstBlocks(
|
|
18
67
|
mst: MST,
|
|
19
68
|
): Promise<BlockMap> {
|
|
20
69
|
const diff = await mst.getUnstoredBlocks();
|
|
21
|
-
for (const [cid, bytes] of diff.blocks) {
|
|
22
|
-
console.log(
|
|
23
|
-
`[RepoManager] Storing new MST block: ${cid.toString()}, size: ${bytes.length}`,
|
|
24
|
-
);
|
|
25
|
-
await blockstore.put(cid, bytes);
|
|
26
|
-
}
|
|
27
|
-
console.log(`[RepoManager] Stored ${diff.blocks.size} new MST blocks`);
|
|
28
70
|
return diff.blocks;
|
|
29
71
|
}
|