@mybase-dev/sdk 1.0.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.
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@mybase-dev/sdk",
3
+ "version": "1.0.0",
4
+ "description": "Client helpers for apps running on mybase: analytics, cron calls, one-off tasks, cache, rate limits, queues.",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/LandonHarter/mybase.git",
9
+ "directory": "packages/sdk"
10
+ },
11
+ "exports": {
12
+ "./analytics": {
13
+ "bun": "./src/analytics.ts",
14
+ "types": "./dist/analytics.d.ts",
15
+ "default": "./dist/analytics.js"
16
+ },
17
+ "./cron": {
18
+ "bun": "./src/cron.ts",
19
+ "types": "./dist/cron.d.ts",
20
+ "default": "./dist/cron.js"
21
+ },
22
+ "./config": {
23
+ "bun": "./src/config.ts",
24
+ "types": "./dist/config.d.ts",
25
+ "default": "./dist/config.js"
26
+ },
27
+ "./cache": {
28
+ "bun": "./src/cache.ts",
29
+ "types": "./dist/cache.d.ts",
30
+ "default": "./dist/cache.js"
31
+ },
32
+ "./ratelimit": {
33
+ "bun": "./src/ratelimit.ts",
34
+ "types": "./dist/ratelimit.d.ts",
35
+ "default": "./dist/ratelimit.js"
36
+ },
37
+ "./queue": {
38
+ "bun": "./src/queue.ts",
39
+ "types": "./dist/queue.d.ts",
40
+ "default": "./dist/queue.js"
41
+ }
42
+ },
43
+ "files": [
44
+ "dist",
45
+ "src/analytics.ts",
46
+ "src/cron.ts",
47
+ "src/config.ts",
48
+ "src/cache.ts",
49
+ "src/ratelimit.ts",
50
+ "src/queue.ts",
51
+ "README.md"
52
+ ],
53
+ "engines": {
54
+ "node": ">=20"
55
+ },
56
+ "scripts": {
57
+ "build": "rm -rf dist && bun build src/analytics.ts src/cron.ts src/config.ts src/cache.ts src/ratelimit.ts src/queue.ts --outdir dist --target node --format esm && bun build src/analytics-script.ts --outfile dist/analytics.min.js --target browser --format iife --minify && tsc -p tsconfig.build.json",
58
+ "test": "bun test",
59
+ "prepublishOnly": "bun run build"
60
+ },
61
+ "license": "MIT",
62
+ "homepage": "https://github.com/LandonHarter/mybase#readme"
63
+ }
@@ -0,0 +1,311 @@
1
+ // Page views, custom events, and errors, sent to a mybase project's analytics service.
2
+ //
3
+ // In a browser, `createAnalytics` records page views (including client-side route changes)
4
+ // and uncaught errors on its own, batches everything, and sends it with `sendBeacon` when
5
+ // the page is hidden. On a server it only sends what you `track` or `captureError`.
6
+ //
7
+ // The write key is public: it's meant to ship in browser code. It can only add events.
8
+ // No cookies or local storage are used; mybase counts visitors with a daily-rotating hash.
9
+
10
+ export type AnalyticsEventType = "pageview" | "event" | "error";
11
+
12
+ /** Custom properties: flat, small, JSON-safe values. */
13
+ export type AnalyticsProps = Record<string, string | number | boolean | null>;
14
+
15
+ /** One event on the wire. */
16
+ export interface AnalyticsEvent {
17
+ type: AnalyticsEventType;
18
+ /** Custom events: the event name. Errors: the message. Unused for page views. */
19
+ name?: string;
20
+ /** The route, e.g. `/pricing`. Query string stripped unless the client keeps it. */
21
+ path?: string;
22
+ /** Page views: the page that linked here. mybase keeps only its host. */
23
+ referrer?: string;
24
+ props?: AnalyticsProps;
25
+ /** Errors: the stack trace. */
26
+ stack?: string;
27
+ /** The browser's IANA time zone, used to guess the country when the request doesn't say. */
28
+ tz?: string;
29
+ /** When it happened (ISO). Defaults to when mybase received it. */
30
+ ts?: string;
31
+ }
32
+
33
+ /** The body of `POST /analytics/<project>/events`. */
34
+ export interface AnalyticsIngestRequest {
35
+ key: string;
36
+ events: AnalyticsEvent[];
37
+ }
38
+
39
+ /** What the ingest route accepts. Bigger values are rejected (strings are not truncated for you, except by this client). */
40
+ export const ANALYTICS_LIMITS = {
41
+ /** Events per request. */
42
+ batch: 50,
43
+ /** Request body, in bytes. */
44
+ bodyBytes: 64 * 1024,
45
+ props: 20,
46
+ propKey: 64,
47
+ propValue: 256,
48
+ path: 512,
49
+ referrer: 512,
50
+ /** Error messages. */
51
+ message: 512,
52
+ stack: 4096,
53
+ } as const;
54
+
55
+ /** Custom event names: letters, digits, space, and `_ . : -`, 1–64 chars. */
56
+ export const ANALYTICS_EVENT_NAME_PATTERN = /^[A-Za-z0-9_.:\- ]{1,64}$/;
57
+
58
+ export interface AnalyticsOptions {
59
+ /** Where the mybase API is reachable from the browser (or server), e.g. `https://mybase.example.com`. */
60
+ apiUrl: string;
61
+ project: string;
62
+ /** The project's public write key (`mba_…`), from the Analytics page. */
63
+ key: string;
64
+ /** Browser: record a page view on load and on every client-side route change. Default true. */
65
+ autoPageviews?: boolean;
66
+ /** Browser: record uncaught errors and unhandled promise rejections. Default true. */
67
+ captureErrors?: boolean;
68
+ /** Keep the query string in paths. Default false (`/search?q=x` is recorded as `/search`). */
69
+ keepQuery?: boolean;
70
+ /** Send nothing when the browser's Do Not Track setting is on. Default false. */
71
+ respectDoNotTrack?: boolean;
72
+ /** How long to collect events before sending, in ms. Default 5000. */
73
+ flushIntervalMs?: number;
74
+ /** Send right away once this many events are queued. Default 20. */
75
+ batchSize?: number;
76
+ /** Called when a send fails. Analytics never throws into your app; failed events are dropped. */
77
+ onError?: (err: unknown) => void;
78
+ /** Default `globalThis.fetch`. */
79
+ fetch?: typeof fetch;
80
+ }
81
+
82
+ export interface Analytics {
83
+ /** Records a page view. `path` defaults to the current location (browser). */
84
+ pageview(path?: string, props?: AnalyticsProps): void;
85
+ /** Records a custom event, e.g. `track("signup", { plan: "pro" })`. */
86
+ track(name: string, props?: AnalyticsProps): void;
87
+ /** Records an error (an `Error`, or anything else, which is stringified). */
88
+ captureError(error: unknown, props?: AnalyticsProps): void;
89
+ /** Sends everything queued now. Call it before a server process exits. */
90
+ flush(): Promise<void>;
91
+ /** Sends what's queued and removes every listener this client added. */
92
+ stop(): void;
93
+ }
94
+
95
+ /** `/a/b?x=1#h` → `/a/b` (or `/a/b?x=1` with `keepQuery`). Full URLs are reduced to their path. */
96
+ export function normalizePath(input: string, keepQuery = false): string {
97
+ let path = input.trim();
98
+ const scheme = /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i.exec(path);
99
+ if (scheme) path = path.slice(scheme[0].length);
100
+ path = path.split("#", 1)[0]!;
101
+ if (!keepQuery) path = path.split("?", 1)[0]!;
102
+ if (!path.startsWith("/")) path = `/${path}`;
103
+ return path.slice(0, ANALYTICS_LIMITS.path);
104
+ }
105
+
106
+ /** Drops props that would be rejected, rather than losing the whole event. */
107
+ function cleanProps(props: AnalyticsProps | undefined): AnalyticsProps | undefined {
108
+ if (!props) return undefined;
109
+ const out: AnalyticsProps = {};
110
+ let n = 0;
111
+ for (const [k, v] of Object.entries(props)) {
112
+ if (n >= ANALYTICS_LIMITS.props || !k || k.length > ANALYTICS_LIMITS.propKey) continue;
113
+ if (typeof v === "string") out[k] = v.slice(0, ANALYTICS_LIMITS.propValue);
114
+ else if (v === null || typeof v === "boolean" || (typeof v === "number" && Number.isFinite(v))) out[k] = v;
115
+ else continue;
116
+ n++;
117
+ }
118
+ return n > 0 ? out : undefined;
119
+ }
120
+
121
+ /** `"TypeError: x is undefined"` and its stack, from anything thrown. */
122
+ export function describeError(error: unknown): { message: string; stack?: string } {
123
+ if (error instanceof Error) {
124
+ const message = error.message ? `${error.name}: ${error.message}` : error.name;
125
+ return { message: message.slice(0, ANALYTICS_LIMITS.message), stack: error.stack?.slice(0, ANALYTICS_LIMITS.stack) };
126
+ }
127
+ let message: string;
128
+ try {
129
+ message = typeof error === "string" ? error : JSON.stringify(error) ?? String(error);
130
+ } catch {
131
+ message = String(error);
132
+ }
133
+ return { message: (message || "Unknown error").slice(0, ANALYTICS_LIMITS.message) };
134
+ }
135
+
136
+ // One patch of history.pushState/replaceState per page, shared by every client.
137
+ const NAVIGATE_EVENT = "mybase:navigate";
138
+ let historyPatched = false;
139
+ function patchHistory() {
140
+ if (historyPatched || typeof history === "undefined") return;
141
+ historyPatched = true;
142
+ for (const method of ["pushState", "replaceState"] as const) {
143
+ const original = history[method];
144
+ history[method] = function (this: History, ...args: Parameters<History["pushState"]>) {
145
+ const result = original.apply(this, args);
146
+ window.dispatchEvent(new Event(NAVIGATE_EVENT));
147
+ return result;
148
+ };
149
+ }
150
+ }
151
+
152
+ /** Most errors a page reports in one load, so a render loop can't flood the project. */
153
+ const MAX_ERRORS_PER_PAGE = 20;
154
+
155
+ /**
156
+ * const analytics = createAnalytics({ apiUrl: "https://mybase.example.com", project: "shop", key: "mba_…" });
157
+ * analytics.track("signup", { plan: "pro" });
158
+ */
159
+ export function createAnalytics(options: AnalyticsOptions): Analytics {
160
+ const doFetch = options.fetch ?? globalThis.fetch;
161
+ const url = `${options.apiUrl.replace(/\/+$/, "")}/analytics/${encodeURIComponent(options.project)}/events`;
162
+ const browser = typeof window !== "undefined" && typeof document !== "undefined";
163
+ const keepQuery = options.keepQuery ?? false;
164
+ const interval = options.flushIntervalMs ?? 5000;
165
+ const batchSize = Math.min(options.batchSize ?? 20, ANALYTICS_LIMITS.batch);
166
+ const tz = (() => {
167
+ try {
168
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
169
+ } catch {
170
+ return undefined;
171
+ }
172
+ })();
173
+ const dnt = browser && options.respectDoNotTrack && (navigator.doNotTrack === "1" || (window as { doNotTrack?: string }).doNotTrack === "1");
174
+
175
+ const queue: AnalyticsEvent[] = [];
176
+ let timer: ReturnType<typeof setTimeout> | null = null;
177
+ let stopped = false;
178
+ const cleanups: (() => void)[] = [];
179
+ const reportFailure = (err: unknown) => options.onError?.(err);
180
+
181
+ function send(events: AnalyticsEvent[], beacon: boolean): Promise<void> {
182
+ const body = JSON.stringify({ key: options.key, events } satisfies AnalyticsIngestRequest);
183
+ // text/plain keeps it a "simple" request: no CORS preflight, and sendBeacon accepts it.
184
+ if (beacon && browser && typeof navigator.sendBeacon === "function") {
185
+ if (navigator.sendBeacon(url, new Blob([body], { type: "text/plain" }))) return Promise.resolve();
186
+ }
187
+ return doFetch(url, { method: "POST", body, headers: { "Content-Type": "text/plain" }, keepalive: browser, credentials: "omit" }).then(
188
+ async (res) => {
189
+ if (!res.ok) {
190
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
191
+ reportFailure(new Error(data.error ?? `mybase analytics answered ${res.status}`));
192
+ }
193
+ },
194
+ reportFailure,
195
+ );
196
+ }
197
+
198
+ function flushQueued(beacon: boolean): Promise<void> {
199
+ if (timer) clearTimeout(timer);
200
+ timer = null;
201
+ const sends: Promise<void>[] = [];
202
+ while (queue.length > 0) sends.push(send(queue.splice(0, batchSize), beacon));
203
+ return Promise.all(sends).then(() => undefined);
204
+ }
205
+
206
+ function enqueue(event: AnalyticsEvent) {
207
+ if (stopped || dnt) return;
208
+ queue.push({ ...event, tz: event.tz ?? tz, ts: event.ts ?? new Date().toISOString() });
209
+ if (queue.length >= batchSize) void flushQueued(false);
210
+ else if (!timer) timer = setTimeout(() => void flushQueued(false), interval);
211
+ }
212
+
213
+ // Page views ------------------------------------------------------------------
214
+ let lastPath: string | null = null;
215
+ let firstView = true;
216
+
217
+ function pageview(path?: string, props?: AnalyticsProps) {
218
+ const raw = path ?? (browser ? location.pathname + location.search : undefined);
219
+ if (raw === undefined) return;
220
+ const normalized = normalizePath(raw, keepQuery);
221
+ const event: AnalyticsEvent = { type: "pageview", path: normalized, props: cleanProps(props) };
222
+ // The referrer only means something for the page the visitor landed on.
223
+ if (firstView && browser && document.referrer) {
224
+ try {
225
+ if (new URL(document.referrer).host !== location.host) event.referrer = document.referrer.slice(0, ANALYTICS_LIMITS.referrer);
226
+ } catch {
227
+ // not a URL; skip it
228
+ }
229
+ }
230
+ firstView = false;
231
+ lastPath = normalized;
232
+ enqueue(event);
233
+ }
234
+
235
+ if (browser && (options.autoPageviews ?? true)) {
236
+ const onRoute = () => {
237
+ const path = normalizePath(location.pathname + location.search, keepQuery);
238
+ // replaceState is often used for scroll or filter state; only a new path counts.
239
+ if (path !== lastPath) pageview();
240
+ };
241
+ patchHistory();
242
+ window.addEventListener(NAVIGATE_EVENT, onRoute);
243
+ window.addEventListener("popstate", onRoute);
244
+ cleanups.push(() => {
245
+ window.removeEventListener(NAVIGATE_EVENT, onRoute);
246
+ window.removeEventListener("popstate", onRoute);
247
+ });
248
+ pageview();
249
+ }
250
+
251
+ // Errors ----------------------------------------------------------------------
252
+ const seenErrors = new Set<string>();
253
+
254
+ function captureError(error: unknown, props?: AnalyticsProps) {
255
+ const { message, stack } = describeError(error);
256
+ // The same error over and over is one error; count it once per page load.
257
+ const id = `${message}\n${stack?.split("\n", 2)[1] ?? ""}`;
258
+ if (seenErrors.has(id) || seenErrors.size >= MAX_ERRORS_PER_PAGE) return;
259
+ seenErrors.add(id);
260
+ enqueue({
261
+ type: "error",
262
+ name: message,
263
+ stack,
264
+ path: browser ? normalizePath(location.pathname + location.search, keepQuery) : undefined,
265
+ props: cleanProps(props),
266
+ });
267
+ }
268
+
269
+ if (browser && (options.captureErrors ?? true)) {
270
+ const onError = (e: ErrorEvent) => captureError(e.error ?? e.message);
271
+ const onRejection = (e: PromiseRejectionEvent) => captureError(e.reason);
272
+ window.addEventListener("error", onError);
273
+ window.addEventListener("unhandledrejection", onRejection);
274
+ cleanups.push(() => {
275
+ window.removeEventListener("error", onError);
276
+ window.removeEventListener("unhandledrejection", onRejection);
277
+ });
278
+ }
279
+
280
+ // Leaving the page: send what's queued while the browser still lets us.
281
+ if (browser) {
282
+ const onHide = () => {
283
+ if (document.visibilityState === "hidden") void flushQueued(true);
284
+ };
285
+ const onPageHide = () => void flushQueued(true);
286
+ document.addEventListener("visibilitychange", onHide);
287
+ window.addEventListener("pagehide", onPageHide);
288
+ cleanups.push(() => {
289
+ document.removeEventListener("visibilitychange", onHide);
290
+ window.removeEventListener("pagehide", onPageHide);
291
+ });
292
+ }
293
+
294
+ return {
295
+ pageview,
296
+ track(name, props) {
297
+ if (typeof name !== "string" || !ANALYTICS_EVENT_NAME_PATTERN.test(name)) {
298
+ reportFailure(new TypeError(`invalid event name "${String(name)}": use 1–64 letters, digits, spaces, or _ . : -`));
299
+ return;
300
+ }
301
+ enqueue({ type: "event", name, path: browser ? normalizePath(location.pathname + location.search, keepQuery) : undefined, props: cleanProps(props) });
302
+ },
303
+ captureError,
304
+ flush: () => flushQueued(false),
305
+ stop() {
306
+ void flushQueued(false);
307
+ stopped = true;
308
+ for (const cleanup of cleanups.splice(0)) cleanup();
309
+ },
310
+ };
311
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,191 @@
1
+ // A small HTTP client for a project's mybase cache (Valkey, Redis-compatible), for apps that
2
+ // can't open a TCP connection to the mybase machine: serverless hosts, or anything behind the
3
+ // Cloudflare Tunnel. On the LAN or Tailscale, any Redis client works directly:
4
+ //
5
+ // new Redis("redis://shop:<password>@mybase.local:6379", { keyPrefix: "shop:" }) // ioredis
6
+ //
7
+ // Every key a project can touch starts with `<project>:`. The helpers here add that prefix
8
+ // for you; `command()` sends a raw command and doesn't (prefix keys yourself).
9
+ //
10
+ // The wire format matches Upstash's REST API, so `@upstash/redis` also works against
11
+ // `<apiUrl>/cache/<project>` with a mybase API key as its token (without the auto-prefix).
12
+ import { MybaseApiError } from "./cron";
13
+
14
+ export { MybaseApiError };
15
+
16
+ export type CacheArg = string | number;
17
+
18
+ /** One reply per command: Valkey errors don't fail the whole pipeline. */
19
+ export type PipelineResult = { result: unknown } | { error: string };
20
+
21
+ export interface CacheOptions {
22
+ /** Where the mybase API is reachable from your app, e.g. `https://mybase.example.com`. */
23
+ apiUrl: string;
24
+ project: string;
25
+ /** A mybase API key (`mb_…`) with `cache:write` for this project. Server-side only. */
26
+ apiKey: string;
27
+ /** Default `globalThis.fetch`. */
28
+ fetch?: typeof fetch;
29
+ }
30
+
31
+ export interface SetOptions {
32
+ /** Expire after this many seconds. */
33
+ ex?: number;
34
+ /** Expire after this many milliseconds. */
35
+ px?: number;
36
+ /** Only set if the key doesn't exist. */
37
+ nx?: boolean;
38
+ /** Only set if the key exists. */
39
+ xx?: boolean;
40
+ }
41
+
42
+ /** Builds a batch of commands sent in one request. Keys get the project prefix. */
43
+ export interface CachePipeline {
44
+ command(args: CacheArg[]): CachePipeline;
45
+ get(key: string): CachePipeline;
46
+ set(key: string, value: CacheArg, options?: SetOptions): CachePipeline;
47
+ del(...keys: string[]): CachePipeline;
48
+ incr(key: string, by?: number): CachePipeline;
49
+ expire(key: string, seconds: number): CachePipeline;
50
+ /** Sends the batch. `atomic: true` wraps it in MULTI/EXEC. */
51
+ exec(options?: { atomic?: boolean }): Promise<PipelineResult[]>;
52
+ }
53
+
54
+ export interface Cache {
55
+ /** `key` with the project prefix, for raw commands. */
56
+ key(key: string): string;
57
+ /** A raw command, e.g. `["ZADD", cache.key("board"), 10, "ana"]`. Keys aren't prefixed. */
58
+ command<T = unknown>(args: CacheArg[]): Promise<T>;
59
+ get(key: string): Promise<string | null>;
60
+ /** JSON.parse of `get`, or null. */
61
+ getJson<T = unknown>(key: string): Promise<T | null>;
62
+ /** `true` when set, `false` when `nx`/`xx` stopped it. */
63
+ set(key: string, value: CacheArg, options?: SetOptions): Promise<boolean>;
64
+ setJson(key: string, value: unknown, options?: SetOptions): Promise<boolean>;
65
+ /** How many keys were deleted. */
66
+ del(...keys: string[]): Promise<number>;
67
+ exists(...keys: string[]): Promise<number>;
68
+ incr(key: string, by?: number): Promise<number>;
69
+ expire(key: string, seconds: number): Promise<boolean>;
70
+ /** Seconds left; -1 with no expiry, -2 when the key doesn't exist. */
71
+ ttl(key: string): Promise<number>;
72
+ mget(...keys: string[]): Promise<(string | null)[]>;
73
+ hget(key: string, field: string): Promise<string | null>;
74
+ hset(key: string, fields: Record<string, CacheArg>): Promise<number>;
75
+ hgetall(key: string): Promise<Record<string, string>>;
76
+ hdel(key: string, ...fields: string[]): Promise<number>;
77
+ lpush(key: string, ...values: CacheArg[]): Promise<number>;
78
+ rpush(key: string, ...values: CacheArg[]): Promise<number>;
79
+ lrange(key: string, start: number, stop: number): Promise<string[]>;
80
+ sadd(key: string, ...members: CacheArg[]): Promise<number>;
81
+ srem(key: string, ...members: CacheArg[]): Promise<number>;
82
+ smembers(key: string): Promise<string[]>;
83
+ sismember(key: string, member: CacheArg): Promise<boolean>;
84
+ zadd(key: string, ...entries: { score: number; member: string }[]): Promise<number>;
85
+ /** Members by rank, lowest score first. `withScores` adds the scores. */
86
+ zrange(key: string, start: number, stop: number, options?: { withScores?: boolean; rev?: boolean }): Promise<string[]>;
87
+ zincrby(key: string, by: number, member: string): Promise<number>;
88
+ publish(channel: string, message: string): Promise<number>;
89
+ pipeline(): CachePipeline;
90
+ }
91
+
92
+ function setArgs(key: string, value: CacheArg, o: SetOptions = {}): CacheArg[] {
93
+ const args: CacheArg[] = ["SET", key, value];
94
+ if (o.ex !== undefined) args.push("EX", o.ex);
95
+ if (o.px !== undefined) args.push("PX", o.px);
96
+ if (o.nx) args.push("NX");
97
+ if (o.xx) args.push("XX");
98
+ return args;
99
+ }
100
+
101
+ /**
102
+ * A cache client over HTTP. Each call is one request; batch with `pipeline()`.
103
+ *
104
+ * const cache = createCache({ apiUrl, project: "shop", apiKey: process.env.MYBASE_API_KEY! });
105
+ * await cache.set("session:abc", userId, { ex: 3600 }); // key is shop:session:abc
106
+ */
107
+ export function createCache(options: CacheOptions): Cache {
108
+ const doFetch = options.fetch ?? globalThis.fetch;
109
+ const base = `${options.apiUrl.replace(/\/+$/, "")}/cache/${encodeURIComponent(options.project)}`;
110
+ const prefix = `${options.project}:`;
111
+ const k = (key: string) => (key.startsWith(prefix) ? key : prefix + key);
112
+
113
+ async function post<T>(path: string, body: unknown): Promise<T> {
114
+ const res = await doFetch(base + path, {
115
+ method: "POST",
116
+ body: JSON.stringify(body),
117
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${options.apiKey}` },
118
+ });
119
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
120
+ if (!res.ok) throw new MybaseApiError(data.error ?? `mybase API answered ${res.status}`, res.status);
121
+ return data as T;
122
+ }
123
+
124
+ async function command<T>(args: CacheArg[]): Promise<T> {
125
+ const data = await post<{ result?: T; error?: string }>("", args);
126
+ if (data.error !== undefined) throw new MybaseApiError(data.error, 400);
127
+ return data.result as T;
128
+ }
129
+
130
+ function pipeline(): CachePipeline {
131
+ const commands: CacheArg[][] = [];
132
+ const add = (args: CacheArg[]) => {
133
+ commands.push(args);
134
+ return p;
135
+ };
136
+ const p: CachePipeline = {
137
+ command: add,
138
+ get: (key) => add(["GET", k(key)]),
139
+ set: (key, value, o) => add(setArgs(k(key), value, o)),
140
+ del: (...keys) => add(["DEL", ...keys.map(k)]),
141
+ incr: (key, by = 1) => add(by === 1 ? ["INCR", k(key)] : ["INCRBY", k(key), by]),
142
+ expire: (key, seconds) => add(["EXPIRE", k(key), seconds]),
143
+ exec: ({ atomic = false } = {}) => (commands.length === 0 ? Promise.resolve([]) : post(atomic ? "/multi-exec" : "/pipeline", commands)),
144
+ };
145
+ return p;
146
+ }
147
+
148
+ const pairs = (flat: string[]) => {
149
+ const out: Record<string, string> = {};
150
+ for (let i = 0; i + 1 < flat.length; i += 2) out[flat[i]!] = flat[i + 1]!;
151
+ return out;
152
+ };
153
+
154
+ return {
155
+ key: k,
156
+ command,
157
+ get: (key) => command(["GET", k(key)]),
158
+ async getJson<T>(key: string) {
159
+ const raw = await command<string | null>(["GET", k(key)]);
160
+ return raw === null ? null : (JSON.parse(raw) as T);
161
+ },
162
+ set: async (key, value, o) => (await command<string | null>(setArgs(k(key), value, o))) === "OK",
163
+ setJson: async (key, value, o) => (await command<string | null>(setArgs(k(key), JSON.stringify(value), o))) === "OK",
164
+ del: (...keys) => command(["DEL", ...keys.map(k)]),
165
+ exists: (...keys) => command(["EXISTS", ...keys.map(k)]),
166
+ incr: (key, by = 1) => command(by === 1 ? ["INCR", k(key)] : ["INCRBY", k(key), by]),
167
+ expire: async (key, seconds) => (await command<number>(["EXPIRE", k(key), seconds])) === 1,
168
+ ttl: (key) => command(["TTL", k(key)]),
169
+ mget: (...keys) => command(["MGET", ...keys.map(k)]),
170
+ hget: (key, field) => command(["HGET", k(key), field]),
171
+ hset: (key, fields) => command(["HSET", k(key), ...Object.entries(fields).flat()]),
172
+ hgetall: async (key) => {
173
+ // Upstash-style replies keep RESP2's flat array; the API may also answer with an object.
174
+ const raw = await command<string[] | Record<string, string>>(["HGETALL", k(key)]);
175
+ return Array.isArray(raw) ? pairs(raw) : raw;
176
+ },
177
+ hdel: (key, ...fields) => command(["HDEL", k(key), ...fields]),
178
+ lpush: (key, ...values) => command(["LPUSH", k(key), ...values]),
179
+ rpush: (key, ...values) => command(["RPUSH", k(key), ...values]),
180
+ lrange: (key, start, stop) => command(["LRANGE", k(key), start, stop]),
181
+ sadd: (key, ...members) => command(["SADD", k(key), ...members]),
182
+ srem: (key, ...members) => command(["SREM", k(key), ...members]),
183
+ smembers: (key) => command(["SMEMBERS", k(key)]),
184
+ sismember: async (key, member) => (await command<number>(["SISMEMBER", k(key), member])) === 1,
185
+ zadd: (key, ...entries) => command(["ZADD", k(key), ...entries.flatMap((e) => [e.score, e.member])]),
186
+ zrange: (key, start, stop, o = {}) => command(["ZRANGE", k(key), start, stop, ...(o.rev ? ["REV"] : []), ...(o.withScores ? ["WITHSCORES"] : [])]),
187
+ zincrby: async (key, by, member) => Number(await command<string | number>(["ZINCRBY", k(key), by, member])),
188
+ publish: (channel, message) => command(["PUBLISH", k(channel), message]),
189
+ pipeline,
190
+ };
191
+ }
package/src/config.ts ADDED
@@ -0,0 +1,76 @@
1
+ // Cron jobs, rate limit rules, and queues declared in code. `mybase cron sync`, `mybase ratelimit
2
+ // sync`, and `mybase queue sync` import this file (and only this file), so keep app code out of it:
3
+ // handlers live with your routes (see ./cron).
4
+ import type { DeadLetterSettings } from "./queue";
5
+ import type { RateLimitAlgorithm } from "./ratelimit";
6
+
7
+ /** One job mybase calls on a schedule. */
8
+ export interface CronJobConfig {
9
+ /** Standard 5-field cron expression (minute precision), e.g. `0 3 * * *`. */
10
+ schedule: string;
11
+ /** Appended to `cron.baseUrl`. Default `/api/cron`. */
12
+ path?: string;
13
+ /** IANA time zone the schedule runs in. Default `UTC`. */
14
+ timezone?: string;
15
+ /** JSON sent to your handler as `run.payload`. */
16
+ payload?: unknown;
17
+ /** Extra tries after a failed call (network error, timeout, 429, 5xx). 0–5, default 2. */
18
+ retries?: number;
19
+ /** How long to wait for your endpoint, in ms. 1000–120000, default 30000. */
20
+ timeoutMs?: number;
21
+ }
22
+
23
+ /** One named rate limit. Apps use it with `limiter.limit("<name>", id)`. */
24
+ export interface RateLimitRuleConfig {
25
+ /** Default `sliding`. */
26
+ algorithm?: RateLimitAlgorithm;
27
+ /** Requests (units) allowed per window. */
28
+ limit: number;
29
+ /** Milliseconds, or `"10s"`, `"1m"`, `"1h"`, `"1d"`. 1s–30d. */
30
+ window: number | string;
31
+ /** Token bucket only: capacity. Default `limit`. */
32
+ burst?: number;
33
+ }
34
+
35
+ /** One queue. Durations are ms or `"30s"`, `"5m"`, `"4d"`. */
36
+ export interface QueueConfig {
37
+ /** Strict order per message group, deduplicated sends. Can't be changed after creation. Default false. */
38
+ fifo?: boolean;
39
+ /** How long a received message stays hidden. 0–12h, default 30s. */
40
+ visibilityTimeout?: number | string;
41
+ /** How long new messages wait before they can be received. 0–15m, default 0. */
42
+ delay?: number | string;
43
+ /** How long unreceived messages are kept. 1m–14d, default 4d. */
44
+ retention?: number | string;
45
+ /** Default long-poll wait for receives. 0–20s, default 0. */
46
+ receiveWait?: number | string;
47
+ /** 1024–262144, default 262144. */
48
+ maxMessageBytes?: number;
49
+ /** FIFO only: deduplicate by body when a send has no `dedupId`. */
50
+ contentDedup?: boolean;
51
+ /** After `maxReceives` failed deliveries, move messages to this queue. */
52
+ deadLetter?: DeadLetterSettings;
53
+ }
54
+
55
+ export interface MybaseConfig {
56
+ /** The mybase project these jobs belong to. */
57
+ project: string;
58
+ cron?: {
59
+ /** Where your app is reachable from the mybase server, e.g. `https://shop.example.com`. */
60
+ baseUrl: string;
61
+ jobs: Record<string, CronJobConfig>;
62
+ };
63
+ ratelimit?: {
64
+ rules: Record<string, RateLimitRuleConfig>;
65
+ };
66
+ queue?: {
67
+ queues: Record<string, QueueConfig>;
68
+ };
69
+ }
70
+
71
+ /** Identity function that types `mybase.config.ts`. */
72
+ export function defineConfig<const C extends MybaseConfig>(config: C): C {
73
+ return config;
74
+ }
75
+
76
+ export const DEFAULT_CRON_PATH = "/api/cron";