@objy/server 0.0.0-stage → 0.1.0

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.
Files changed (55) hide show
  1. package/API.md +1469 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +9 -0
  4. package/README.md +21 -2
  5. package/objydb.config.json +48 -0
  6. package/package.json +42 -4
  7. package/public/index.html +4134 -0
  8. package/public/objy-db-logo.png +0 -0
  9. package/src/aggregate.js +438 -0
  10. package/src/api.js +15 -0
  11. package/src/auth.js +472 -0
  12. package/src/clock.js +18 -0
  13. package/src/cluster-secret.js +25 -0
  14. package/src/cluster-status.js +562 -0
  15. package/src/compression-stats.js +32 -0
  16. package/src/config.js +360 -0
  17. package/src/crdt.js +1076 -0
  18. package/src/derived-state-scheduler.js +128 -0
  19. package/src/files.js +324 -0
  20. package/src/hnsw.js +269 -0
  21. package/src/http-cors.js +31 -0
  22. package/src/http-router.js +162 -0
  23. package/src/index.js +137 -0
  24. package/src/interfaces/auth.js +123 -0
  25. package/src/interfaces/cluster.js +296 -0
  26. package/src/interfaces/public-object.js +2452 -0
  27. package/src/interfaces/ui.js +38 -0
  28. package/src/lib/crypto.cjs +54 -0
  29. package/src/lib/jwt.cjs +231 -0
  30. package/src/local-wal.js +122 -0
  31. package/src/memory-guard.js +152 -0
  32. package/src/ndjson.js +117 -0
  33. package/src/node-runtime.js +29 -0
  34. package/src/object-hooks.js +282 -0
  35. package/src/object-id.js +7 -0
  36. package/src/object-storage.js +263 -0
  37. package/src/object-store.js +288 -0
  38. package/src/patch.js +87 -0
  39. package/src/permissions.js +178 -0
  40. package/src/persistence.js +1918 -0
  41. package/src/plugin-loader.js +21 -0
  42. package/src/query-planner.js +266 -0
  43. package/src/query.js +416 -0
  44. package/src/references.js +349 -0
  45. package/src/replication-scheduler.js +303 -0
  46. package/src/restore-status.js +77 -0
  47. package/src/runtime.js +47 -0
  48. package/src/server.js +242 -0
  49. package/src/shape.js +286 -0
  50. package/src/shard-map-store.js +85 -0
  51. package/src/shard-map.js +333 -0
  52. package/src/shard-router.js +737 -0
  53. package/src/store.js +3410 -0
  54. package/src/system-secrets.js +107 -0
  55. package/src/vector.js +83 -0
@@ -0,0 +1,128 @@
1
+ import { yieldToEventLoop } from "./runtime.js";
2
+
3
+ const DEFAULT_MAX_CONCURRENT = 1;
4
+ const DEFAULT_BATCH_SIZE = 4096;
5
+ const DEFAULT_MAX_SLICE_MS = 12;
6
+
7
+ export class DerivedStateScheduler {
8
+ constructor(options = {}) {
9
+ this.maxConcurrent = positiveInt(options.maxConcurrent, DEFAULT_MAX_CONCURRENT);
10
+ this.batchSize = positiveInt(options.batchSize, DEFAULT_BATCH_SIZE);
11
+ this.maxSliceMs = positiveInt(options.maxSliceMs, DEFAULT_MAX_SLICE_MS);
12
+ this.queue = [];
13
+ this.active = new Set();
14
+ this.completed = 0;
15
+ this.failed = 0;
16
+ this.closed = false;
17
+ }
18
+
19
+ schedule(name, run, { owner = null } = {}) {
20
+ if (typeof run !== "function") throw new TypeError("derived state job requires a run function");
21
+ if (this.closed) return Promise.reject(new Error("derived state scheduler is closed"));
22
+
23
+ return new Promise((resolve, reject) => {
24
+ this.queue.push({
25
+ name: String(name || "derived-state"),
26
+ run,
27
+ owner,
28
+ cancelled: false,
29
+ resolve,
30
+ reject,
31
+ enqueuedAt: Date.now(),
32
+ });
33
+ this._drain();
34
+ });
35
+ }
36
+
37
+ cancel(owner) {
38
+ if (owner === null || owner === undefined) return 0;
39
+ let cancelled = 0;
40
+ this.queue = this.queue.filter((job) => {
41
+ if (job.owner !== owner) return true;
42
+ job.cancelled = true;
43
+ job.resolve(undefined);
44
+ cancelled++;
45
+ return false;
46
+ });
47
+ for (const job of this.active) {
48
+ if (job.owner !== owner || job.cancelled) continue;
49
+ job.cancelled = true;
50
+ cancelled++;
51
+ }
52
+ return cancelled;
53
+ }
54
+
55
+ stats() {
56
+ return {
57
+ active: this.active.size,
58
+ queued: this.queue.length,
59
+ completed: this.completed,
60
+ failed: this.failed,
61
+ jobs: [...this.active].map((job) => ({
62
+ name: job.name,
63
+ startedAt: new Date(job.startedAt).toISOString(),
64
+ })),
65
+ };
66
+ }
67
+
68
+ close() {
69
+ this.closed = true;
70
+ const pending = this.queue.splice(0);
71
+ for (const job of pending) job.reject(new Error("derived state scheduler is closed"));
72
+ }
73
+
74
+ _drain() {
75
+ while (!this.closed && this.active.size < this.maxConcurrent && this.queue.length) {
76
+ const job = this.queue.shift();
77
+ job.startedAt = Date.now();
78
+ this.active.add(job);
79
+ this._run(job);
80
+ }
81
+ }
82
+
83
+ async _run(job) {
84
+ const context = new DerivedStateJobContext(this, job);
85
+ try {
86
+ await yieldToEventLoop();
87
+ job.resolve(job.cancelled ? undefined : await job.run(context));
88
+ this.completed++;
89
+ } catch (error) {
90
+ this.failed++;
91
+ job.reject(error);
92
+ } finally {
93
+ this.active.delete(job);
94
+ this._drain();
95
+ }
96
+ }
97
+ }
98
+
99
+ class DerivedStateJobContext {
100
+ constructor(scheduler, job) {
101
+ this.scheduler = scheduler;
102
+ this.job = job;
103
+ this.lastYieldAt = Date.now();
104
+ }
105
+
106
+ get cancelled() {
107
+ return this.job.cancelled;
108
+ }
109
+
110
+ async checkpoint(processed = 0) {
111
+ const count = Math.max(0, Math.floor(Number(processed) || 0));
112
+ const shouldYieldByBatch = count > 0 && count % this.scheduler.batchSize === 0;
113
+ const shouldYieldByTime = Date.now() - this.lastYieldAt >= this.scheduler.maxSliceMs;
114
+ if (!shouldYieldByBatch && !shouldYieldByTime) return;
115
+ this.lastYieldAt = Date.now();
116
+ await yieldToEventLoop();
117
+ }
118
+
119
+ async yield() {
120
+ this.lastYieldAt = Date.now();
121
+ await yieldToEventLoop();
122
+ }
123
+ }
124
+
125
+ function positiveInt(value, fallback) {
126
+ const number = Number(value);
127
+ return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
128
+ }
package/src/files.js ADDED
@@ -0,0 +1,324 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { DEFAULT_FILE_PREFIX } from "./config.js";
3
+ import { createObjectId } from "./object-id.js";
4
+ import { createObjectStorage } from "./object-storage.js";
5
+
6
+ const DEFAULT_MAX_UPLOAD_BYTES = 256 * 1024 * 1024;
7
+ const ALLOWED_FILE_KEYS = new Set(["storage", "key", "mimeType"]);
8
+
9
+ export function createFileService(config = {}, persistenceConfig = {}) {
10
+ const mode = config.mode || "off";
11
+ if (mode === "off" || mode === false) return null;
12
+ const storage = createObjectStorage({
13
+ mode,
14
+ s3: {
15
+ ...(persistenceConfig?.s3 || {}),
16
+ ...(config.s3 || {}),
17
+ },
18
+ });
19
+ return new FileService({
20
+ storage,
21
+ prefix: config.prefix || DEFAULT_FILE_PREFIX,
22
+ storageName: config.storageName || mode,
23
+ maxUploadBytes: positiveInt(config.maxUploadBytes, DEFAULT_MAX_UPLOAD_BYTES),
24
+ });
25
+ }
26
+
27
+ export class FileService {
28
+ constructor({ storage, prefix = DEFAULT_FILE_PREFIX, storageName = "s3", maxUploadBytes = DEFAULT_MAX_UPLOAD_BYTES } = {}) {
29
+ if (!storage) throw new Error("file storage is required");
30
+ this.storage = storage;
31
+ this.prefix = normalizePrefix(prefix);
32
+ this.storageName = storageName;
33
+ this.maxUploadBytes = positiveInt(maxUploadBytes, DEFAULT_MAX_UPLOAD_BYTES);
34
+ }
35
+
36
+ keyPrefix(database, collection, id) {
37
+ return [
38
+ this.collectionPrefix(database, collection),
39
+ "object",
40
+ encodeKeyPart(id),
41
+ "blob",
42
+ ].join("/");
43
+ }
44
+
45
+ databasePrefix(database) {
46
+ return [
47
+ this.prefix,
48
+ "db",
49
+ encodeKeyPart(database),
50
+ ].join("/");
51
+ }
52
+
53
+ collectionPrefix(database, collection) {
54
+ return [
55
+ this.databasePrefix(database),
56
+ "collection",
57
+ encodeKeyPart(collection),
58
+ ].join("/");
59
+ }
60
+
61
+ key(database, collection, id, fileId = createObjectId()) {
62
+ return `${this.keyPrefix(database, collection, id)}/${encodeKeyPart(fileId)}`;
63
+ }
64
+
65
+ metadata(database, collection, id, mimeType) {
66
+ return {
67
+ storage: this.storageName,
68
+ key: this.key(database, collection, id),
69
+ mimeType: cleanMimeType(mimeType),
70
+ };
71
+ }
72
+
73
+ attach(database, collection, object, file) {
74
+ if (object?._file !== undefined) throw httpError(400, "object._file is server-owned");
75
+ return {
76
+ ...(object || {}),
77
+ _file: this.metadata(database, collection, object?._id, file?.mimeType),
78
+ };
79
+ }
80
+
81
+ normalizeObject(database, collection, object) {
82
+ if (!isPlainObject(object) || object._file === undefined) return object;
83
+ if (object._file === null) {
84
+ const { _file, ...rest } = object;
85
+ return rest;
86
+ }
87
+ return {
88
+ ...object,
89
+ _file: this.normalizeMetadata(database, collection, object._id, object._file),
90
+ };
91
+ }
92
+
93
+ normalizeMetadata(database, collection, id, metadata) {
94
+ if (!isPlainObject(metadata)) throw httpError(400, "object._file must be a plain object");
95
+ for (const key of Object.keys(metadata)) {
96
+ if (!ALLOWED_FILE_KEYS.has(key)) throw httpError(400, `object._file.${key} is not supported`);
97
+ }
98
+ if (metadata.storage !== this.storageName) throw httpError(400, `object._file.storage must be ${this.storageName}`);
99
+ const prefix = `${this.keyPrefix(database, collection, id)}/`;
100
+ const keySuffix = typeof metadata.key === "string" && metadata.key.startsWith(prefix)
101
+ ? metadata.key.slice(prefix.length)
102
+ : "";
103
+ if (!keySuffix || keySuffix.includes("/")) {
104
+ throw httpError(400, "object._file.key must match the server-derived file key");
105
+ }
106
+ return {
107
+ storage: this.storageName,
108
+ key: metadata.key,
109
+ mimeType: cleanMimeType(metadata.mimeType),
110
+ };
111
+ }
112
+
113
+ fileKeyFromObject(database, collection, object) {
114
+ if (!object?._file) return null;
115
+ return this.normalizeMetadata(database, collection, object._id, object._file).key;
116
+ }
117
+
118
+ async put(database, collection, id, metadata, file) {
119
+ const normalized = this.normalizeMetadata(database, collection, id, metadata);
120
+ await this.storage.putBlob(normalized.key, file.body, normalized.mimeType);
121
+ return normalized;
122
+ }
123
+
124
+ async get(database, collection, id, metadata) {
125
+ const normalized = this.normalizeMetadata(database, collection, id, metadata);
126
+ return this.storage.getBlob(normalized.key);
127
+ }
128
+
129
+ async deleteObjectFile(database, collection, object) {
130
+ const key = this.fileKeyFromObject(database, collection, object);
131
+ if (!key) return false;
132
+ await this.storage.delete([key]);
133
+ return true;
134
+ }
135
+
136
+ async deleteCollectionFiles(database, collection) {
137
+ await this.storage.deletePrefix(`${this.collectionPrefix(database, collection)}/`);
138
+ }
139
+
140
+ async deleteDatabaseFiles(database) {
141
+ await this.storage.deletePrefix(`${this.databasePrefix(database)}/`);
142
+ }
143
+ }
144
+
145
+ export function isMultipartRequest(req) {
146
+ const contentType = req.headers["content-type"];
147
+ return typeof contentType === "string" && contentType.toLowerCase().startsWith("multipart/form-data");
148
+ }
149
+
150
+ export async function readMultipartObjectFile(req, { maxBytes = DEFAULT_MAX_UPLOAD_BYTES, assertCanBuffer = null } = {}) {
151
+ const parts = parseMultipart(await readRawBody(req, maxBytes, assertCanBuffer), multipartBoundary(req));
152
+ const objectPart = parts.find((part) => part.name === "object");
153
+ const filePart = parts.find((part) => part.name === "file");
154
+ if (!objectPart) throw httpError(400, 'multipart field "object" is required');
155
+ if (!filePart) throw httpError(400, 'multipart field "file" is required');
156
+
157
+ let object;
158
+ try {
159
+ object = JSON.parse(objectPart.body.toString("utf8"));
160
+ } catch {
161
+ throw httpError(400, 'multipart field "object" must be valid JSON');
162
+ }
163
+ if (!isPlainObject(object)) throw httpError(400, 'multipart field "object" must be a plain object');
164
+
165
+ return {
166
+ object,
167
+ file: {
168
+ filename: filePart.filename || null,
169
+ mimeType: cleanMimeType(filePart.contentType),
170
+ body: filePart.body,
171
+ },
172
+ };
173
+ }
174
+
175
+ export function buildMultipartObjectFile({ object, file }) {
176
+ const boundary = `objydb-${randomUUID()}`;
177
+ const chunks = [
178
+ Buffer.from(`--${boundary}\r\ncontent-disposition: form-data; name="object"\r\ncontent-type: application/json\r\n\r\n`),
179
+ Buffer.from(JSON.stringify(object)),
180
+ Buffer.from(`\r\n--${boundary}\r\ncontent-disposition: form-data; name="file"; filename="${escapeHeader(file.filename || "blob")}"\r\ncontent-type: ${cleanMimeType(file.mimeType)}\r\n\r\n`),
181
+ Buffer.isBuffer(file.body) ? file.body : Buffer.from(file.body || ""),
182
+ Buffer.from(`\r\n--${boundary}--\r\n`),
183
+ ];
184
+ return {
185
+ body: Buffer.concat(chunks),
186
+ contentType: `multipart/form-data; boundary=${boundary}`,
187
+ };
188
+ }
189
+
190
+ export async function writeBlobResponse(res, blob, fallbackContentType = "application/octet-stream") {
191
+ const headers = {
192
+ "content-type": blob.contentType || fallbackContentType,
193
+ };
194
+ if (Number.isFinite(blob.contentLength)) headers["content-length"] = String(blob.contentLength);
195
+ res.writeHead(200, headers);
196
+ if (Buffer.isBuffer(blob.body) || typeof blob.body === "string") {
197
+ res.end(blob.body);
198
+ return;
199
+ }
200
+ for await (const chunk of blob.body) res.write(chunk);
201
+ res.end();
202
+ }
203
+
204
+ async function readRawBody(req, maxBytes, assertCanBuffer = null) {
205
+ if (req._bodyBuffer !== undefined) return req._bodyBuffer;
206
+ assertCanBuffer?.();
207
+ req._bodyBuffer = await new Promise((resolve, reject) => {
208
+ const chunks = [];
209
+ let size = 0;
210
+ let settled = false;
211
+ const fail = (err) => {
212
+ if (settled) return;
213
+ settled = true;
214
+ reject(err);
215
+ req.destroy();
216
+ };
217
+ req.on("data", (chunk) => {
218
+ try {
219
+ assertCanBuffer?.();
220
+ } catch (err) {
221
+ fail(err);
222
+ return;
223
+ }
224
+ size += chunk.length;
225
+ if (size > maxBytes) {
226
+ fail(httpError(413, "payload too large"));
227
+ return;
228
+ }
229
+ chunks.push(chunk);
230
+ });
231
+ req.on("end", () => {
232
+ if (settled) return;
233
+ settled = true;
234
+ resolve(Buffer.concat(chunks));
235
+ });
236
+ req.on("error", fail);
237
+ });
238
+ return req._bodyBuffer;
239
+ }
240
+
241
+ function multipartBoundary(req) {
242
+ const contentType = req.headers["content-type"];
243
+ const match = typeof contentType === "string" ? contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/i) : null;
244
+ const boundary = match?.[1] || match?.[2];
245
+ if (!boundary) throw httpError(400, "multipart boundary is required");
246
+ return boundary.trim();
247
+ }
248
+
249
+ function parseMultipart(buffer, boundary) {
250
+ const marker = Buffer.from(`--${boundary}`);
251
+ const parts = [];
252
+ let pos = buffer.indexOf(marker);
253
+ if (pos < 0) throw httpError(400, "invalid multipart body");
254
+
255
+ while (pos >= 0) {
256
+ pos += marker.length;
257
+ if (buffer.slice(pos, pos + 2).toString("ascii") === "--") break;
258
+ if (buffer.slice(pos, pos + 2).toString("ascii") !== "\r\n") throw httpError(400, "invalid multipart boundary");
259
+ pos += 2;
260
+
261
+ const headerEnd = buffer.indexOf(Buffer.from("\r\n\r\n"), pos);
262
+ if (headerEnd < 0) throw httpError(400, "invalid multipart headers");
263
+ const headers = parsePartHeaders(buffer.slice(pos, headerEnd).toString("utf8"));
264
+ const bodyStart = headerEnd + 4;
265
+ const next = buffer.indexOf(Buffer.from(`\r\n--${boundary}`), bodyStart);
266
+ if (next < 0) throw httpError(400, "invalid multipart body");
267
+ parts.push({
268
+ name: dispositionParam(headers["content-disposition"], "name"),
269
+ filename: dispositionParam(headers["content-disposition"], "filename"),
270
+ contentType: headers["content-type"],
271
+ body: buffer.slice(bodyStart, next),
272
+ });
273
+ pos = next + 2;
274
+ }
275
+
276
+ return parts.filter((part) => part.name);
277
+ }
278
+
279
+ function parsePartHeaders(raw) {
280
+ const headers = {};
281
+ for (const line of raw.split("\r\n")) {
282
+ const index = line.indexOf(":");
283
+ if (index <= 0) continue;
284
+ headers[line.slice(0, index).trim().toLowerCase()] = line.slice(index + 1).trim();
285
+ }
286
+ return headers;
287
+ }
288
+
289
+ function dispositionParam(header, name) {
290
+ const match = typeof header === "string" ? header.match(new RegExp(`${name}="([^"]*)"`)) : null;
291
+ return match ? match[1] : null;
292
+ }
293
+
294
+ function cleanMimeType(value) {
295
+ return typeof value === "string" && value.length > 0 ? value : "application/octet-stream";
296
+ }
297
+
298
+ function normalizePrefix(value) {
299
+ const cleaned = String(value || DEFAULT_FILE_PREFIX).replace(/^\/+|\/+$/g, "");
300
+ return cleaned || DEFAULT_FILE_PREFIX;
301
+ }
302
+
303
+ function encodeKeyPart(value) {
304
+ return encodeURIComponent(String(value));
305
+ }
306
+
307
+ function escapeHeader(value) {
308
+ return String(value).replace(/["\r\n]/g, "_");
309
+ }
310
+
311
+ function positiveInt(value, fallback) {
312
+ const n = Number(value);
313
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
314
+ }
315
+
316
+ function isPlainObject(value) {
317
+ return value !== null && typeof value === "object" && !Array.isArray(value);
318
+ }
319
+
320
+ function httpError(status, message) {
321
+ const err = new Error(message);
322
+ err.status = status;
323
+ return err;
324
+ }