@lunora/runtime 0.0.0 → 1.0.0-alpha.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.
@@ -0,0 +1,141 @@
1
+ const shouldSkip = (event, onlyErrors) => onlyErrors === true && event.ok;
2
+ const mergeHeaders = (defaults, overrides) => {
3
+ if (!overrides) {
4
+ return { ...defaults };
5
+ }
6
+ const merged = {};
7
+ const seen = /* @__PURE__ */ new Map();
8
+ for (const [name, value] of Object.entries(defaults)) {
9
+ const lower = name.toLowerCase();
10
+ seen.set(lower, name);
11
+ merged[name] = value;
12
+ }
13
+ for (const [name, value] of Object.entries(overrides)) {
14
+ const lower = name.toLowerCase();
15
+ const existing = seen.get(lower);
16
+ if (existing === void 0) {
17
+ seen.set(lower, name);
18
+ merged[name] = value;
19
+ } else {
20
+ merged[existing] = value;
21
+ }
22
+ }
23
+ return merged;
24
+ };
25
+ const consoleSink = (options = {}) => {
26
+ const { onlyErrors } = options;
27
+ return {
28
+ onLog: (event) => {
29
+ if (event.level === "error") {
30
+ console.error("[lunora:log]", event.functionPath, event.message);
31
+ } else {
32
+ console.log("[lunora:log]", event.functionPath, event.message);
33
+ }
34
+ },
35
+ onRpc: (event) => {
36
+ if (shouldSkip(event, onlyErrors)) {
37
+ return;
38
+ }
39
+ if (event.ok) {
40
+ console.log("[lunora:rpc]", event);
41
+ } else {
42
+ console.error("[lunora:rpc]", event);
43
+ }
44
+ }
45
+ };
46
+ };
47
+ const webhookSink = (options) => {
48
+ const { headers, onlyErrors, transform, url } = options;
49
+ const mergedHeaders = mergeHeaders({ "content-type": "application/json" }, headers);
50
+ return {
51
+ onRpc: (event, context) => {
52
+ if (shouldSkip(event, onlyErrors)) {
53
+ return;
54
+ }
55
+ try {
56
+ let payload = event;
57
+ if (transform) {
58
+ try {
59
+ payload = transform(event);
60
+ } catch {
61
+ return;
62
+ }
63
+ }
64
+ if (payload === null || payload === void 0) {
65
+ return;
66
+ }
67
+ const sent = fetch(url, {
68
+ body: JSON.stringify(payload),
69
+ headers: mergedHeaders,
70
+ method: "POST"
71
+ }).catch(() => {
72
+ });
73
+ if (context?.waitUntil) {
74
+ context.waitUntil(sent);
75
+ }
76
+ } catch {
77
+ }
78
+ }
79
+ };
80
+ };
81
+ const sentrySink = (options) => {
82
+ const { capture } = options;
83
+ const onlyErrors = options.onlyErrors ?? true;
84
+ return {
85
+ onRpc: (event) => {
86
+ if (shouldSkip(event, onlyErrors)) {
87
+ return;
88
+ }
89
+ try {
90
+ capture(event);
91
+ } catch {
92
+ }
93
+ }
94
+ };
95
+ };
96
+ const analyticsEngineSink = (options) => {
97
+ const { dataset, onlyErrors } = options;
98
+ return {
99
+ onRpc: (event) => {
100
+ if (shouldSkip(event, onlyErrors)) {
101
+ return;
102
+ }
103
+ try {
104
+ dataset.writeDataPoint({
105
+ blobs: [event.functionPath, event.ok ? "ok" : "error", event.shardKey ?? "", event.error?.code ?? "", event.fanOut?.table ?? ""],
106
+ doubles: [event.durationMs, event.ok ? 0 : 1, event.fanOut?.shards ?? 0, event.fanOut?.failed ?? 0],
107
+ indexes: [event.functionPath]
108
+ });
109
+ } catch {
110
+ }
111
+ }
112
+ };
113
+ };
114
+ const combineSinks = (...sinks) => {
115
+ return {
116
+ onLog: (event, context) => {
117
+ for (const sink of sinks) {
118
+ if (!sink.onLog) {
119
+ continue;
120
+ }
121
+ try {
122
+ sink.onLog(event, context);
123
+ } catch {
124
+ }
125
+ }
126
+ },
127
+ onRpc: (event, context) => {
128
+ for (const sink of sinks) {
129
+ if (!sink.onRpc) {
130
+ continue;
131
+ }
132
+ try {
133
+ sink.onRpc(event, context);
134
+ } catch {
135
+ }
136
+ }
137
+ }
138
+ };
139
+ };
140
+
141
+ export { analyticsEngineSink, combineSinks, consoleSink, sentrySink, webhookSink };
@@ -0,0 +1,61 @@
1
+ const RPC_ENDPOINT = "/_lunora/rpc";
2
+ const buildIdentityHeaders = (options) => {
3
+ const headers = { "content-type": "application/json" };
4
+ if (options.userId !== void 0 && options.userId.length > 0) {
5
+ headers["x-lunora-userid"] = options.userId;
6
+ }
7
+ if (options.identity !== void 0) {
8
+ headers["x-lunora-identity"] = JSON.stringify(options.identity);
9
+ }
10
+ return headers;
11
+ };
12
+ const fanOutRelation = async (options, body, label) => {
13
+ const doFetch = options.fetch ?? globalThis.fetch;
14
+ const response = await doFetch(
15
+ new Request(`${options.origin}${RPC_ENDPOINT}`, {
16
+ body: JSON.stringify(body),
17
+ headers: buildIdentityHeaders(options),
18
+ method: "POST"
19
+ })
20
+ );
21
+ if (!response.ok) {
22
+ throw new Error(`cross-shard relation ${label} failed: worker returned ${String(response.status)}`);
23
+ }
24
+ const result = await response.json();
25
+ if (typeof result.failed === "number" && result.failed > 0) {
26
+ const reached = (typeof result.ok === "number" ? result.ok : 0) + result.failed;
27
+ throw new Error(
28
+ `cross-shard relation ${label} failed on ${String(result.failed)} of ${String(reached)} shard(s) — refusing to return a partial result`
29
+ );
30
+ }
31
+ return result.data;
32
+ };
33
+ const createCrossShardRelationCapabilities = (options) => {
34
+ const crossShardReader = async (table, args) => {
35
+ const data = await fanOutRelation(
36
+ options,
37
+ {
38
+ args: { orderBy: args?.orderBy, table, where: args?.where, with: args?.with },
39
+ fanOut: { merge: { kind: "concat" }, table },
40
+ functionPath: "__lunora_relation__:read"
41
+ },
42
+ "read"
43
+ );
44
+ return { continueCursor: null, isDone: true, page: Array.isArray(data) ? data : [] };
45
+ };
46
+ const crossShardCounter = async (table, where) => {
47
+ const data = await fanOutRelation(
48
+ options,
49
+ {
50
+ args: { table, where },
51
+ fanOut: { merge: { kind: "sum" }, table },
52
+ functionPath: "__lunora_relation__:count"
53
+ },
54
+ "count"
55
+ );
56
+ return typeof data === "number" ? data : 0;
57
+ };
58
+ return { crossShardCounter, crossShardReader };
59
+ };
60
+
61
+ export { createCrossShardRelationCapabilities };
@@ -0,0 +1,75 @@
1
+ const SHARD_REGISTRY_DO_NAME = "__lunora_shard_registry__";
2
+ const DEFAULT_REGISTRY_CACHE_TTL_MS = 3e4;
3
+ const REGISTRY_BASE_URL = "https://shard-registry.internal";
4
+ const decodeJson = async (response) => (
5
+ // Response.json() throws on non-JSON; the DO always returns JSON so this
6
+ // only surfaces if the DO route is misbehaving — let it bubble.
7
+ await response.json()
8
+ );
9
+ const createDynamicShardRegistry = (options) => {
10
+ const instanceName = options.instanceName ?? SHARD_REGISTRY_DO_NAME;
11
+ const cacheTtlMs = options.cacheTtlMs ?? DEFAULT_REGISTRY_CACHE_TTL_MS;
12
+ const cache = /* @__PURE__ */ new Map();
13
+ let cachedStub;
14
+ const stub = () => {
15
+ cachedStub ??= options.namespace.get(options.namespace.idFromName(instanceName));
16
+ return cachedStub;
17
+ };
18
+ const post = async (path, body) => stub().fetch(
19
+ new Request(`${REGISTRY_BASE_URL}${path}`, {
20
+ body: JSON.stringify(body),
21
+ headers: { "content-type": "application/json" },
22
+ method: "POST"
23
+ })
24
+ );
25
+ const get = async (path) => stub().fetch(new Request(`${REGISTRY_BASE_URL}${path}`, { method: "GET" }));
26
+ return {
27
+ invalidate(table) {
28
+ if (table === void 0) {
29
+ cache.clear();
30
+ } else {
31
+ cache.delete(table);
32
+ }
33
+ },
34
+ async listShardKeys(table) {
35
+ const now = Date.now();
36
+ const cached = cache.get(table);
37
+ if (cached && cached.expiresAt > now) {
38
+ return cached.shardKeys;
39
+ }
40
+ const response = await get(`/list?table=${encodeURIComponent(table)}`);
41
+ if (!response.ok) {
42
+ throw new Error(`shard registry /list returned ${String(response.status)}`);
43
+ }
44
+ const { shardKeys } = await decodeJson(response);
45
+ if (cacheTtlMs > 0) {
46
+ cache.set(table, { expiresAt: now + cacheTtlMs, shardKeys });
47
+ }
48
+ return shardKeys;
49
+ },
50
+ async register(table, shardKey) {
51
+ const response = await post("/register", { shardKey, table });
52
+ if (!response.ok) {
53
+ throw new Error(`shard registry /register returned ${String(response.status)}`);
54
+ }
55
+ cache.delete(table);
56
+ },
57
+ async snapshot() {
58
+ const response = await get("/snapshot");
59
+ if (!response.ok) {
60
+ throw new Error(`shard registry /snapshot returned ${String(response.status)}`);
61
+ }
62
+ const { tables } = await decodeJson(response);
63
+ return tables;
64
+ },
65
+ async unregister(table, shardKey) {
66
+ const response = await post("/unregister", { shardKey, table });
67
+ if (!response.ok) {
68
+ throw new Error(`shard registry /unregister returned ${String(response.status)}`);
69
+ }
70
+ cache.delete(table);
71
+ }
72
+ };
73
+ };
74
+
75
+ export { DEFAULT_REGISTRY_CACHE_TTL_MS, SHARD_REGISTRY_DO_NAME, createDynamicShardRegistry };