Skip to content

Commit e1d177b

Browse files
authored
feat: API improvements
2 parents f68e043 + 3299536 commit e1d177b

15 files changed

Lines changed: 4051 additions & 33 deletions

File tree

public/.well-known/ai.txt

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ GET /api/collections/:owner/:slug/versions/latest → latest version with
6969
GET /api/collections/:owner/:slug/versions/:n → specific version by number
7070

7171
### Read records and files
72-
GET /api/collections/:owner/:slug/versions/:n/records → records for a version (?type=TypeName&limit=100&offset=0)
72+
GET /api/collections/:owner/:slug/versions/:n/records → records for a version (?type=TypeName&limit=100&after=recordId)
7373
GET /api/collections/:owner/:slug/versions/:n/manifest → lightweight manifest: record ids/types + file hashes
7474
GET /api/collections/:owner/:slug/files/:hash → download a file by hash
7575
HEAD /api/collections/:owner/:slug/files/:hash → check if a file exists (returns Content-Length, Content-Type)
@@ -185,6 +185,119 @@ Set base_version to null. Put all records in changes.added. Include schemas for
185185

186186
---
187187

188+
## Chunked Push: Large Uploads
189+
190+
For pushes exceeding 100MB (or millions of records), use the chunked upload protocol. This avoids
191+
body size limits and memory pressure by streaming changes in batches.
192+
193+
### Step 1: Start an upload session
194+
POST /api/collections/:owner/:slug/versions/upload
195+
Authorization: Bearer ul_<key>
196+
197+
{
198+
"base_version": 3,
199+
"message": "Bulk import 2M records",
200+
"app_id": "my-app",
201+
"schemas": { ... }
202+
}
203+
204+
Response (201):
205+
{
206+
"sessionId": "uuid-of-session",
207+
"expiresAt": "2026-04-30T01:00:00.000Z"
208+
}
209+
210+
Sessions expire after 1 hour.
211+
212+
### Step 2: Append batches (repeat as needed)
213+
PUT /api/collections/:owner/:slug/versions/upload/:sessionId
214+
Authorization: Bearer ul_<key>
215+
216+
{
217+
"changes": {
218+
"added": [ ...up to 10,000 records... ],
219+
"updated": [ ... ],
220+
"removed": ["id-1", "id-2"]
221+
}
222+
}
223+
224+
Response:
225+
{
226+
"received": { "added": 5000, "updated": 0, "removed": 0 },
227+
"totalStaged": 15000
228+
}
229+
230+
Each batch can contain up to 10,000 records. Call this endpoint as many times as needed.
231+
If a record ID is sent more than once, the last write wins (upsert semantics).
232+
233+
### Step 3: Finalize the version
234+
POST /api/collections/:owner/:slug/versions/upload/:sessionId/finalize
235+
Authorization: Bearer ul_<key>
236+
237+
Response (201): same as regular push
238+
{
239+
"version": 4,
240+
"semver": "v1.3.0",
241+
"hash": "...",
242+
"recordCount": 2000000,
243+
"fileCount": 5
244+
}
245+
246+
Finalize applies all staged changes to the base version, validates against schemas,
247+
computes hashes, and creates the new immutable version.
248+
249+
### Errors during finalize
250+
- 409 Version conflict: someone pushed since your base_version. Start a new session.
251+
- 422 Schema validation failed: one or more records don't match. Fix and re-upload.
252+
- 422 Missing files: records reference files not yet uploaded. Upload them and retry finalize.
253+
- 410 Session expired: the 1-hour window elapsed. Start a new session.
254+
255+
### Cancel a session
256+
DELETE /api/collections/:owner/:slug/versions/upload/:sessionId
257+
Response: 204 (staged records are discarded)
258+
259+
### Check session status
260+
GET /api/collections/:owner/:slug/versions/upload/:sessionId
261+
Response: { sessionId, status, recordCount, baseVersion, expiresAt, createdAt }
262+
263+
Status values: open, finalizing, completed, failed, expired.
264+
265+
Note: On successful finalize, the session and all staged records are deleted from the database.
266+
There is no need to manually clean up completed sessions.
267+
268+
---
269+
270+
## Pagination (Records Endpoint)
271+
272+
The records endpoint uses cursor-based pagination for efficient traversal of large collections.
273+
274+
GET /api/collections/:owner/:slug/versions/:n/records?limit=100&after=record-id-42
275+
276+
Response:
277+
{
278+
"records": [ ...up to `limit` records... ],
279+
"pagination": {
280+
"limit": 100,
281+
"hasMore": true,
282+
"nextCursor": "record-id-142",
283+
"total": 2000000
284+
}
285+
}
286+
287+
Parameters:
288+
- limit: max records per page (default 100, max 1000)
289+
- after: cursor (record ID) — return records with IDs lexicographically after this value
290+
- offset: legacy offset-based pagination (still supported, but cursor is preferred for large sets)
291+
- type: filter by record type
292+
293+
To paginate through all records:
294+
1. First request: GET .../records?limit=1000
295+
2. If pagination.hasMore is true, use pagination.nextCursor for the next request:
296+
GET .../records?limit=1000&after=<nextCursor>
297+
3. Repeat until hasMore is false.
298+
299+
---
300+
188301
## Record Format
189302

190303
{

src/api/routes/files.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { FastifyInstance } from "fastify";
22
import { eq, and, sql } from "drizzle-orm";
33
import { db, schema } from "../../db/index.js";
44
import { requireAuth } from "../plugins/auth.js";
5-
import { uploadToS3, downloadFromS3, headS3Object } from "../../lib/s3.js";
5+
import { uploadToS3 } from "../../lib/s3.js";
66
import { createHash } from "node:crypto";
77

88
/**
@@ -166,12 +166,9 @@ export async function fileRoutes(app: FastifyInstance) {
166166
return reply.status(404).send({ error: "File not found", statusCode: 404 });
167167
}
168168

169-
const buffer = await downloadFromS3(file.storageKey);
170-
reply.header("Content-Type", file.mimeType);
171-
reply.header("Content-Length", file.size);
172-
reply.header("Cache-Control", "public, max-age=31536000, immutable");
173-
reply.header("ETag", `"${cleanHash}"`);
174-
return reply.send(buffer);
169+
// Redirect to CDN
170+
const cdnUrl = `https://assets.underlay.org/files/${cleanHash.slice(0, 2)}/${cleanHash.slice(2, 4)}/${cleanHash}`;
171+
return reply.redirect(301, cdnUrl);
175172
});
176173

177174
// Upload file

0 commit comments

Comments
 (0)