@alteran/astro 0.7.7 → 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/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
|
@@ -71,6 +71,27 @@
|
|
|
71
71
|
"when": 1778624701795,
|
|
72
72
|
"tag": "0009_oauth_session_state",
|
|
73
73
|
"breakpoints": true
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"idx": 10,
|
|
77
|
+
"version": "6",
|
|
78
|
+
"when": 1778710862250,
|
|
79
|
+
"tag": "0010_eminent_klaw",
|
|
80
|
+
"breakpoints": true
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"idx": 11,
|
|
84
|
+
"version": "6",
|
|
85
|
+
"when": 1778754994653,
|
|
86
|
+
"tag": "0011_chief_darwin",
|
|
87
|
+
"breakpoints": true
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"idx": 12,
|
|
91
|
+
"version": "6",
|
|
92
|
+
"when": 1778790000000,
|
|
93
|
+
"tag": "0012_backfill_blob_usage",
|
|
94
|
+
"breakpoints": true
|
|
74
95
|
}
|
|
75
96
|
]
|
|
76
|
-
}
|
|
97
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alteran/astro",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Astro integration for running a Cloudflare-hosted Bluesky PDS with Alteran.",
|
|
5
5
|
"module": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -25,54 +25,37 @@
|
|
|
25
25
|
"publishConfig": {
|
|
26
26
|
"access": "public"
|
|
27
27
|
},
|
|
28
|
-
"scripts": {
|
|
29
|
-
"dev": "astro dev",
|
|
30
|
-
"build": "astro build",
|
|
31
|
-
"preview": "astro preview",
|
|
32
|
-
"test:oauth": "bun test tests/oauth-*.test.ts tests/resource-auth.test.ts",
|
|
33
|
-
"deploy": "astro build && bunx wrangler deploy --env production",
|
|
34
|
-
"iac:deploy": "bunx alchemy deploy iac/alchemy.run.ts",
|
|
35
|
-
"iac:destroy": "bunx alchemy destroy iac/alchemy.run.ts",
|
|
36
|
-
"iac:plan": "bunx alchemy plan iac/alchemy.run.ts",
|
|
37
|
-
"db:generate": "bunx drizzle-kit generate",
|
|
38
|
-
"db:apply": "bunx wrangler d1 migrations apply pds",
|
|
39
|
-
"db:apply:local": "bunx wrangler d1 migrations apply pds --local",
|
|
40
|
-
"db:apply:local:direct": "wrangler d1 migrations apply pds --local",
|
|
41
|
-
"db:reset:local": "rm -rf .wrangler/state && rm -rf migrations && bun run db:generate && bun run db:apply:local",
|
|
42
|
-
"secrets:setup": "bun run scripts/setup-secrets.ts",
|
|
43
|
-
"relay:request-crawl": "bun run scripts/request-crawl.ts",
|
|
44
|
-
"pds:test-create-session": "bun run scripts/test-create-session.ts"
|
|
45
|
-
},
|
|
28
|
+
"scripts": {},
|
|
46
29
|
"devDependencies": {
|
|
47
|
-
"@astrojs/cloudflare": "^12.6.
|
|
48
|
-
"@
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"vite": "^6.3.6",
|
|
54
|
-
"wrangler": "^4.40.3"
|
|
30
|
+
"@astrojs/cloudflare": "^12.6.13",
|
|
31
|
+
"@cloudflare/vite-plugin": "^1.37.0",
|
|
32
|
+
"drizzle-kit": "^0.31.10",
|
|
33
|
+
"miniflare": "^3.20250718.3",
|
|
34
|
+
"vite": "^6.4.2",
|
|
35
|
+
"wrangler": "^4.91.0"
|
|
55
36
|
},
|
|
56
37
|
"peerDependencies": {
|
|
57
38
|
"typescript": "^5"
|
|
58
39
|
},
|
|
59
40
|
"dependencies": {
|
|
60
|
-
"@atproto/
|
|
61
|
-
"@
|
|
41
|
+
"@atproto/api": "^0.17.7",
|
|
42
|
+
"@atproto/crypto": "^0.4.5",
|
|
43
|
+
"@atproto/syntax": "^0.4.3",
|
|
44
|
+
"@cloudflare/workers-types": "^4.20260511.1",
|
|
62
45
|
"@did-plc/lib": "^0.0.4",
|
|
63
|
-
"@iconify-json/simple-icons": "^1.2.
|
|
64
|
-
"@ipld/car": "^5.4.
|
|
65
|
-
"@ipld/dag-cbor": "^9.2.
|
|
66
|
-
"@noble/hashes": "^2.0
|
|
67
|
-
"astro": "^6.
|
|
46
|
+
"@iconify-json/simple-icons": "^1.2.82",
|
|
47
|
+
"@ipld/car": "^5.4.6",
|
|
48
|
+
"@ipld/dag-cbor": "^9.2.7",
|
|
49
|
+
"@noble/hashes": "^2.2.0",
|
|
50
|
+
"astro": "^6.3.3",
|
|
68
51
|
"astro-icon": "^1.1.5",
|
|
69
|
-
"dotenv": "^17.2
|
|
70
|
-
"drizzle-orm": "^0.44.
|
|
71
|
-
"get-port": "^7.
|
|
72
|
-
"hono": "^4.
|
|
73
|
-
"multiformats": "^13.4.
|
|
74
|
-
"uint8arrays": "^5.1.
|
|
75
|
-
"jose": "^5.
|
|
52
|
+
"dotenv": "^17.4.2",
|
|
53
|
+
"drizzle-orm": "^0.44.7",
|
|
54
|
+
"get-port": "^7.2.0",
|
|
55
|
+
"hono": "^4.12.18",
|
|
56
|
+
"multiformats": "^13.4.2",
|
|
57
|
+
"uint8arrays": "^5.1.1",
|
|
58
|
+
"jose": "^5.10.0"
|
|
76
59
|
},
|
|
77
60
|
"repository": {
|
|
78
61
|
"type": "git",
|
package/src/db/blob.ts
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { eq, sql } from 'drizzle-orm';
|
|
2
|
+
import { getDb } from './client';
|
|
3
|
+
import { blob_ref, blob_quota } from './schema';
|
|
4
|
+
import type { Env } from '../env';
|
|
5
|
+
|
|
6
|
+
export type BlobKeyRef = {
|
|
7
|
+
did: string;
|
|
8
|
+
key: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type BlobRefMetadata = {
|
|
12
|
+
did: string;
|
|
13
|
+
cid: string;
|
|
14
|
+
key: string;
|
|
15
|
+
mime: string;
|
|
16
|
+
size: number;
|
|
17
|
+
uploadedAt: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type BlobRegistrationResult =
|
|
21
|
+
| { tag: 'registered'; blob: BlobRefMetadata }
|
|
22
|
+
| { tag: 'alreadyExists'; blob: BlobRefMetadata }
|
|
23
|
+
| { tag: 'quotaExceeded' };
|
|
24
|
+
|
|
25
|
+
export async function putBlobRef(env: Env, did: string, cid: string, key: string, mime: string, size: number) {
|
|
26
|
+
const db = getDb(env);
|
|
27
|
+
const uploadedAt = Date.now();
|
|
28
|
+
await db
|
|
29
|
+
.insert(blob_ref)
|
|
30
|
+
.values({ did, cid, key, mime, size, uploadedAt })
|
|
31
|
+
.onConflictDoUpdate({
|
|
32
|
+
target: [blob_ref.did, blob_ref.cid],
|
|
33
|
+
set: {
|
|
34
|
+
key: sql.raw(`excluded.${blob_ref.key.name}`),
|
|
35
|
+
mime: sql.raw(`excluded.${blob_ref.mime.name}`),
|
|
36
|
+
size: sql.raw(`excluded.${blob_ref.size.name}`),
|
|
37
|
+
uploadedAt: sql.raw(`excluded.${blob_ref.uploadedAt.name}`),
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function registerBlobRefWithQuota(
|
|
43
|
+
env: Env,
|
|
44
|
+
did: string,
|
|
45
|
+
cid: string,
|
|
46
|
+
key: string,
|
|
47
|
+
mime: string,
|
|
48
|
+
size: number,
|
|
49
|
+
): Promise<BlobRegistrationResult> {
|
|
50
|
+
const existing = await getBlobRef(env, did, cid);
|
|
51
|
+
if (existing) {
|
|
52
|
+
const uploadedAt = Date.now();
|
|
53
|
+
await env.ALTERAN_DB.batch([
|
|
54
|
+
env.ALTERAN_DB.prepare(
|
|
55
|
+
`UPDATE blob
|
|
56
|
+
SET uploaded_at = ?
|
|
57
|
+
WHERE did = ? AND cid = ?`,
|
|
58
|
+
).bind(uploadedAt, did, cid),
|
|
59
|
+
env.ALTERAN_DB.prepare(
|
|
60
|
+
`UPDATE blob_quota
|
|
61
|
+
SET updated_at = ?
|
|
62
|
+
WHERE did = ?`,
|
|
63
|
+
).bind(uploadedAt, did),
|
|
64
|
+
]);
|
|
65
|
+
return { tag: 'alreadyExists', blob: { ...existing, uploadedAt } };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const quotaBytes = parseInt(env.PDS_BLOB_QUOTA_BYTES || '10737418240', 10);
|
|
69
|
+
const maxBytes = Number.isFinite(quotaBytes) && quotaBytes > 0 ? quotaBytes : 10737418240;
|
|
70
|
+
const uploadedAt = Date.now();
|
|
71
|
+
const results = await env.ALTERAN_DB.batch([
|
|
72
|
+
env.ALTERAN_DB.prepare(
|
|
73
|
+
`INSERT OR IGNORE INTO blob_quota (did, total_bytes, blob_count, updated_at)
|
|
74
|
+
VALUES (?, 0, 0, ?)`,
|
|
75
|
+
).bind(did, uploadedAt),
|
|
76
|
+
env.ALTERAN_DB.prepare(
|
|
77
|
+
`UPDATE blob_quota
|
|
78
|
+
SET updated_at = ?
|
|
79
|
+
WHERE did = ?`,
|
|
80
|
+
).bind(uploadedAt, did),
|
|
81
|
+
env.ALTERAN_DB.prepare(
|
|
82
|
+
`INSERT OR IGNORE INTO blob (did, cid, key, mime, size, uploaded_at)
|
|
83
|
+
SELECT ?, ?, ?, ?, ?, ?
|
|
84
|
+
FROM blob_quota
|
|
85
|
+
WHERE did = ?
|
|
86
|
+
AND total_bytes + ? <= ?`,
|
|
87
|
+
).bind(did, cid, key, mime, size, uploadedAt, did, size, maxBytes),
|
|
88
|
+
env.ALTERAN_DB.prepare(
|
|
89
|
+
`UPDATE blob_quota
|
|
90
|
+
SET total_bytes = (
|
|
91
|
+
SELECT COALESCE(SUM(size), 0) FROM blob WHERE did = ?
|
|
92
|
+
),
|
|
93
|
+
blob_count = (
|
|
94
|
+
SELECT COUNT(*) FROM blob WHERE did = ?
|
|
95
|
+
),
|
|
96
|
+
updated_at = ?
|
|
97
|
+
WHERE did = ?`,
|
|
98
|
+
).bind(did, did, Date.now(), did),
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
const blob = await getBlobRef(env, did, cid);
|
|
102
|
+
if (blob && changedRows(results[2]) === 1) return { tag: 'registered', blob };
|
|
103
|
+
if (blob) return { tag: 'alreadyExists', blob };
|
|
104
|
+
return { tag: 'quotaExceeded' };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function getRecordBlobKeys(env: Env, did: string, uri: string): Promise<string[]> {
|
|
108
|
+
const result = await env.ALTERAN_DB.prepare(
|
|
109
|
+
'SELECT key FROM blob_usage WHERE did = ? AND record_uri = ?',
|
|
110
|
+
)
|
|
111
|
+
.bind(did, uri)
|
|
112
|
+
.all<{ key: string }>();
|
|
113
|
+
|
|
114
|
+
return result.results?.map((row: { key: string }) => row.key) ?? [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function blobKeyHasUsage(env: Env, did: string, key: string): Promise<boolean> {
|
|
118
|
+
const row = await env.ALTERAN_DB.prepare(
|
|
119
|
+
'SELECT 1 FROM blob_usage WHERE did = ? AND key = ? LIMIT 1',
|
|
120
|
+
)
|
|
121
|
+
.bind(did, key)
|
|
122
|
+
.first();
|
|
123
|
+
|
|
124
|
+
return row !== null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function deleteUnreferencedBlobKeys(
|
|
128
|
+
env: Env,
|
|
129
|
+
refs: readonly BlobKeyRef[],
|
|
130
|
+
): Promise<number> {
|
|
131
|
+
let deleted = 0;
|
|
132
|
+
for (const { did, key } of uniqueBlobKeyRefs(refs)) {
|
|
133
|
+
const blob = await env.ALTERAN_DB.prepare(
|
|
134
|
+
'SELECT 1 FROM blob WHERE did = ? AND key = ? LIMIT 1',
|
|
135
|
+
)
|
|
136
|
+
.bind(did, key)
|
|
137
|
+
.first();
|
|
138
|
+
if (!blob) continue;
|
|
139
|
+
|
|
140
|
+
const cutoff = blobDeletionCutoff();
|
|
141
|
+
const object = await env.ALTERAN_BLOBS.head(key);
|
|
142
|
+
if (object && !uploadedBeforeCutoff(object, cutoff)) continue;
|
|
143
|
+
|
|
144
|
+
const [deletion] = await env.ALTERAN_DB.batch([
|
|
145
|
+
env.ALTERAN_DB.prepare(
|
|
146
|
+
`DELETE FROM blob
|
|
147
|
+
WHERE did = ?
|
|
148
|
+
AND key = ?
|
|
149
|
+
AND uploaded_at <= ?
|
|
150
|
+
AND NOT EXISTS (
|
|
151
|
+
SELECT 1 FROM blob_usage WHERE did = ? AND key = ?
|
|
152
|
+
)`,
|
|
153
|
+
).bind(did, key, cutoff, did, key),
|
|
154
|
+
recomputeQuotaStatement(env, did),
|
|
155
|
+
]);
|
|
156
|
+
if (changedRows(deletion) !== 1) continue;
|
|
157
|
+
|
|
158
|
+
// R2 deletes cannot share D1's commit guard. Metadata is the visibility
|
|
159
|
+
// gate; object cleanup needs a separate lease/tombstone flow.
|
|
160
|
+
deleted++;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return deleted;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function sweepEligibleUnreferencedBlobKeys(
|
|
167
|
+
env: Env,
|
|
168
|
+
options: { did?: string; limit?: number } = {},
|
|
169
|
+
): Promise<number> {
|
|
170
|
+
return deleteUnreferencedBlobKeys(env, await listOrphanBlobRefs(env, {
|
|
171
|
+
did: options.did,
|
|
172
|
+
limit: options.limit ?? 100,
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function listOrphanBlobKeys(env: Env): Promise<string[]> {
|
|
177
|
+
return (await listOrphanBlobRefs(env)).map((ref) => ref.key);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function listOrphanBlobRefs(
|
|
181
|
+
env: Env,
|
|
182
|
+
options: { did?: string; limit?: number } = {},
|
|
183
|
+
): Promise<BlobKeyRef[]> {
|
|
184
|
+
const limit = Math.max(1, Math.min(options.limit ?? 100, 1000));
|
|
185
|
+
const cutoff = blobDeletionCutoff();
|
|
186
|
+
const result = options.did
|
|
187
|
+
? await env.ALTERAN_DB.prepare(
|
|
188
|
+
`SELECT did, key
|
|
189
|
+
FROM blob b
|
|
190
|
+
WHERE b.did = ?
|
|
191
|
+
AND b.uploaded_at <= ?
|
|
192
|
+
AND NOT EXISTS (
|
|
193
|
+
SELECT 1 FROM blob_usage u WHERE u.did = b.did AND u.key = b.key
|
|
194
|
+
)
|
|
195
|
+
ORDER BY b.uploaded_at, b.did, b.key
|
|
196
|
+
LIMIT ?`,
|
|
197
|
+
).bind(options.did, cutoff, limit).all<BlobKeyRef>()
|
|
198
|
+
: await env.ALTERAN_DB.prepare(
|
|
199
|
+
`SELECT did, key
|
|
200
|
+
FROM blob b
|
|
201
|
+
WHERE b.uploaded_at <= ?
|
|
202
|
+
AND NOT EXISTS (
|
|
203
|
+
SELECT 1 FROM blob_usage u WHERE u.did = b.did AND u.key = b.key
|
|
204
|
+
)
|
|
205
|
+
ORDER BY b.uploaded_at, b.did, b.key
|
|
206
|
+
LIMIT ?`,
|
|
207
|
+
).bind(cutoff, limit).all<BlobKeyRef>();
|
|
208
|
+
return result.results ?? [];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function deleteBlobByKey(env: Env, key: string) {
|
|
212
|
+
const db = getDb(env);
|
|
213
|
+
await db.delete(blob_ref).where(eq(blob_ref.key, key)).run();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function getBlobQuota(env: Env, did: string) {
|
|
217
|
+
const db = getDb(env);
|
|
218
|
+
const quota = await db.select().from(blob_quota).where(eq(blob_quota.did, did)).get();
|
|
219
|
+
return quota ?? { did, total_bytes: 0, blob_count: 0, updated_at: Date.now() };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function updateBlobQuota(env: Env, did: string, bytesAdded: number, countAdded: number) {
|
|
223
|
+
const db = getDb(env);
|
|
224
|
+
const current = await getBlobQuota(env, did);
|
|
225
|
+
|
|
226
|
+
const newTotalBytes = Math.max(0, current.total_bytes + bytesAdded);
|
|
227
|
+
const newBlobCount = Math.max(0, current.blob_count + countAdded);
|
|
228
|
+
const now = Date.now();
|
|
229
|
+
|
|
230
|
+
await db
|
|
231
|
+
.insert(blob_quota)
|
|
232
|
+
.values({
|
|
233
|
+
did,
|
|
234
|
+
total_bytes: newTotalBytes,
|
|
235
|
+
blob_count: newBlobCount,
|
|
236
|
+
updated_at: now,
|
|
237
|
+
})
|
|
238
|
+
.onConflictDoUpdate({
|
|
239
|
+
target: blob_quota.did,
|
|
240
|
+
set: {
|
|
241
|
+
total_bytes: sql.raw(`excluded.${blob_quota.total_bytes.name}`),
|
|
242
|
+
blob_count: sql.raw(`excluded.${blob_quota.blob_count.name}`),
|
|
243
|
+
updated_at: sql.raw(`excluded.${blob_quota.updated_at.name}`),
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function checkBlobQuota(env: Env, did: string, additionalBytes: number): Promise<boolean> {
|
|
249
|
+
const quota = await getBlobQuota(env, did);
|
|
250
|
+
const maxBytes = parseInt(env.PDS_BLOB_QUOTA_BYTES || '10737418240', 10);
|
|
251
|
+
|
|
252
|
+
return quota.total_bytes + additionalBytes <= maxBytes;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function deleteUnreferencedBlobRef(env: Env, did: string, cid: string): Promise<void> {
|
|
256
|
+
await env.ALTERAN_DB.batch([
|
|
257
|
+
env.ALTERAN_DB.prepare(
|
|
258
|
+
`DELETE FROM blob
|
|
259
|
+
WHERE did = ?
|
|
260
|
+
AND cid = ?
|
|
261
|
+
AND NOT EXISTS (
|
|
262
|
+
SELECT 1 FROM blob_usage u WHERE u.did = blob.did AND u.key = blob.key
|
|
263
|
+
)`,
|
|
264
|
+
).bind(did, cid),
|
|
265
|
+
recomputeQuotaStatement(env, did),
|
|
266
|
+
]);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function uniqueBlobKeyRefs(refs: readonly BlobKeyRef[]): BlobKeyRef[] {
|
|
270
|
+
const seen = new Set<string>();
|
|
271
|
+
const unique: BlobKeyRef[] = [];
|
|
272
|
+
for (const ref of refs) {
|
|
273
|
+
const id = `${ref.did}\0${ref.key}`;
|
|
274
|
+
if (seen.has(id)) continue;
|
|
275
|
+
seen.add(id);
|
|
276
|
+
unique.push(ref);
|
|
277
|
+
}
|
|
278
|
+
return unique;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function blobDeletionCutoff(): number {
|
|
282
|
+
return Date.now() - 60 * 60 * 1000;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function changedRows(result: unknown): number {
|
|
286
|
+
const meta = (result as { meta?: Record<string, unknown> } | undefined)?.meta;
|
|
287
|
+
const changes = meta?.changes ?? meta?.rows_written ?? meta?.rowsWritten;
|
|
288
|
+
return typeof changes === 'number' ? changes : 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function recomputeQuotaStatement(env: Env, did: string) {
|
|
292
|
+
return env.ALTERAN_DB.prepare(
|
|
293
|
+
`UPDATE blob_quota
|
|
294
|
+
SET total_bytes = (
|
|
295
|
+
SELECT COALESCE(SUM(size), 0) FROM blob WHERE did = ?
|
|
296
|
+
),
|
|
297
|
+
blob_count = (
|
|
298
|
+
SELECT COUNT(*) FROM blob WHERE did = ?
|
|
299
|
+
),
|
|
300
|
+
updated_at = ?
|
|
301
|
+
WHERE did = ?`,
|
|
302
|
+
).bind(did, did, Date.now(), did);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function getBlobRef(env: Env, did: string, cid: string): Promise<BlobRefMetadata | null> {
|
|
306
|
+
const row = await env.ALTERAN_DB.prepare(
|
|
307
|
+
`SELECT did, cid, key, mime, size, uploaded_at as uploadedAt
|
|
308
|
+
FROM blob
|
|
309
|
+
WHERE did = ? AND cid = ?
|
|
310
|
+
LIMIT 1`,
|
|
311
|
+
).bind(did, cid).first<BlobRefMetadata>();
|
|
312
|
+
return row ?? null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function uploadedBeforeCutoff(object: { uploaded?: Date | string | number }, cutoff: number): boolean {
|
|
316
|
+
const uploaded = object.uploaded;
|
|
317
|
+
if (uploaded instanceof Date) return uploaded.getTime() <= cutoff;
|
|
318
|
+
if (typeof uploaded === 'string' || typeof uploaded === 'number') {
|
|
319
|
+
const timestamp = new Date(uploaded).getTime();
|
|
320
|
+
return Number.isFinite(timestamp) && timestamp <= cutoff;
|
|
321
|
+
}
|
|
322
|
+
return false;
|
|
323
|
+
}
|