@@ -16,9 +16,10 @@ const auth = @import("auth.zig");
1616const collection = @import ("collection.zig" );
1717const Database = collection .Database ;
1818
19- const MAX_REQ = 65536 ; // 64 KiB
19+ const MAX_REQ = 65536 ; // 64 KiB (initial read)
2020const MAX_RESP = 131072 ; // 128 KiB
2121const MAX_BODY = 65536 ; // 64 KiB
22+ const MAX_BULK = 16 * 1024 * 1024 ; // 16 MiB for bulk inserts
2223
2324// Heap-allocated per-connection buffers (threadlocal pointers set in handleConn).
2425// This avoids large threadlocal TLS segments that break in Release mode on macOS.
@@ -181,11 +182,45 @@ fn handleConn(srv: *Server, conn: std.net.Server.Connection) void {
181182 defer tl_bufs = null ;
182183
183184 while (true ) {
184- const n = conn .stream .read (& bufs .req ) catch return ;
185+ var n = conn .stream .read (& bufs .req ) catch return ;
185186 if (n == 0 ) return ;
186187 _ = srv .req_count .fetchAdd (1 , .monotonic );
187188
188- const resp_len = dispatch (srv , bufs .req [0.. n ], std .heap .page_allocator );
189+ // For bulk inserts: read the full body based on Content-Length.
190+ // The initial read may only contain part of a large body.
191+ const initial = bufs .req [0.. n ];
192+ const content_length = extractContentLength (initial );
193+ if (content_length > MAX_REQ and content_length <= MAX_BULK ) {
194+ // Check this is actually a bulk request before allocating
195+ const is_bulk = std .mem .indexOf (u8 , initial [0.. @min (n , 256 )], "/bulk" ) != null ;
196+ if (is_bulk ) {
197+ // Find where headers end
198+ const header_end = if (std .mem .indexOf (u8 , initial , "\r \n \r \n " )) | p | p + 4
199+ else if (std .mem .indexOf (u8 , initial , "\n\n " )) | p | p + 2
200+ else n ;
201+ const total_size = header_end + content_length ;
202+ if (total_size <= MAX_BULK ) {
203+ const big_buf = std .heap .page_allocator .alloc (u8 , total_size ) catch {
204+ const resp_len = dispatch (srv , initial , std .heap .page_allocator );
205+ conn .stream .writeAll (bufs .resp [0.. resp_len ]) catch return ;
206+ continue ;
207+ };
208+ defer std .heap .page_allocator .free (big_buf );
209+ @memcpy (big_buf [0.. n ], initial );
210+ // Read remaining bytes
211+ while (n < total_size ) {
212+ const r = conn .stream .read (big_buf [n .. total_size ]) catch break ;
213+ if (r == 0 ) break ;
214+ n += r ;
215+ }
216+ const resp_len = dispatch (srv , big_buf [0.. n ], std .heap .page_allocator );
217+ conn .stream .writeAll (bufs .resp [0.. resp_len ]) catch return ;
218+ continue ;
219+ }
220+ }
221+ }
222+
223+ const resp_len = dispatch (srv , initial , std .heap .page_allocator );
189224 conn .stream .writeAll (bufs .resp [0.. resp_len ]) catch return ;
190225 }
191226}
@@ -338,6 +373,9 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize {
338373 const col_name = rest [0.. sep ];
339374 const key = rest [sep + 1 .. ];
340375 const tenant_id = requestTenant (raw , query );
376+ // POST /db/:col/bulk — bulk insert
377+ if (std .mem .eql (u8 , key , "bulk" ) and std .mem .eql (u8 , method , "POST" ))
378+ return handleBulkInsert (srv , tenant_id , col_name , body , alloc );
341379 if (std .mem .eql (u8 , method , "GET" )) return handleGet (srv , tenant_id , col_name , key , requestAsOf (raw , query ));
342380 if (std .mem .eql (u8 , method , "PUT" )) return handleUpdate (srv , tenant_id , col_name , key , body , alloc );
343381 if (std .mem .eql (u8 , method , "DELETE" )) return handleDelete (srv , tenant_id , col_name , key );
@@ -383,6 +421,49 @@ fn doInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, key: []co
383421 return ok (getBodyBuf ()[0.. fbs .pos ]);
384422}
385423
424+ /// POST /db/:col/bulk — insert multiple documents in one request.
425+ /// Body: NDJSON — one {"key":"...","value":"..."} per line.
426+ /// Response: {"inserted":N,"errors":M,"collection":"...","tenant":"..."}
427+ fn handleBulkInsert (srv : * Server , tenant_id : []const u8 , col_name : []const u8 , body : []const u8 , alloc : std.mem.Allocator ) usize {
428+ _ = alloc ;
429+ const start_ns = std .time .nanoTimestamp ();
430+ srv .db .recordTenantOperation (tenant_id ) catch return err (429 , "tenant ops quota exceeded" );
431+ const col = srv .db .collectionForTenant (tenant_id , col_name ) catch return err (500 , "open collection failed" );
432+
433+ var inserted : u32 = 0 ;
434+ var errors : u32 = 0 ;
435+ var total_bytes : u64 = 0 ;
436+
437+ // Parse NDJSON: iterate lines, each is a {"key":"...","value":...} object
438+ var pos : usize = 0 ;
439+ while (pos < body .len ) {
440+ // Find end of line
441+ const line_end = std .mem .indexOfScalarPos (u8 , body , pos , '\n ' ) orelse body .len ;
442+ const line = std .mem .trim (u8 , body [pos .. line_end ], " \t \r " );
443+ pos = line_end + 1 ;
444+
445+ if (line .len < 2 ) continue ; // skip empty lines
446+
447+ // Extract key from this JSON line
448+ const key = jsonStr (line , "key" ) orelse continue ;
449+
450+ // Use the full line as the value (TurboDB stores the raw JSON)
451+ _ = col .insert (key , line ) catch {
452+ errors += 1 ;
453+ continue ;
454+ };
455+ inserted += 1 ;
456+ total_bytes += line .len ;
457+ }
458+
459+ srv .recordQueryCost (tenant_id , "bulk_insert" , inserted , total_bytes , start_ns );
460+
461+ var fbs = std .io .fixedBufferStream (getBodyBuf ());
462+ std .fmt .format (fbs .writer (),
463+ "{{\" inserted\" :{d},\" errors\" :{d},\" collection\" :\" {s}\" ,\" tenant\" :\" {s}\" }}" ,
464+ .{ inserted , errors , col_name , tenant_id }) catch {};
465+ return ok (getBodyBuf ()[0.. fbs .pos ]);
466+ }
386467fn handleGet (srv : * Server , tenant_id : []const u8 , col_name : []const u8 , key : []const u8 , as_of : ? i64 ) usize {
387468 const start_ns = std .time .nanoTimestamp ();
388469 srv .db .recordTenantOperation (tenant_id ) catch return err (429 , "tenant ops quota exceeded" );
@@ -787,6 +868,24 @@ fn respond(code: u16, status: []const u8, body: []const u8) usize {
787868
788869// ─── mini parsers ────────────────────────────────────────────────────────
789870
871+ fn extractContentLength (raw : []const u8 ) usize {
872+ // Case-insensitive search for Content-Length header
873+ const headers = raw [0.. @min (raw .len , 2048 )]; // only scan headers
874+ const needle = "ontent-length: " ; // skip first char for case insensitivity
875+ var i : usize = 0 ;
876+ while (i + needle .len < headers .len ) : (i += 1 ) {
877+ if ((headers [i ] == 'C' or headers [i ] == 'c' ) and std .mem .eql (u8 , headers [i + 1 .. i + 1 + needle .len ], needle )) {
878+ const start = i + 1 + needle .len ;
879+ var end = start ;
880+ while (end < headers .len and headers [end ] >= '0' and headers [end ] <= '9' ) : (end += 1 ) {}
881+ if (end > start ) {
882+ return std .fmt .parseInt (usize , headers [start .. end ], 10 ) catch 0 ;
883+ }
884+ }
885+ }
886+ return 0 ;
887+ }
888+
790889fn jsonStr (json : []const u8 , key : []const u8 ) ? []const u8 {
791890 var kbuf : [64 ]u8 = undefined ;
792891 const needle = std .fmt .bufPrint (& kbuf , "\" {s}\" :\" " , .{key }) catch return null ;
0 commit comments