tina4-nodejs 3.0.0-rc.2
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/BENCHMARK_REPORT.md +96 -0
- package/CARBONAH.md +140 -0
- package/CLAUDE.md +599 -0
- package/COMPARISON.md +194 -0
- package/README.md +595 -0
- package/package.json +59 -0
- package/packages/cli/src/bin.ts +110 -0
- package/packages/cli/src/commands/init.ts +194 -0
- package/packages/cli/src/commands/migrate.ts +96 -0
- package/packages/cli/src/commands/migrateCreate.ts +59 -0
- package/packages/cli/src/commands/routes.ts +61 -0
- package/packages/cli/src/commands/serve.ts +58 -0
- package/packages/cli/src/commands/test.ts +83 -0
- package/packages/core/gallery/auth/meta.json +1 -0
- package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +22 -0
- package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +16 -0
- package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +97 -0
- package/packages/core/gallery/database/meta.json +1 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +13 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +17 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +23 -0
- package/packages/core/gallery/error-overlay/meta.json +1 -0
- package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +17 -0
- package/packages/core/gallery/orm/meta.json +1 -0
- package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +12 -0
- package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +7 -0
- package/packages/core/gallery/queue/meta.json +1 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +16 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +10 -0
- package/packages/core/gallery/rest-api/meta.json +1 -0
- package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +6 -0
- package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +7 -0
- package/packages/core/gallery/templates/meta.json +1 -0
- package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +15 -0
- package/packages/core/gallery/templates/src/templates/gallery_page.twig +257 -0
- package/packages/core/public/css/tina4.css +2463 -0
- package/packages/core/public/css/tina4.min.css +1 -0
- package/packages/core/public/favicon.ico +0 -0
- package/packages/core/public/images/logo.svg +5 -0
- package/packages/core/public/images/tina4-logo-icon.webp +0 -0
- package/packages/core/public/js/frond.min.js +420 -0
- package/packages/core/public/js/tina4-dev-admin.min.js +327 -0
- package/packages/core/public/js/tina4.min.js +93 -0
- package/packages/core/public/swagger/index.html +90 -0
- package/packages/core/public/swagger/oauth2-redirect.html +63 -0
- package/packages/core/src/ai.ts +359 -0
- package/packages/core/src/api.ts +248 -0
- package/packages/core/src/auth.ts +287 -0
- package/packages/core/src/cache.ts +121 -0
- package/packages/core/src/constants.ts +48 -0
- package/packages/core/src/container.ts +90 -0
- package/packages/core/src/devAdmin.ts +2024 -0
- package/packages/core/src/devMailbox.ts +316 -0
- package/packages/core/src/dotenv.ts +172 -0
- package/packages/core/src/errorOverlay.test.ts +122 -0
- package/packages/core/src/errorOverlay.ts +278 -0
- package/packages/core/src/events.ts +112 -0
- package/packages/core/src/fakeData.ts +309 -0
- package/packages/core/src/graphql.ts +812 -0
- package/packages/core/src/health.ts +31 -0
- package/packages/core/src/htmlElement.ts +172 -0
- package/packages/core/src/i18n.ts +136 -0
- package/packages/core/src/index.ts +88 -0
- package/packages/core/src/logger.ts +226 -0
- package/packages/core/src/messenger.ts +822 -0
- package/packages/core/src/middleware.ts +138 -0
- package/packages/core/src/queue.ts +481 -0
- package/packages/core/src/queueBackends/kafkaBackend.ts +348 -0
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +479 -0
- package/packages/core/src/rateLimiter.ts +107 -0
- package/packages/core/src/request.ts +189 -0
- package/packages/core/src/response.ts +146 -0
- package/packages/core/src/routeDiscovery.ts +87 -0
- package/packages/core/src/router.ts +398 -0
- package/packages/core/src/scss.ts +366 -0
- package/packages/core/src/server.ts +610 -0
- package/packages/core/src/service.ts +380 -0
- package/packages/core/src/session.ts +480 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +286 -0
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +184 -0
- package/packages/core/src/static.ts +58 -0
- package/packages/core/src/testing.ts +233 -0
- package/packages/core/src/types.ts +98 -0
- package/packages/core/src/watcher.ts +37 -0
- package/packages/core/src/websocket.ts +408 -0
- package/packages/core/src/wsdl.ts +546 -0
- package/packages/core/templates/errors/302.twig +14 -0
- package/packages/core/templates/errors/401.twig +9 -0
- package/packages/core/templates/errors/403.twig +29 -0
- package/packages/core/templates/errors/404.twig +29 -0
- package/packages/core/templates/errors/500.twig +38 -0
- package/packages/core/templates/errors/502.twig +9 -0
- package/packages/core/templates/errors/503.twig +12 -0
- package/packages/core/templates/errors/base.twig +37 -0
- package/packages/frond/src/engine.ts +1475 -0
- package/packages/frond/src/index.ts +2 -0
- package/packages/orm/src/adapters/firebird.ts +455 -0
- package/packages/orm/src/adapters/mssql.ts +440 -0
- package/packages/orm/src/adapters/mysql.ts +355 -0
- package/packages/orm/src/adapters/postgres.ts +362 -0
- package/packages/orm/src/adapters/sqlite.ts +270 -0
- package/packages/orm/src/autoCrud.ts +231 -0
- package/packages/orm/src/baseModel.ts +536 -0
- package/packages/orm/src/database.ts +321 -0
- package/packages/orm/src/fakeData.ts +118 -0
- package/packages/orm/src/index.ts +49 -0
- package/packages/orm/src/migration.ts +392 -0
- package/packages/orm/src/model.ts +56 -0
- package/packages/orm/src/query.ts +113 -0
- package/packages/orm/src/seeder.ts +120 -0
- package/packages/orm/src/sqlTranslation.ts +272 -0
- package/packages/orm/src/types.ts +110 -0
- package/packages/orm/src/validation.ts +93 -0
- package/packages/swagger/src/generator.ts +189 -0
- package/packages/swagger/src/index.ts +2 -0
- package/packages/swagger/src/ui.ts +48 -0
- package/skills/tina4-developer.skill +0 -0
- package/skills/tina4-js.skill +0 -0
- package/skills/tina4-maintainer.skill +0 -0
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 GraphQL — Zero-dependency GraphQL engine.
|
|
3
|
+
*
|
|
4
|
+
* Recursive-descent parser, schema builder, and query executor.
|
|
5
|
+
*
|
|
6
|
+
* import { GraphQL } from "@tina4/core";
|
|
7
|
+
*
|
|
8
|
+
* const gql = new GraphQL();
|
|
9
|
+
* gql.addType("User", { id: { type: "ID" }, name: { type: "String" } });
|
|
10
|
+
* gql.addQuery("user", { id: "ID!" }, "User", (root, args) => getUser(args.id));
|
|
11
|
+
* const result = gql.execute('{ user(id: "1") { name } }');
|
|
12
|
+
*
|
|
13
|
+
* Supported:
|
|
14
|
+
* - Queries, mutations
|
|
15
|
+
* - Variables, default values
|
|
16
|
+
* - Aliases
|
|
17
|
+
* - Nested selections
|
|
18
|
+
* - List types ([Type])
|
|
19
|
+
* - Non-null types (Type!)
|
|
20
|
+
* - Error capture (resolver exceptions become GraphQL errors)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// ── Types ────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export interface GraphQLField {
|
|
26
|
+
type: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ResolverFn = (
|
|
31
|
+
root: unknown,
|
|
32
|
+
args: Record<string, unknown>,
|
|
33
|
+
context?: Record<string, unknown>,
|
|
34
|
+
) => unknown;
|
|
35
|
+
|
|
36
|
+
export interface GraphQLResult {
|
|
37
|
+
data: Record<string, unknown> | null;
|
|
38
|
+
errors?: Array<{ message: string; path?: string[] }>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Token ────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
interface Token {
|
|
44
|
+
type: string;
|
|
45
|
+
value: string;
|
|
46
|
+
pos: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const TOKEN_PATTERNS: Array<[string, RegExp]> = [
|
|
50
|
+
["SPREAD", /\.\.\./y],
|
|
51
|
+
["LBRACE", /\{/y],
|
|
52
|
+
["RBRACE", /\}/y],
|
|
53
|
+
["LPAREN", /\(/y],
|
|
54
|
+
["RPAREN", /\)/y],
|
|
55
|
+
["LBRACKET", /\[/y],
|
|
56
|
+
["RBRACKET", /\]/y],
|
|
57
|
+
["COLON", /:/y],
|
|
58
|
+
["BANG", /!/y],
|
|
59
|
+
["EQUALS", /=/y],
|
|
60
|
+
["AT", /@/y],
|
|
61
|
+
["DOLLAR", /\$/y],
|
|
62
|
+
["COMMA", /,/y],
|
|
63
|
+
["STRING", /"(?:[^"\\]|\\.)*"/y],
|
|
64
|
+
["NUMBER", /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y],
|
|
65
|
+
["BOOL", /\b(?:true|false)\b/y],
|
|
66
|
+
["NULL", /\bnull\b/y],
|
|
67
|
+
["NAME", /[_a-zA-Z]\w*/y],
|
|
68
|
+
["SKIP", /[\s,]+/y],
|
|
69
|
+
["COMMENT", /#[^\n]*/y],
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
function tokenize(source: string): Token[] {
|
|
73
|
+
const tokens: Token[] = [];
|
|
74
|
+
let pos = 0;
|
|
75
|
+
|
|
76
|
+
while (pos < source.length) {
|
|
77
|
+
let matched = false;
|
|
78
|
+
|
|
79
|
+
for (const [type, regex] of TOKEN_PATTERNS) {
|
|
80
|
+
regex.lastIndex = pos;
|
|
81
|
+
const m = regex.exec(source);
|
|
82
|
+
if (m) {
|
|
83
|
+
if (type !== "SKIP" && type !== "COMMENT") {
|
|
84
|
+
tokens.push({ type, value: m[0], pos });
|
|
85
|
+
}
|
|
86
|
+
pos = regex.lastIndex;
|
|
87
|
+
matched = true;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!matched) {
|
|
93
|
+
throw new ParseError(`Unexpected character: ${source[pos]} at position ${pos}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return tokens;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Parser ───────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
export class ParseError extends Error {
|
|
103
|
+
constructor(message: string) {
|
|
104
|
+
super(message);
|
|
105
|
+
this.name = "ParseError";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface ParsedField {
|
|
110
|
+
kind: "field";
|
|
111
|
+
name: string;
|
|
112
|
+
alias: string | null;
|
|
113
|
+
args: Record<string, unknown>;
|
|
114
|
+
directives: ParsedDirective[];
|
|
115
|
+
selections: ParsedSelection[] | null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface ParsedDirective {
|
|
119
|
+
name: string;
|
|
120
|
+
args: Record<string, unknown>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface ParsedOperation {
|
|
124
|
+
kind: "operation";
|
|
125
|
+
operation: string;
|
|
126
|
+
name: string | null;
|
|
127
|
+
variables: ParsedVariableDef[];
|
|
128
|
+
directives: ParsedDirective[];
|
|
129
|
+
selections: ParsedSelection[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface ParsedVariableDef {
|
|
133
|
+
name: string;
|
|
134
|
+
type: string;
|
|
135
|
+
default: unknown;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type ParsedSelection = ParsedField;
|
|
139
|
+
|
|
140
|
+
class Parser {
|
|
141
|
+
private tokens: Token[];
|
|
142
|
+
private pos: number;
|
|
143
|
+
|
|
144
|
+
constructor(tokens: Token[]) {
|
|
145
|
+
this.tokens = tokens;
|
|
146
|
+
this.pos = 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
peek(): Token | null {
|
|
150
|
+
return this.pos < this.tokens.length ? this.tokens[this.pos] : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
advance(): Token {
|
|
154
|
+
const t = this.tokens[this.pos];
|
|
155
|
+
this.pos++;
|
|
156
|
+
return t;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
expect(type: string, value?: string): Token {
|
|
160
|
+
const t = this.peek();
|
|
161
|
+
if (!t || t.type !== type || (value !== undefined && t.value !== value)) {
|
|
162
|
+
const expected = value ? `${type}(${value})` : type;
|
|
163
|
+
const got = t ? `${t.type}(${t.value})` : "EOF";
|
|
164
|
+
throw new ParseError(`Expected ${expected}, got ${got}`);
|
|
165
|
+
}
|
|
166
|
+
return this.advance();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
match(type: string, value?: string): Token | null {
|
|
170
|
+
const t = this.peek();
|
|
171
|
+
if (t && t.type === type && (value === undefined || t.value === value)) {
|
|
172
|
+
return this.advance();
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
parse(): { definitions: ParsedOperation[] } {
|
|
178
|
+
const doc: { definitions: ParsedOperation[] } = { definitions: [] };
|
|
179
|
+
while (this.pos < this.tokens.length) {
|
|
180
|
+
doc.definitions.push(this.parseOperation());
|
|
181
|
+
}
|
|
182
|
+
return doc;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private parseOperation(): ParsedOperation {
|
|
186
|
+
const t = this.peek();
|
|
187
|
+
let opType = "query";
|
|
188
|
+
let name: string | null = null;
|
|
189
|
+
let variables: ParsedVariableDef[] = [];
|
|
190
|
+
|
|
191
|
+
if (t && t.type === "NAME" && (t.value === "query" || t.value === "mutation")) {
|
|
192
|
+
opType = this.advance().value;
|
|
193
|
+
if (this.peek() && this.peek()!.type === "NAME") {
|
|
194
|
+
name = this.advance().value;
|
|
195
|
+
}
|
|
196
|
+
if (this.match("LPAREN")) {
|
|
197
|
+
variables = this.parseVariableDefs();
|
|
198
|
+
this.expect("RPAREN");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const directives = this.parseDirectives();
|
|
203
|
+
const selections = this.parseSelectionSet();
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
kind: "operation",
|
|
207
|
+
operation: opType,
|
|
208
|
+
name,
|
|
209
|
+
variables,
|
|
210
|
+
directives,
|
|
211
|
+
selections,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private parseSelectionSet(): ParsedSelection[] {
|
|
216
|
+
this.expect("LBRACE");
|
|
217
|
+
const selections: ParsedSelection[] = [];
|
|
218
|
+
while (!this.match("RBRACE")) {
|
|
219
|
+
selections.push(this.parseField());
|
|
220
|
+
}
|
|
221
|
+
return selections;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private parseField(): ParsedField {
|
|
225
|
+
const nameToken = this.expect("NAME");
|
|
226
|
+
let name = nameToken.value;
|
|
227
|
+
let alias: string | null = null;
|
|
228
|
+
|
|
229
|
+
if (this.match("COLON")) {
|
|
230
|
+
alias = name;
|
|
231
|
+
name = this.expect("NAME").value;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let args: Record<string, unknown> = {};
|
|
235
|
+
if (this.match("LPAREN")) {
|
|
236
|
+
args = this.parseArguments();
|
|
237
|
+
this.expect("RPAREN");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const directives = this.parseDirectives();
|
|
241
|
+
|
|
242
|
+
let selections: ParsedSelection[] | null = null;
|
|
243
|
+
if (this.peek() && this.peek()!.type === "LBRACE") {
|
|
244
|
+
selections = this.parseSelectionSet();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
kind: "field",
|
|
249
|
+
name,
|
|
250
|
+
alias,
|
|
251
|
+
args,
|
|
252
|
+
directives,
|
|
253
|
+
selections,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private parseArguments(): Record<string, unknown> {
|
|
258
|
+
const args: Record<string, unknown> = {};
|
|
259
|
+
while (this.peek() && this.peek()!.type !== "RPAREN") {
|
|
260
|
+
const name = this.expect("NAME").value;
|
|
261
|
+
this.expect("COLON");
|
|
262
|
+
args[name] = this.parseValue();
|
|
263
|
+
this.match("COMMA");
|
|
264
|
+
}
|
|
265
|
+
return args;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private parseValue(): unknown {
|
|
269
|
+
const t = this.peek();
|
|
270
|
+
if (!t) throw new ParseError("Unexpected EOF in value");
|
|
271
|
+
|
|
272
|
+
if (t.type === "STRING") {
|
|
273
|
+
this.advance();
|
|
274
|
+
return t.value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
275
|
+
}
|
|
276
|
+
if (t.type === "NUMBER") {
|
|
277
|
+
this.advance();
|
|
278
|
+
return t.value.includes(".") || t.value.toLowerCase().includes("e")
|
|
279
|
+
? parseFloat(t.value)
|
|
280
|
+
: parseInt(t.value, 10);
|
|
281
|
+
}
|
|
282
|
+
if (t.type === "BOOL") {
|
|
283
|
+
this.advance();
|
|
284
|
+
return t.value === "true";
|
|
285
|
+
}
|
|
286
|
+
if (t.type === "NULL") {
|
|
287
|
+
this.advance();
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
if (t.type === "NAME") {
|
|
291
|
+
this.advance();
|
|
292
|
+
return t.value;
|
|
293
|
+
}
|
|
294
|
+
if (t.type === "DOLLAR") {
|
|
295
|
+
this.advance();
|
|
296
|
+
const name = this.expect("NAME").value;
|
|
297
|
+
return { $var: name };
|
|
298
|
+
}
|
|
299
|
+
if (t.type === "LBRACKET") {
|
|
300
|
+
this.advance();
|
|
301
|
+
const items: unknown[] = [];
|
|
302
|
+
while (!this.match("RBRACKET")) {
|
|
303
|
+
items.push(this.parseValue());
|
|
304
|
+
}
|
|
305
|
+
return items;
|
|
306
|
+
}
|
|
307
|
+
if (t.type === "LBRACE") {
|
|
308
|
+
this.advance();
|
|
309
|
+
const obj: Record<string, unknown> = {};
|
|
310
|
+
while (!this.match("RBRACE")) {
|
|
311
|
+
const key = this.expect("NAME").value;
|
|
312
|
+
this.expect("COLON");
|
|
313
|
+
obj[key] = this.parseValue();
|
|
314
|
+
}
|
|
315
|
+
return obj;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
throw new ParseError(`Unexpected token: ${t.type}(${t.value})`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private parseDirectives(): ParsedDirective[] {
|
|
322
|
+
const directives: ParsedDirective[] = [];
|
|
323
|
+
while (this.peek() && this.peek()!.type === "AT") {
|
|
324
|
+
this.advance();
|
|
325
|
+
const name = this.expect("NAME").value;
|
|
326
|
+
let args: Record<string, unknown> = {};
|
|
327
|
+
if (this.match("LPAREN")) {
|
|
328
|
+
args = this.parseArguments();
|
|
329
|
+
this.expect("RPAREN");
|
|
330
|
+
}
|
|
331
|
+
directives.push({ name, args });
|
|
332
|
+
}
|
|
333
|
+
return directives;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private parseVariableDefs(): ParsedVariableDef[] {
|
|
337
|
+
const defs: ParsedVariableDef[] = [];
|
|
338
|
+
while (this.peek() && this.peek()!.type === "DOLLAR") {
|
|
339
|
+
this.advance();
|
|
340
|
+
const name = this.expect("NAME").value;
|
|
341
|
+
this.expect("COLON");
|
|
342
|
+
const typeName = this.parseTypeRef();
|
|
343
|
+
let defaultVal: unknown = undefined;
|
|
344
|
+
if (this.match("EQUALS")) {
|
|
345
|
+
defaultVal = this.parseValue();
|
|
346
|
+
}
|
|
347
|
+
defs.push({ name, type: typeName, default: defaultVal });
|
|
348
|
+
this.match("COMMA");
|
|
349
|
+
}
|
|
350
|
+
return defs;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private parseTypeRef(): string {
|
|
354
|
+
let t: string;
|
|
355
|
+
if (this.match("LBRACKET")) {
|
|
356
|
+
const inner = this.parseTypeRef();
|
|
357
|
+
this.expect("RBRACKET");
|
|
358
|
+
t = `[${inner}]`;
|
|
359
|
+
} else {
|
|
360
|
+
t = this.expect("NAME").value;
|
|
361
|
+
}
|
|
362
|
+
if (this.match("BANG")) {
|
|
363
|
+
t += "!";
|
|
364
|
+
}
|
|
365
|
+
return t;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Schema ───────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
interface QueryConfig {
|
|
372
|
+
args: Record<string, string>;
|
|
373
|
+
returnType: string;
|
|
374
|
+
resolver: ResolverFn;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ── GraphQL Engine ───────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
export class GraphQL {
|
|
380
|
+
private types: Map<string, Record<string, GraphQLField>> = new Map();
|
|
381
|
+
private queries: Map<string, QueryConfig> = new Map();
|
|
382
|
+
private mutations: Map<string, QueryConfig> = new Map();
|
|
383
|
+
|
|
384
|
+
constructor() {}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Register a named type with its fields.
|
|
388
|
+
*/
|
|
389
|
+
addType(name: string, fields: Record<string, GraphQLField>): GraphQL {
|
|
390
|
+
this.types.set(name, fields);
|
|
391
|
+
return this;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Register a query resolver.
|
|
396
|
+
*/
|
|
397
|
+
addQuery(
|
|
398
|
+
name: string,
|
|
399
|
+
args: Record<string, string>,
|
|
400
|
+
returnType: string,
|
|
401
|
+
resolver: ResolverFn,
|
|
402
|
+
): GraphQL {
|
|
403
|
+
this.queries.set(name, { args, returnType, resolver });
|
|
404
|
+
return this;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Register a mutation resolver.
|
|
409
|
+
*/
|
|
410
|
+
addMutation(
|
|
411
|
+
name: string,
|
|
412
|
+
args: Record<string, string>,
|
|
413
|
+
returnType: string,
|
|
414
|
+
resolver: ResolverFn,
|
|
415
|
+
): GraphQL {
|
|
416
|
+
this.mutations.set(name, { args, returnType, resolver });
|
|
417
|
+
return this;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Execute a GraphQL query string.
|
|
422
|
+
*/
|
|
423
|
+
execute(query: string, variables?: Record<string, unknown>): GraphQLResult {
|
|
424
|
+
const vars = variables ?? {};
|
|
425
|
+
const errors: Array<{ message: string; path?: string[] }> = [];
|
|
426
|
+
|
|
427
|
+
let doc: { definitions: ParsedOperation[] };
|
|
428
|
+
try {
|
|
429
|
+
const tokens = tokenize(query);
|
|
430
|
+
const parser = new Parser(tokens);
|
|
431
|
+
doc = parser.parse();
|
|
432
|
+
} catch (e: unknown) {
|
|
433
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
434
|
+
return { data: null, errors: [{ message }] };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (doc.definitions.length === 0) {
|
|
438
|
+
return { data: null, errors: [{ message: "No operation found" }] };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const op = doc.definitions[0];
|
|
442
|
+
const resolvers = op.operation === "query" ? this.queries : this.mutations;
|
|
443
|
+
|
|
444
|
+
// Apply variable defaults
|
|
445
|
+
for (const vdef of op.variables) {
|
|
446
|
+
if (!(vdef.name in vars) && vdef.default !== undefined) {
|
|
447
|
+
vars[vdef.name] = vdef.default;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const data: Record<string, unknown> = {};
|
|
452
|
+
|
|
453
|
+
for (const sel of op.selections) {
|
|
454
|
+
const [value, errs] = this.resolveField(sel, resolvers, null, vars);
|
|
455
|
+
errors.push(...errs);
|
|
456
|
+
const key = sel.alias ?? sel.name;
|
|
457
|
+
data[key] = value;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const result: GraphQLResult = { data };
|
|
461
|
+
if (errors.length > 0) {
|
|
462
|
+
result.errors = errors;
|
|
463
|
+
}
|
|
464
|
+
return result;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Generate SDL schema string.
|
|
469
|
+
*/
|
|
470
|
+
schema(): string {
|
|
471
|
+
const lines: string[] = [];
|
|
472
|
+
|
|
473
|
+
// Types
|
|
474
|
+
for (const [name, fields] of this.types) {
|
|
475
|
+
lines.push(`type ${name} {`);
|
|
476
|
+
for (const [fieldName, field] of Object.entries(fields)) {
|
|
477
|
+
lines.push(` ${fieldName}: ${field.type}`);
|
|
478
|
+
}
|
|
479
|
+
lines.push("}");
|
|
480
|
+
lines.push("");
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Query type
|
|
484
|
+
if (this.queries.size > 0) {
|
|
485
|
+
lines.push("type Query {");
|
|
486
|
+
for (const [name, config] of this.queries) {
|
|
487
|
+
const argsStr = this.formatArgs(config.args);
|
|
488
|
+
lines.push(` ${name}${argsStr}: ${config.returnType}`);
|
|
489
|
+
}
|
|
490
|
+
lines.push("}");
|
|
491
|
+
lines.push("");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Mutation type
|
|
495
|
+
if (this.mutations.size > 0) {
|
|
496
|
+
lines.push("type Mutation {");
|
|
497
|
+
for (const [name, config] of this.mutations) {
|
|
498
|
+
const argsStr = this.formatArgs(config.args);
|
|
499
|
+
lines.push(` ${name}${argsStr}: ${config.returnType}`);
|
|
500
|
+
}
|
|
501
|
+
lines.push("}");
|
|
502
|
+
lines.push("");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return lines.join("\n");
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Auto-generate type, queries, and CRUD mutations from an ORM model class.
|
|
510
|
+
*
|
|
511
|
+
* The model class must have static `tableName` (string) and `fields`
|
|
512
|
+
* (Record<string, { type: string; primaryKey?: boolean }>).
|
|
513
|
+
*
|
|
514
|
+
* Creates:
|
|
515
|
+
* - A GraphQL type from the model's fields
|
|
516
|
+
* - Queries: {modelName}(id: ID!): Type, {modelNames}(limit: Int, offset: Int): [Type]
|
|
517
|
+
* - Mutations: create{ModelName}, update{ModelName}, delete{ModelName}
|
|
518
|
+
*
|
|
519
|
+
* @param modelClass - The model class with static tableName and fields
|
|
520
|
+
* @param adapter - Optional database adapter; if omitted, resolvers will
|
|
521
|
+
* import getAdapter from @tina4/orm at call time
|
|
522
|
+
*/
|
|
523
|
+
fromOrm(
|
|
524
|
+
modelClass: {
|
|
525
|
+
tableName: string;
|
|
526
|
+
fields: Record<string, { type: string; primaryKey?: boolean }>;
|
|
527
|
+
name?: string;
|
|
528
|
+
},
|
|
529
|
+
adapter?: {
|
|
530
|
+
query: <T = Record<string, unknown>>(sql: string, params?: unknown[]) => T[];
|
|
531
|
+
execute: (sql: string, params?: unknown[]) => unknown;
|
|
532
|
+
},
|
|
533
|
+
): GraphQL {
|
|
534
|
+
const tableName = modelClass.tableName;
|
|
535
|
+
const fields = modelClass.fields;
|
|
536
|
+
const className = modelClass.name ?? tableName.charAt(0).toUpperCase() + tableName.slice(1);
|
|
537
|
+
|
|
538
|
+
// Find primary key
|
|
539
|
+
let pkField = "id";
|
|
540
|
+
for (const [fname, fdef] of Object.entries(fields)) {
|
|
541
|
+
if (fdef.primaryKey) {
|
|
542
|
+
pkField = fname;
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Map model field types to GraphQL types
|
|
548
|
+
const gqlFields: Record<string, GraphQLField> = {};
|
|
549
|
+
for (const [fname, fdef] of Object.entries(fields)) {
|
|
550
|
+
let gqlType: string;
|
|
551
|
+
if (fdef.primaryKey) {
|
|
552
|
+
gqlType = "ID";
|
|
553
|
+
} else {
|
|
554
|
+
switch (fdef.type) {
|
|
555
|
+
case "integer":
|
|
556
|
+
gqlType = "Int";
|
|
557
|
+
break;
|
|
558
|
+
case "number":
|
|
559
|
+
case "numeric":
|
|
560
|
+
gqlType = "Float";
|
|
561
|
+
break;
|
|
562
|
+
case "boolean":
|
|
563
|
+
gqlType = "Boolean";
|
|
564
|
+
break;
|
|
565
|
+
case "string":
|
|
566
|
+
case "text":
|
|
567
|
+
case "datetime":
|
|
568
|
+
default:
|
|
569
|
+
gqlType = "String";
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
gqlFields[fname] = { type: gqlType };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
this.addType(className, gqlFields);
|
|
577
|
+
|
|
578
|
+
// Build singular/plural names
|
|
579
|
+
const singular = className.charAt(0).toLowerCase() + className.slice(1);
|
|
580
|
+
const plural = singular + "s";
|
|
581
|
+
|
|
582
|
+
// Helper to get the adapter (lazy so it works even if adapter is set later)
|
|
583
|
+
const getDb = () => {
|
|
584
|
+
if (adapter) return adapter;
|
|
585
|
+
// Try dynamic import fallback — caller must provide adapter
|
|
586
|
+
throw new Error(
|
|
587
|
+
`GraphQL fromOrm: no database adapter provided for ${className}. Pass an adapter to fromOrm().`,
|
|
588
|
+
);
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
// Query: single record by ID
|
|
592
|
+
this.addQuery(singular, { id: "ID!" }, className, (root, args) => {
|
|
593
|
+
const db = getDb();
|
|
594
|
+
const rows = db.query(
|
|
595
|
+
`SELECT * FROM "${tableName}" WHERE "${pkField}" = ?`,
|
|
596
|
+
[args.id],
|
|
597
|
+
);
|
|
598
|
+
return rows.length > 0 ? rows[0] : null;
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// Query: list with pagination
|
|
602
|
+
this.addQuery(
|
|
603
|
+
plural,
|
|
604
|
+
{ limit: "Int", offset: "Int" },
|
|
605
|
+
`[${className}]`,
|
|
606
|
+
(root, args) => {
|
|
607
|
+
const db = getDb();
|
|
608
|
+
const limit = (args.limit as number) ?? 10;
|
|
609
|
+
const offset = (args.offset as number) ?? 0;
|
|
610
|
+
return db.query(
|
|
611
|
+
`SELECT * FROM "${tableName}" LIMIT ? OFFSET ?`,
|
|
612
|
+
[limit, offset],
|
|
613
|
+
);
|
|
614
|
+
},
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
// Build mutation args (all fields except PK)
|
|
618
|
+
const mutationArgs: Record<string, string> = {};
|
|
619
|
+
for (const [fname, fdef] of Object.entries(fields)) {
|
|
620
|
+
if (fname !== pkField) {
|
|
621
|
+
mutationArgs[fname] = "String";
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Mutation: create
|
|
626
|
+
this.addMutation(
|
|
627
|
+
`create${className}`,
|
|
628
|
+
mutationArgs,
|
|
629
|
+
className,
|
|
630
|
+
(root, args) => {
|
|
631
|
+
const db = getDb();
|
|
632
|
+
const fieldNames = Object.keys(args);
|
|
633
|
+
const placeholders = fieldNames.map(() => "?");
|
|
634
|
+
const values = fieldNames.map((f) => args[f]);
|
|
635
|
+
|
|
636
|
+
db.execute(
|
|
637
|
+
`INSERT INTO "${tableName}" (${fieldNames.map((f) => `"${f}"`).join(", ")}) VALUES (${placeholders.join(", ")})`,
|
|
638
|
+
values,
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
// Fetch the newly created record
|
|
642
|
+
const rows = db.query(
|
|
643
|
+
`SELECT * FROM "${tableName}" ORDER BY "${pkField}" DESC LIMIT 1`,
|
|
644
|
+
);
|
|
645
|
+
return rows.length > 0 ? rows[0] : null;
|
|
646
|
+
},
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
// Mutation: update
|
|
650
|
+
const updateArgs: Record<string, string> = { id: "ID!", ...mutationArgs };
|
|
651
|
+
this.addMutation(
|
|
652
|
+
`update${className}`,
|
|
653
|
+
updateArgs,
|
|
654
|
+
className,
|
|
655
|
+
(root, args) => {
|
|
656
|
+
const db = getDb();
|
|
657
|
+
const id = args.id;
|
|
658
|
+
const setClauses: string[] = [];
|
|
659
|
+
const values: unknown[] = [];
|
|
660
|
+
|
|
661
|
+
for (const [k, v] of Object.entries(args)) {
|
|
662
|
+
if (k !== "id") {
|
|
663
|
+
setClauses.push(`"${k}" = ?`);
|
|
664
|
+
values.push(v);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (setClauses.length === 0) return null;
|
|
669
|
+
values.push(id);
|
|
670
|
+
|
|
671
|
+
db.execute(
|
|
672
|
+
`UPDATE "${tableName}" SET ${setClauses.join(", ")} WHERE "${pkField}" = ?`,
|
|
673
|
+
values,
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
const rows = db.query(
|
|
677
|
+
`SELECT * FROM "${tableName}" WHERE "${pkField}" = ?`,
|
|
678
|
+
[id],
|
|
679
|
+
);
|
|
680
|
+
return rows.length > 0 ? rows[0] : null;
|
|
681
|
+
},
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
// Mutation: delete
|
|
685
|
+
this.addMutation(
|
|
686
|
+
`delete${className}`,
|
|
687
|
+
{ id: "ID!" },
|
|
688
|
+
"Boolean",
|
|
689
|
+
(root, args) => {
|
|
690
|
+
const db = getDb();
|
|
691
|
+
const rows = db.query(
|
|
692
|
+
`SELECT * FROM "${tableName}" WHERE "${pkField}" = ?`,
|
|
693
|
+
[args.id],
|
|
694
|
+
);
|
|
695
|
+
if (rows.length === 0) return false;
|
|
696
|
+
|
|
697
|
+
db.execute(
|
|
698
|
+
`DELETE FROM "${tableName}" WHERE "${pkField}" = ?`,
|
|
699
|
+
[args.id],
|
|
700
|
+
);
|
|
701
|
+
return true;
|
|
702
|
+
},
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
return this;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// ── Private helpers ──────────────────────────────────────
|
|
709
|
+
|
|
710
|
+
private formatArgs(args: Record<string, string>): string {
|
|
711
|
+
const entries = Object.entries(args);
|
|
712
|
+
if (entries.length === 0) return "";
|
|
713
|
+
const parts = entries.map(([k, v]) => `${k}: ${v}`);
|
|
714
|
+
return `(${parts.join(", ")})`;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
private resolveField(
|
|
718
|
+
sel: ParsedField,
|
|
719
|
+
resolvers: Map<string, QueryConfig>,
|
|
720
|
+
parent: unknown,
|
|
721
|
+
variables: Record<string, unknown>,
|
|
722
|
+
): [unknown, Array<{ message: string; path?: string[] }>] {
|
|
723
|
+
const errors: Array<{ message: string; path?: string[] }> = [];
|
|
724
|
+
const name = sel.name;
|
|
725
|
+
const args = this.resolveArgs(sel.args, variables);
|
|
726
|
+
|
|
727
|
+
let value: unknown = undefined;
|
|
728
|
+
|
|
729
|
+
if (parent !== null && parent !== undefined) {
|
|
730
|
+
// Resolve from parent object
|
|
731
|
+
if (typeof parent === "object" && parent !== null) {
|
|
732
|
+
value = (parent as Record<string, unknown>)[name];
|
|
733
|
+
}
|
|
734
|
+
} else if (resolvers.has(name)) {
|
|
735
|
+
const config = resolvers.get(name)!;
|
|
736
|
+
try {
|
|
737
|
+
value = config.resolver(null, args, {});
|
|
738
|
+
} catch (e: unknown) {
|
|
739
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
740
|
+
errors.push({ message, path: [name] });
|
|
741
|
+
return [null, errors];
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// If no sub-selections, return the scalar value
|
|
746
|
+
if (!sel.selections || sel.selections.length === 0) {
|
|
747
|
+
return [value, errors];
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Handle list types
|
|
751
|
+
if (Array.isArray(value)) {
|
|
752
|
+
const result: Record<string, unknown>[] = [];
|
|
753
|
+
for (const item of value) {
|
|
754
|
+
const obj: Record<string, unknown> = {};
|
|
755
|
+
for (const subSel of sel.selections) {
|
|
756
|
+
const [subVal, subErrs] = this.resolveField(
|
|
757
|
+
subSel,
|
|
758
|
+
new Map(),
|
|
759
|
+
item,
|
|
760
|
+
variables,
|
|
761
|
+
);
|
|
762
|
+
errors.push(...subErrs);
|
|
763
|
+
const key = subSel.alias ?? subSel.name;
|
|
764
|
+
obj[key] = subVal;
|
|
765
|
+
}
|
|
766
|
+
result.push(obj);
|
|
767
|
+
}
|
|
768
|
+
return [result, errors];
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Handle object types
|
|
772
|
+
if (value !== null && value !== undefined) {
|
|
773
|
+
const obj: Record<string, unknown> = {};
|
|
774
|
+
for (const subSel of sel.selections) {
|
|
775
|
+
const [subVal, subErrs] = this.resolveField(
|
|
776
|
+
subSel,
|
|
777
|
+
new Map(),
|
|
778
|
+
value,
|
|
779
|
+
variables,
|
|
780
|
+
);
|
|
781
|
+
errors.push(...subErrs);
|
|
782
|
+
const key = subSel.alias ?? subSel.name;
|
|
783
|
+
obj[key] = subVal;
|
|
784
|
+
}
|
|
785
|
+
return [obj, errors];
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
return [null, errors];
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
private resolveArgs(
|
|
792
|
+
args: Record<string, unknown>,
|
|
793
|
+
variables: Record<string, unknown>,
|
|
794
|
+
): Record<string, unknown> {
|
|
795
|
+
const resolved: Record<string, unknown> = {};
|
|
796
|
+
for (const [k, v] of Object.entries(args)) {
|
|
797
|
+
if (typeof v === "object" && v !== null && "$var" in v) {
|
|
798
|
+
resolved[k] = variables[(v as { $var: string }).$var];
|
|
799
|
+
} else if (Array.isArray(v)) {
|
|
800
|
+
resolved[k] = v.map((i) => {
|
|
801
|
+
if (typeof i === "object" && i !== null && "$var" in i) {
|
|
802
|
+
return variables[(i as { $var: string }).$var];
|
|
803
|
+
}
|
|
804
|
+
return i;
|
|
805
|
+
});
|
|
806
|
+
} else {
|
|
807
|
+
resolved[k] = v;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return resolved;
|
|
811
|
+
}
|
|
812
|
+
}
|