@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,123 @@
1
+ import { json, readJson, remoteHttpError } from "../http-router.js";
2
+
3
+ export function createAuthInterface() {
4
+ return {
5
+ name: "auth",
6
+ register(router, { auth, node, routing }) {
7
+ router.post("/api/:database/:collection/auth", async (req, res) => {
8
+ try {
9
+ const body = await readJson(req);
10
+ const result = await loginWithRouting(req, {
11
+ auth,
12
+ node,
13
+ routing,
14
+ database: req.params.database,
15
+ collection: req.params.collection,
16
+ username: body?.username,
17
+ password: body?.password,
18
+ });
19
+ if (!result) return json(res, 401, { error: "invalid username or password" });
20
+ json(res, 200, result);
21
+ } catch (err) {
22
+ json(res, err.status || 500, err.body || { error: err.message });
23
+ }
24
+ });
25
+
26
+ router.post("/api/:database/:collection/renew", async (req, res) => {
27
+ try {
28
+ const body = await readJson(req);
29
+ json(res, 200, await renewWithRouting(req, {
30
+ auth,
31
+ node,
32
+ routing,
33
+ database: req.params.database,
34
+ collection: req.params.collection,
35
+ refreshToken: body?.refreshToken,
36
+ }));
37
+ } catch (err) {
38
+ json(res, err.status || 500, err.body || { error: err.message });
39
+ }
40
+ });
41
+ },
42
+ };
43
+ }
44
+
45
+ async function loginWithRouting(req, { auth, node, routing, database, collection, username, password }) {
46
+ if (!auth.enabled) return auth.login(database, collection, username, password);
47
+ auth.assertLoginInput(database, collection, username, password);
48
+
49
+ const targetShard = routing.targetShard(req);
50
+ if (targetShard) {
51
+ routing.assertOwnsTargetShard(targetShard);
52
+ return auth.loginFromUsers(
53
+ database,
54
+ collection,
55
+ username,
56
+ password,
57
+ localAuthUsers({ node, routing, database, collection, username, shard: targetShard }),
58
+ );
59
+ }
60
+
61
+ for (const target of routing.queryTargets(database, collection).filter((candidate) => candidate.local)) {
62
+ const result = auth.loginFromUsers(
63
+ database,
64
+ collection,
65
+ username,
66
+ password,
67
+ localAuthUsers({ node, routing, database, collection, username, shard: target.shard }),
68
+ );
69
+ if (result) return result;
70
+ }
71
+
72
+ for (const target of routing.queryTargets(database, collection).filter((candidate) => !candidate.local)) {
73
+ const remote = await routing.forwardToShard(req, target.shard, authPath(database, collection), {
74
+ method: "POST",
75
+ body: { username, password },
76
+ retryStatuses: [409, 503],
77
+ });
78
+ if (remote.status === 200) return remote.body;
79
+ if (remote.status === 401 || remote.status === 404) continue;
80
+ throw remoteHttpError(remote, `auth failed on shard ${target.shard}`);
81
+ }
82
+
83
+ return null;
84
+ }
85
+
86
+ async function renewWithRouting(req, { auth, node, routing, database, collection, refreshToken }) {
87
+ if (!auth.enabled) return auth.renew(database, collection, refreshToken);
88
+
89
+ const claims = auth.verifyRefreshClaims(database, collection, refreshToken);
90
+ const shard = routing.shardForObject(database, collection, claims.sub);
91
+ routing.assertTargetShardMatches(req, shard);
92
+
93
+ if (routing.ownsShard(shard)) {
94
+ const user = node.get(database, collection, claims.sub);
95
+ return auth.renewForUser(database, collection, refreshToken, user, claims);
96
+ }
97
+
98
+ if (routing.isForwarded(req)) {
99
+ throw Object.assign(new Error(`forward target does not own shard ${shard}`), { status: 409 });
100
+ }
101
+
102
+ const remote = await routing.forwardToShard(req, shard, renewPath(database, collection), {
103
+ method: "POST",
104
+ body: { refreshToken },
105
+ retryStatuses: [409, 503],
106
+ });
107
+ if (remote.status === 200) return remote.body;
108
+ throw remoteHttpError(remote, `renew failed on shard ${shard}`);
109
+ }
110
+
111
+ function localAuthUsers({ node, routing, database, collection, username, shard }) {
112
+ if (node.getCollectionMeta(database, collection) === null) return [];
113
+ if (!shard) return node.find(database, collection, { username });
114
+ return node.findShards(database, collection, { username }, {}, [shard]);
115
+ }
116
+
117
+ function authPath(database, collection) {
118
+ return `/api/${encodeURIComponent(database)}/${encodeURIComponent(collection)}/auth`;
119
+ }
120
+
121
+ function renewPath(database, collection) {
122
+ return `/api/${encodeURIComponent(database)}/${encodeURIComponent(collection)}/renew`;
123
+ }
@@ -0,0 +1,296 @@
1
+ import { once } from "node:events";
2
+ import {
3
+ CLUSTER_SECRET_HEADER,
4
+ clusterSecretFromRequest,
5
+ clusterSecretMatches,
6
+ normalizeClusterSecret,
7
+ } from "../cluster-secret.js";
8
+ import { json, readJson } from "../http-router.js";
9
+ import { localClusterStatus } from "../cluster-status.js";
10
+ import { acceptsNdjson, isNdjsonContentType, NDJSON_CONTENT_TYPE, parseNdjsonBatches } from "../ndjson.js";
11
+ import { publishShardMap, TOPOLOGY_RESHARDING } from "../shard-map.js";
12
+
13
+ export { CLUSTER_SECRET_HEADER } from "../cluster-secret.js";
14
+ export const DEFAULT_REPLICATION_TIMEOUT_MS = 60_000;
15
+
16
+ export function createClusterInterface() {
17
+ return {
18
+ name: "cluster",
19
+ register(router, { node, shardMapStore, persistence, memoryGuard, restoreStatus, clusterSecret, replicationScheduler }) {
20
+ const secure = secureClusterRoute(clusterSecret);
21
+
22
+ router.get("/health", (req, res) => {
23
+ json(res, 200, { ok: true, node: node.id, restore: restoreStatus?.snapshot?.() || null });
24
+ });
25
+
26
+ router.get("/cluster/shards", secure((req, res) => {
27
+ json(res, 200, { node: node.id, shardMap: node.shardMapSnapshot() });
28
+ }));
29
+
30
+ router.get("/cluster/status", secure((req, res) => {
31
+ json(res, 200, localClusterStatus(node, memoryGuard, replicationScheduler, restoreStatus, {
32
+ details: req.query.get("details") === "1",
33
+ }));
34
+ }));
35
+
36
+ router.put("/cluster/shards", secure(async (req, res) => {
37
+ try {
38
+ const body = await readJson(req);
39
+ const desired = body.shardMap || body;
40
+ const next = publishShardMap(node.shardMapSnapshot(), desired);
41
+ const startsReshard = next.topology.state === TOPOLOGY_RESHARDING
42
+ && node.shardMapSnapshot().topology?.id !== next.topology.id;
43
+ if (startsReshard && (!shardMapStore?.durable || !persistence?.durable)) {
44
+ json(res, 409, { error: "resharding requires S3 persistence and an S3 shard map store" });
45
+ return;
46
+ }
47
+ if (startsReshard) {
48
+ json(res, 409, { error: "partition changes must be published while the cluster is stopped" });
49
+ return;
50
+ }
51
+ const published = shardMapStore ? (await shardMapStore.save(desired)).shardMap : next;
52
+ node.replaceShardMap(shardMapFromHttp(published, node.id, { secret: clusterSecret }));
53
+ json(res, 200, { ok: true, node: node.id, shardMap: node.shardMapSnapshot() });
54
+ } catch (err) {
55
+ json(res, err.status || 409, { error: err.message });
56
+ }
57
+ }));
58
+
59
+ router.post("/cluster/peers", secure(async (req, res) => {
60
+ const body = await readJson(req);
61
+ if (!body.url) {
62
+ json(res, 400, { error: "url is required" });
63
+ return;
64
+ }
65
+ const desired = node.shardMapSnapshot();
66
+ const shard = String(body.shard || "default");
67
+ desired.shards[shard] ||= { peers: [] };
68
+ if (!desired.shards[shard].peers.some((peer) => peer.url === body.url)) {
69
+ desired.shards[shard].peers.push({ url: String(body.url) });
70
+ }
71
+ const published = shardMapStore ? (await shardMapStore.save(desired)).shardMap : desired;
72
+ node.replaceShardMap(shardMapFromHttp(published, node.id, { secret: clusterSecret }));
73
+ json(res, 201, { ok: true, node: node.id, shardMap: node.shardMapSnapshot() });
74
+ }));
75
+
76
+ router.get("/replicate", secure(async (req, res) => {
77
+ const after = Number(req.query.get("after") || 0);
78
+ const limit = Number(req.query.get("limit") || 0);
79
+ const scope = scopeFromParams(req.query);
80
+ if (node.needsSnapshot(after, scope)) {
81
+ json(res, 410, {
82
+ error: "log truncated, durable catch-up required",
83
+ node: node.id,
84
+ lastSeq: node.seq,
85
+ trimmed: node.trimmed,
86
+ });
87
+ return;
88
+ }
89
+ if (acceptsNdjson(req.headers)) {
90
+ await writeReplicationNdjson(res, node, after, limit, scope);
91
+ return;
92
+ }
93
+ json(res, 200, {
94
+ node: node.id,
95
+ lastSeq: node.seq,
96
+ ops: node._opsAfter(after, limit, scope),
97
+ });
98
+ }));
99
+
100
+ router.post("/replicate/ack", secure(async (req, res) => {
101
+ const body = await readJson(req);
102
+ const trimmed = node.receiveAck(String(body.node || ""), Number(body.after || 0), scopeFromBody(body));
103
+ json(res, 200, { ok: true, trimmed });
104
+ }));
105
+
106
+ router.post("/replicate/hint", secure(async (req, res) => {
107
+ try {
108
+ const body = await readJson(req);
109
+ const result = replicationScheduler?.receiveHint(body) || { accepted: 0, matchedPeer: false };
110
+ if (!result.matchedPeer) {
111
+ json(res, 403, { error: "hint sender is not a shard peer" });
112
+ return;
113
+ }
114
+ json(res, 202, { ok: true, ...result });
115
+ } catch (err) {
116
+ json(res, err.status || 400, { error: err.message });
117
+ }
118
+ }));
119
+
120
+ router.post("/replicate", secure(async (req, res) => {
121
+ if (isNdjsonContentType(req.headers["content-type"])) {
122
+ const from = String(req.headers["x-objydb-node"] || req.query.get("node") || "");
123
+ let applied = 0;
124
+ let batches = 0;
125
+ for await (const ops of parseNdjsonBatches(req, {
126
+ maxBatchObjects: replicationBatchObjects(node.syncBatchSize),
127
+ label: "replication stream",
128
+ })) {
129
+ applied += node.applyRemote(from, ops);
130
+ batches++;
131
+ }
132
+ json(res, 200, { ok: true, lastSeq: node.seq, applied, batches });
133
+ return;
134
+ }
135
+ const body = await readJson(req);
136
+ node.applyRemote(String(body.node || ""), Array.isArray(body.ops) ? body.ops : []);
137
+ json(res, 200, { ok: true, lastSeq: node.seq });
138
+ }));
139
+ },
140
+ };
141
+ }
142
+
143
+ export function httpPeer(url, nodeId, { secret = null, timeoutMs = DEFAULT_REPLICATION_TIMEOUT_MS } = {}) {
144
+ const base = url.replace(/\/+$/, "");
145
+ const timeout = positiveInt(timeoutMs, DEFAULT_REPLICATION_TIMEOUT_MS);
146
+ return {
147
+ key: base,
148
+ url: base,
149
+ secret: normalizeClusterSecret(secret),
150
+ nodeId: null,
151
+ pull: async (after, limit, scope) => {
152
+ const params = new URLSearchParams({ after: String(after), node: nodeId });
153
+ if (limit) params.set("limit", String(limit));
154
+ addScopeParams(params, scope);
155
+ const r = await fetchWithTimeout(`${base}/replicate?${params}`, {
156
+ headers: {
157
+ ...clusterHeaders(secret),
158
+ accept: NDJSON_CONTENT_TYPE,
159
+ },
160
+ }, timeout);
161
+ if (r.status === 200 && isNdjsonResponse(r)) {
162
+ return {
163
+ status: r.status,
164
+ node: r.headers.get("x-objydb-node") || undefined,
165
+ lastSeq: numberHeader(r.headers, "x-objydb-last-seq"),
166
+ opBatches: parseNdjsonBatches(r.body, {
167
+ maxBatchObjects: replicationBatchObjects(limit),
168
+ label: "replication stream",
169
+ }),
170
+ };
171
+ }
172
+ const body = await r.json().catch(() => null);
173
+ return { status: r.status, ...(body || {}) };
174
+ },
175
+ ack: async (id, after, scope) => {
176
+ await fetchWithTimeout(`${base}/replicate/ack`, {
177
+ method: "POST",
178
+ headers: clusterHeaders(secret, true),
179
+ body: JSON.stringify({ node: id, after, ...scopeBody(scope) }),
180
+ }, timeout);
181
+ },
182
+ hint: async (body) => {
183
+ const r = await fetchWithTimeout(`${base}/replicate/hint`, {
184
+ method: "POST",
185
+ headers: clusterHeaders(secret, true),
186
+ body: JSON.stringify(body),
187
+ }, timeout);
188
+ const responseBody = await r.json().catch(() => null);
189
+ return { status: r.status, ...(responseBody || {}) };
190
+ },
191
+ };
192
+ }
193
+
194
+ async function writeReplicationNdjson(res, node, after, limit, scope) {
195
+ res.writeHead(200, {
196
+ "content-type": NDJSON_CONTENT_TYPE,
197
+ "x-objydb-node": node.id,
198
+ "x-objydb-last-seq": String(node.seq),
199
+ });
200
+ for (const op of node._opsAfterIterable(after, limit, scope)) {
201
+ if (!res.write(`${JSON.stringify(op)}\n`)) await once(res, "drain");
202
+ }
203
+ res.end();
204
+ }
205
+
206
+ function isNdjsonResponse(response) {
207
+ return String(response.headers.get("content-type") || "").toLowerCase().includes("application/x-ndjson") ||
208
+ String(response.headers.get("content-type") || "").toLowerCase().includes("application/ndjson");
209
+ }
210
+
211
+ function numberHeader(headers, name) {
212
+ const n = Number(headers.get(name));
213
+ return Number.isFinite(n) ? n : 0;
214
+ }
215
+
216
+ function replicationBatchObjects(limit) {
217
+ const n = Number(limit);
218
+ if (!Number.isFinite(n) || n <= 0) return 1000;
219
+ return Math.max(1, Math.min(Math.floor(n), 5000));
220
+ }
221
+
222
+ export function shardMapFromHttp(map, nodeId, { secret = null, timeoutMs = DEFAULT_REPLICATION_TIMEOUT_MS } = {}) {
223
+ return {
224
+ ...(map.version ? { version: map.version } : {}),
225
+ ...(map.topology ? { topology: map.topology } : {}),
226
+ shards: Object.fromEntries(
227
+ Object.entries(map.shards || {}).map(([shard, shardConfig]) => [
228
+ shard,
229
+ {
230
+ peers: (shardConfig.peers || []).map((peer) => (
231
+ peer.url ? httpPeer(peer.url, nodeId, { secret, timeoutMs }) : peer
232
+ )),
233
+ },
234
+ ]),
235
+ ),
236
+ };
237
+ }
238
+
239
+ function secureClusterRoute(secret) {
240
+ const normalized = normalizeClusterSecret(secret);
241
+ return (handler) => async (req, res) => {
242
+ if (normalized && !clusterSecretMatches(clusterSecretFromRequest(req, { allowBearer: true }), normalized)) {
243
+ return json(res, 401, { error: "invalid cluster secret" });
244
+ }
245
+ return handler(req, res);
246
+ };
247
+ }
248
+
249
+ function clusterHeaders(secret, hasBody = false) {
250
+ return {
251
+ ...(hasBody ? { "content-type": "application/json" } : {}),
252
+ ...(normalizeClusterSecret(secret) ? { [CLUSTER_SECRET_HEADER]: normalizeClusterSecret(secret) } : {}),
253
+ };
254
+ }
255
+
256
+ async function fetchWithTimeout(url, options = {}, timeoutMs = DEFAULT_REPLICATION_TIMEOUT_MS) {
257
+ const controller = new AbortController();
258
+ const timer = timeoutMs ? setTimeout(() => controller.abort(), timeoutMs) : null;
259
+ try {
260
+ return await fetch(url, { ...options, signal: controller.signal });
261
+ } catch (err) {
262
+ if (err.name === "AbortError") throw new Error(`replication timeout after ${timeoutMs}ms`);
263
+ throw err;
264
+ } finally {
265
+ if (timer) clearTimeout(timer);
266
+ }
267
+ }
268
+
269
+ function positiveInt(value, fallback) {
270
+ const n = Number(value);
271
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
272
+ }
273
+
274
+ function scopeFromParams(params) {
275
+ const database = params.get("database");
276
+ const collection = params.get("collection");
277
+ const shard = params.get("shard");
278
+ return database && collection && shard ? { database, collection, shard } : undefined;
279
+ }
280
+
281
+ function scopeFromBody(body) {
282
+ return body.database && body.collection && body.shard
283
+ ? { database: String(body.database), collection: String(body.collection), shard: String(body.shard) }
284
+ : undefined;
285
+ }
286
+
287
+ function addScopeParams(params, scope) {
288
+ if (!scope) return;
289
+ params.set("database", scope.database);
290
+ params.set("collection", scope.collection);
291
+ params.set("shard", scope.shard);
292
+ }
293
+
294
+ function scopeBody(scope) {
295
+ return scope ? { database: scope.database, collection: scope.collection, shard: scope.shard } : {};
296
+ }