bonescript-compiler 0.7.0 → 0.9.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.
@@ -19,11 +19,13 @@ export function emitNotifyService(system: IR.IRSystem): string {
19
19
  lines.push(``);
20
20
  lines.push(`import { SystemEvent } from "./events";`);
21
21
  lines.push(``);
22
- lines.push(`export type NotifyProvider = "resend" | "sendgrid" | "log";`);
22
+ lines.push(`export type NotifyProvider = "resend" | "sendgrid" | "webhook" | "log";`);
23
23
  lines.push(``);
24
24
  lines.push(`const PROVIDER = (process.env.NOTIFY_PROVIDER || "log") as NotifyProvider;`);
25
25
  lines.push(`const API_KEY = process.env.NOTIFY_API_KEY || "";`);
26
26
  lines.push(`const FROM_EMAIL = process.env.NOTIFY_FROM_EMAIL || "noreply@example.com";`);
27
+ lines.push(`const WEBHOOK_URL = process.env.NOTIFY_WEBHOOK_URL || "";`);
28
+ lines.push(`const WEBHOOK_SECRET = process.env.NOTIFY_WEBHOOK_SECRET || "";`);
27
29
  lines.push(``);
28
30
  lines.push(`export interface NotifyMessage {`);
29
31
  lines.push(` to: string;`);
@@ -79,6 +81,87 @@ export function emitNotifyService(system: IR.IRSystem): string {
79
81
  lines.push(` if (!res.ok) throw new Error(\`SendGrid error: \${res.status}\`);`);
80
82
  lines.push(` return;`);
81
83
  lines.push(` }`);
84
+ lines.push(` if (PROVIDER === "webhook") {`);
85
+ lines.push(` // Email-style notifications still flow through the webhook so the receiver sees a uniform shape.`);
86
+ lines.push(` await sendWebhook({ kind: "email", to: msg.to, subject: msg.subject, body: msg.body });`);
87
+ lines.push(` return;`);
88
+ lines.push(` }`);
89
+ lines.push(`}`);
90
+ lines.push(``);
91
+ // Webhook signing. We use HMAC-SHA256 over the raw body for tamper detection.
92
+ // Receivers verify with the same secret.
93
+ lines.push(`import { createHmac } from "crypto";`);
94
+ lines.push(``);
95
+ lines.push(`function signPayload(body: string): string {`);
96
+ lines.push(` if (!WEBHOOK_SECRET) return "";`);
97
+ lines.push(` return createHmac("sha256", WEBHOOK_SECRET).update(body).digest("hex");`);
98
+ lines.push(`}`);
99
+ lines.push(``);
100
+ // Private-host detection. Catches loopback (127/8, ::1, 0.0.0.0), RFC1918
101
+ // (10/8, 172.16/12, 192.168/16), link-local (169.254/16, fe80::/10) and
102
+ // unique-local IPv6 (fc00::/7). DNS hostnames that resolve to private
103
+ // addresses are not blocked here — that requires a DNS lookup and a
104
+ // dual-stack check; we leave that to the deployer's network policy.
105
+ lines.push(`function isPrivateHost(host: string): boolean {`);
106
+ lines.push(` const h = host.toLowerCase();`);
107
+ lines.push(` if (h === "localhost" || h === "0.0.0.0" || h === "::" || h === "::1") return true;`);
108
+ lines.push(` // IPv4 dotted quad`);
109
+ lines.push(` const m4 = h.match(/^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$/);`);
110
+ lines.push(` if (m4) {`);
111
+ lines.push(` const a = Number(m4[1]), b = Number(m4[2]);`);
112
+ lines.push(` if (a === 127) return true; // loopback`);
113
+ lines.push(` if (a === 10) return true; // RFC1918`);
114
+ lines.push(` if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918`);
115
+ lines.push(` if (a === 192 && b === 168) return true; // RFC1918`);
116
+ lines.push(` if (a === 169 && b === 254) return true; // link-local / cloud metadata`);
117
+ lines.push(` if (a === 0) return true;`);
118
+ lines.push(` return false;`);
119
+ lines.push(` }`);
120
+ lines.push(` // IPv6 — strip optional brackets and zone suffix.`);
121
+ lines.push(` const v6 = h.replace(/^\\[|\\]$/g, "").split("%")[0];`);
122
+ lines.push(` if (/^fe[89ab][0-9a-f]?:/i.test(v6)) return true; // link-local fe80::/10`);
123
+ lines.push(` if (/^f[cd][0-9a-f]?:/i.test(v6)) return true; // unique-local fc00::/7`);
124
+ lines.push(` return false;`);
125
+ lines.push(`}`);
126
+ lines.push(``);
127
+ lines.push(`/**`);
128
+ lines.push(` * Send a JSON payload to NOTIFY_WEBHOOK_URL.`);
129
+ lines.push(` *`);
130
+ lines.push(` * Headers:`);
131
+ lines.push(` * Content-Type: application/json`);
132
+ lines.push(` * X-BoneScript-Signature: <hex hmac-sha256(body, WEBHOOK_SECRET)> (only if secret set)`);
133
+ lines.push(` * X-BoneScript-Event: <event type> (set by event handlers)`);
134
+ lines.push(` */`);
135
+ lines.push(`export async function sendWebhook(payload: Record<string, unknown>, eventType?: string): Promise<void> {`);
136
+ lines.push(` if (PROVIDER === "log") {`);
137
+ lines.push(` console.log(\`[notify:webhook] \${eventType || payload.kind || "event"}\`, JSON.stringify(payload).slice(0, 200));`);
138
+ lines.push(` return;`);
139
+ lines.push(` }`);
140
+ lines.push(` if (!WEBHOOK_URL) {`);
141
+ lines.push(` throw new Error("NOTIFY_WEBHOOK_URL is not configured");`);
142
+ lines.push(` }`);
143
+ lines.push(` // Validate URL — only http(s), and reject loopback / RFC1918 /`);
144
+ lines.push(` // link-local hosts to make this server unusable as an SSRF probe.`);
145
+ lines.push(` // Set NOTIFY_WEBHOOK_ALLOW_PRIVATE=1 to opt out (e.g. for internal CI`);
146
+ lines.push(` // setups where the webhook receiver is on the same network).`);
147
+ lines.push(` let url: URL;`);
148
+ lines.push(` try { url = new URL(WEBHOOK_URL); }`);
149
+ lines.push(` catch { throw new Error("Invalid NOTIFY_WEBHOOK_URL"); }`);
150
+ lines.push(` if (url.protocol !== "https:" && url.protocol !== "http:") {`);
151
+ lines.push(` throw new Error(\`Webhook URL protocol must be http(s), got \${url.protocol}\`);`);
152
+ lines.push(` }`);
153
+ lines.push(` if (process.env.NOTIFY_WEBHOOK_ALLOW_PRIVATE !== "1") {`);
154
+ lines.push(` if (isPrivateHost(url.hostname)) {`);
155
+ lines.push(` throw new Error(\`Webhook URL host is loopback / private / link-local: \${url.hostname}. Set NOTIFY_WEBHOOK_ALLOW_PRIVATE=1 to allow.\`);`);
156
+ lines.push(` }`);
157
+ lines.push(` }`);
158
+ lines.push(` const body = JSON.stringify(payload);`);
159
+ lines.push(` const headers: Record<string, string> = { "Content-Type": "application/json" };`);
160
+ lines.push(` const sig = signPayload(body);`);
161
+ lines.push(` if (sig) headers["X-BoneScript-Signature"] = sig;`);
162
+ lines.push(` if (eventType) headers["X-BoneScript-Event"] = eventType;`);
163
+ lines.push(` const res = await fetch(WEBHOOK_URL, { method: "POST", headers, body });`);
164
+ lines.push(` if (!res.ok) throw new Error(\`Webhook delivery failed: \${res.status}\`);`);
82
165
  lines.push(`}`);
83
166
  lines.push(``);
84
167
 
@@ -50,8 +50,13 @@ function toPrismaNativeType(irType: string): string | null {
50
50
  case "bytes": return null;
51
51
  case "timestamp": return "@db.Timestamptz";
52
52
  case "float": return "@db.DoublePrecision";
53
- case "uint": return "@db.BigInt";
54
- case "int": return "@db.BigInt";
53
+ // BoneScript `uint` and `int` map to Prisma `Int`. Prisma rejects
54
+ // `Int @db.BigInt` (BigInt requires the Prisma `BigInt` type), so we use
55
+ // no native type and let Prisma map to the default Postgres `INTEGER`.
56
+ // If a caller needs 64-bit ints they should use the `int` IR type with a
57
+ // future `@db.bigint` annotation — TBD.
58
+ case "uint": return null;
59
+ case "int": return null;
55
60
  default: return null;
56
61
  }
57
62
  }
@@ -253,6 +258,9 @@ export class PrismaEmitter {
253
258
  } else if (field.name === "created_at" || (field.type === "timestamp" && field.name.includes("created"))) {
254
259
  attrs.push("@default(now())");
255
260
  } else if (field.name === "updated_at") {
261
+ // @updatedAt only fires on update — we also need a default for create
262
+ // or the NOT NULL column has no value at INSERT time.
263
+ attrs.push("@default(now())");
256
264
  attrs.push("@updatedAt");
257
265
  } else if (field.default_value) {
258
266
  const dv = this.mapDefaultValue(field.default_value, field.type);
@@ -0,0 +1,236 @@
1
+ /**
2
+ * BoneScript React Hooks Emitter
3
+ *
4
+ * Generates a typed React hooks file (sdk/react.ts) on top of the existing
5
+ * fetch SDK. Hooks are zero-dep — they use React's built-in useState +
6
+ * useEffect rather than pulling in @tanstack/react-query so consumers can
7
+ * decide their own data layer.
8
+ *
9
+ * For each entity:
10
+ * useList<Entity>() → { data, loading, error, refetch }
11
+ * use<Entity>(id) → { data, loading, error, refetch }
12
+ * useCreate<Entity>() → mutate(input) → Promise<Entity>
13
+ * useUpdate<Entity>() → mutate(id, patch) → Promise<Entity>
14
+ * useDelete<Entity>() → mutate(id) → Promise<void>
15
+ *
16
+ * For each capability:
17
+ * useCapability<Name>() → mutate(input) → Promise<unknown>
18
+ *
19
+ * Hooks are framework-agnostic in shape (no react-query, no SWR) so they work
20
+ * with Next.js, Vite, CRA, or any plain React app.
21
+ */
22
+
23
+ import * as IR from "./ir";
24
+ import { EmittedFile } from "./emitter";
25
+
26
+ function toSnakeCase(s: string): string {
27
+ return s.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
28
+ }
29
+
30
+ function toPascalCase(s: string): string {
31
+ return s.replace(/(^|[-_\s])(\w)/g, (_, __, c: string) => c.toUpperCase());
32
+ }
33
+
34
+ const TS_TYPE_MAP: Record<string, string> = {
35
+ string: "string", uint: "number", int: "number", float: "number",
36
+ bool: "boolean", timestamp: "string", uuid: "string", bytes: "string", json: "unknown",
37
+ };
38
+
39
+ function toTsType(irType: string): string {
40
+ if (TS_TYPE_MAP[irType]) return TS_TYPE_MAP[irType];
41
+ const m = irType.match(/^(list|set)<(.+)>$/);
42
+ if (m) return `${toTsType(m[2])}[]`;
43
+ const om = irType.match(/^optional<(.+)>$/);
44
+ if (om) return `${toTsType(om[1])} | null`;
45
+ return irType;
46
+ }
47
+
48
+ export function emitReactHooks(system: IR.IRSystem): EmittedFile {
49
+ const lines: string[] = [];
50
+
51
+ lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
52
+ lines.push(`// React hooks for the ${system.name} API.`);
53
+ lines.push(`//`);
54
+ lines.push(`// Pair with sdk/client.ts. Pass an instance of <System>Client to <ApiProvider>.`);
55
+ lines.push(``);
56
+ lines.push(`import { useCallback, useEffect, useState, createContext, useContext, ReactNode, createElement } from "react";`);
57
+ lines.push(``);
58
+
59
+ // Entity types (forward-declared so the hooks compile without importing the SDK)
60
+ // We re-declare them here to keep this file independent — the consumer can
61
+ // also import these types from sdk/client.ts if they prefer.
62
+ const apiModules = system.modules.filter(m => m.kind === "api_service" && m.models.length > 0);
63
+ for (const mod of apiModules) {
64
+ const model = mod.models[0];
65
+ lines.push(`export interface ${toPascalCase(model.name)} {`);
66
+ for (const field of model.fields) {
67
+ const optional = field.nullable ? "?" : "";
68
+ lines.push(` ${field.name}${optional}: ${toTsType(field.type)};`);
69
+ }
70
+ lines.push(`}`);
71
+ lines.push(``);
72
+ }
73
+
74
+ lines.push(`export interface ApiClient {`);
75
+ lines.push(` baseUrl: string;`);
76
+ lines.push(` getToken: () => string | null;`);
77
+ lines.push(`}`);
78
+ lines.push(``);
79
+ lines.push(`async function apiFetch<T>(client: ApiClient, method: string, path: string, body?: unknown): Promise<T> {`);
80
+ lines.push(` const headers: Record<string, string> = { "Content-Type": "application/json" };`);
81
+ lines.push(` const token = client.getToken();`);
82
+ lines.push(` if (token) headers["Authorization"] = "Bearer " + token;`);
83
+ lines.push(` const res = await fetch(client.baseUrl + path, {`);
84
+ lines.push(` method,`);
85
+ lines.push(` headers,`);
86
+ lines.push(` body: body !== undefined ? JSON.stringify(body) : undefined,`);
87
+ lines.push(` });`);
88
+ lines.push(` if (!res.ok) {`);
89
+ lines.push(` let errMsg = "Request failed: " + res.status;`);
90
+ lines.push(` try { const j = await res.json() as { error?: { message?: string } }; if (j.error?.message) errMsg = j.error.message; } catch {}`);
91
+ lines.push(` throw new Error(errMsg);`);
92
+ lines.push(` }`);
93
+ lines.push(` if (res.status === 204) return undefined as unknown as T;`);
94
+ lines.push(` return await res.json() as T;`);
95
+ lines.push(`}`);
96
+ lines.push(``);
97
+
98
+ // Provider + context. createElement avoids needing JSX in this generated file.
99
+ lines.push(`const ApiContext = createContext<ApiClient | null>(null);`);
100
+ lines.push(``);
101
+ lines.push(`export interface ApiProviderProps {`);
102
+ lines.push(` client: ApiClient;`);
103
+ lines.push(` children: ReactNode;`);
104
+ lines.push(`}`);
105
+ lines.push(``);
106
+ lines.push(`export function ApiProvider(props: ApiProviderProps) {`);
107
+ lines.push(` return createElement(ApiContext.Provider, { value: props.client }, props.children);`);
108
+ lines.push(`}`);
109
+ lines.push(``);
110
+ lines.push(`function useApi(): ApiClient {`);
111
+ lines.push(` const ctx = useContext(ApiContext);`);
112
+ lines.push(` if (!ctx) throw new Error("useApi must be used inside <ApiProvider>");`);
113
+ lines.push(` return ctx;`);
114
+ lines.push(`}`);
115
+ lines.push(``);
116
+
117
+ // Generic shape helpers
118
+ lines.push(`export interface QueryState<T> {`);
119
+ lines.push(` data: T | null;`);
120
+ lines.push(` loading: boolean;`);
121
+ lines.push(` error: Error | null;`);
122
+ lines.push(` refetch: () => Promise<void>;`);
123
+ lines.push(`}`);
124
+ lines.push(``);
125
+ lines.push(`export interface MutationState<TInput, TOutput> {`);
126
+ lines.push(` mutate: (input: TInput) => Promise<TOutput>;`);
127
+ lines.push(` loading: boolean;`);
128
+ lines.push(` error: Error | null;`);
129
+ lines.push(` reset: () => void;`);
130
+ lines.push(`}`);
131
+ lines.push(``);
132
+ lines.push(`export interface PaginatedResponse<T> {`);
133
+ lines.push(` items: T[];`);
134
+ lines.push(` total: number;`);
135
+ lines.push(` page: number;`);
136
+ lines.push(` page_size: number;`);
137
+ lines.push(`}`);
138
+ lines.push(``);
139
+
140
+ // Generic primitive hooks. Specific entity / capability hooks below wrap these.
141
+ lines.push(`function useQueryGeneric<T>(method: string, path: string, deps: unknown[]): QueryState<T> {`);
142
+ lines.push(` const client = useApi();`);
143
+ lines.push(` const [data, setData] = useState<T | null>(null);`);
144
+ lines.push(` const [loading, setLoading] = useState(true);`);
145
+ lines.push(` const [error, setError] = useState<Error | null>(null);`);
146
+ lines.push(` const fetchOnce = useCallback(async () => {`);
147
+ lines.push(` setLoading(true); setError(null);`);
148
+ lines.push(` try { const result = await apiFetch<T>(client, method, path); setData(result); }`);
149
+ lines.push(` catch (e: unknown) { setError(e instanceof Error ? e : new Error(String(e))); }`);
150
+ lines.push(` finally { setLoading(false); }`);
151
+ // eslint-disable-next-line react-hooks/exhaustive-deps
152
+ lines.push(` }, [client, method, path]);`);
153
+ lines.push(` useEffect(() => { void fetchOnce(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, deps);`);
154
+ lines.push(` return { data, loading, error, refetch: fetchOnce };`);
155
+ lines.push(`}`);
156
+ lines.push(``);
157
+ lines.push(`function useMutationGeneric<TInput, TOutput>(builder: (input: TInput) => { method: string; path: string; body?: unknown }): MutationState<TInput, TOutput> {`);
158
+ lines.push(` const client = useApi();`);
159
+ lines.push(` const [loading, setLoading] = useState(false);`);
160
+ lines.push(` const [error, setError] = useState<Error | null>(null);`);
161
+ lines.push(` const mutate = useCallback(async (input: TInput): Promise<TOutput> => {`);
162
+ lines.push(` setLoading(true); setError(null);`);
163
+ lines.push(` try { const req = builder(input); return await apiFetch<TOutput>(client, req.method, req.path, req.body); }`);
164
+ lines.push(` catch (e: unknown) { const err = e instanceof Error ? e : new Error(String(e)); setError(err); throw err; }`);
165
+ lines.push(` finally { setLoading(false); }`);
166
+ lines.push(` }, [client, builder]);`);
167
+ lines.push(` const reset = useCallback(() => setError(null), []);`);
168
+ lines.push(` return { mutate, loading, error, reset };`);
169
+ lines.push(`}`);
170
+ lines.push(``);
171
+
172
+ // Per-entity hooks
173
+ for (const mod of apiModules) {
174
+ const model = mod.models[0];
175
+ const entity = toPascalCase(model.name);
176
+ const tableName = toSnakeCase(model.name) + "s";
177
+ const route = `/${tableName}`;
178
+
179
+ lines.push(`// ─── ${entity} ───────────────────────────────────────────────────────────`);
180
+ lines.push(``);
181
+
182
+ // List
183
+ lines.push(`export function useList${entity}(): QueryState<PaginatedResponse<${entity}>> {`);
184
+ lines.push(` return useQueryGeneric<PaginatedResponse<${entity}>>("GET", "${route}", []);`);
185
+ lines.push(`}`);
186
+ lines.push(``);
187
+
188
+ // Read
189
+ lines.push(`export function use${entity}(id: string | null): QueryState<${entity}> {`);
190
+ lines.push(` return useQueryGeneric<${entity}>("GET", id ? \`${route}/\${id}\` : "${route}", [id]);`);
191
+ lines.push(`}`);
192
+ lines.push(``);
193
+
194
+ // Create
195
+ const createInputType = `Partial<${entity}>`;
196
+ lines.push(`export function useCreate${entity}(): MutationState<${createInputType}, ${entity}> {`);
197
+ lines.push(` return useMutationGeneric<${createInputType}, ${entity}>((input) => ({ method: "POST", path: "${route}", body: input }));`);
198
+ lines.push(`}`);
199
+ lines.push(``);
200
+
201
+ // Update
202
+ lines.push(`export function useUpdate${entity}(): MutationState<{ id: string; patch: Partial<${entity}> }, ${entity}> {`);
203
+ lines.push(` return useMutationGeneric<{ id: string; patch: Partial<${entity}> }, ${entity}>(({ id, patch }) => ({ method: "PUT", path: \`${route}/\${id}\`, body: patch }));`);
204
+ lines.push(`}`);
205
+ lines.push(``);
206
+
207
+ // Delete
208
+ lines.push(`export function useDelete${entity}(): MutationState<string, void> {`);
209
+ lines.push(` return useMutationGeneric<string, void>((id) => ({ method: "DELETE", path: \`${route}/\${id}\` }));`);
210
+ lines.push(`}`);
211
+ lines.push(``);
212
+
213
+ // Capability hooks
214
+ for (const iface of mod.interfaces) {
215
+ for (const method of iface.methods) {
216
+ if (["create", "read", "update", "delete", "list"].includes(method.name)) continue;
217
+ const capName = toPascalCase(method.name);
218
+ const inputType = method.input.length > 0
219
+ ? `{ ${method.input.map(p => `${p.name}${p.nullable ? "?" : ""}: ${toTsType(p.type)}`).join("; ")} }`
220
+ : "Record<string, never>";
221
+ const endpoint = `${route}/${method.name.replace(/_/g, "-")}`;
222
+ lines.push(`export function useCapability${capName}(): MutationState<${inputType}, unknown> {`);
223
+ lines.push(` return useMutationGeneric<${inputType}, unknown>((input) => ({ method: "POST", path: "${endpoint}", body: input }));`);
224
+ lines.push(`}`);
225
+ lines.push(``);
226
+ }
227
+ }
228
+ }
229
+
230
+ return {
231
+ path: "sdk/react.ts",
232
+ content: lines.join("\n"),
233
+ language: "typescript",
234
+ source_module: "sdk",
235
+ };
236
+ }