@syncular/typegen 0.2.0 → 0.3.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/README.md +25 -10
- package/dist/cli.js +155 -4
- package/dist/emit-dart.js +3 -2
- package/dist/emit-kotlin.js +3 -2
- package/dist/emit-queries-dart.js +87 -15
- package/dist/emit-queries-kotlin.js +89 -15
- package/dist/emit-queries-swift.js +92 -14
- package/dist/emit-queries.js +131 -22
- package/dist/emit-swift.js +3 -2
- package/dist/emit.d.ts +2 -1
- package/dist/emit.js +13 -27
- package/dist/fmt.d.ts +3 -0
- package/dist/fmt.js +356 -0
- package/dist/generate.d.ts +16 -6
- package/dist/generate.js +34 -9
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/lower.d.ts +54 -0
- package/dist/lower.js +283 -0
- package/dist/lsp.d.ts +20 -0
- package/dist/lsp.js +376 -0
- package/dist/manifest.d.ts +11 -0
- package/dist/manifest.js +20 -0
- package/dist/naming.d.ts +22 -0
- package/dist/naming.js +240 -0
- package/dist/query-ir.d.ts +14 -0
- package/dist/query-ir.js +69 -0
- package/dist/query.d.ts +102 -6
- package/dist/query.js +126 -34
- package/dist/syql.d.ts +83 -0
- package/dist/syql.js +945 -0
- package/package.json +4 -4
- package/src/cli.ts +155 -4
- package/src/emit-dart.ts +3 -2
- package/src/emit-kotlin.ts +3 -2
- package/src/emit-queries-dart.ts +109 -16
- package/src/emit-queries-kotlin.ts +111 -18
- package/src/emit-queries-swift.ts +106 -17
- package/src/emit-queries.ts +179 -28
- package/src/emit-swift.ts +3 -2
- package/src/emit.ts +18 -28
- package/src/fmt.ts +351 -0
- package/src/generate.ts +54 -11
- package/src/index.ts +6 -0
- package/src/lower.ts +331 -0
- package/src/lsp.ts +445 -0
- package/src/manifest.ts +38 -0
- package/src/naming.ts +271 -0
- package/src/query-ir.ts +82 -0
- package/src/query.ts +241 -34
- package/src/syql.ts +1171 -0
package/src/lsp.ts
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `syncular lsp` — a minimal `.syql` language server (DESIGN-queries.md
|
|
3
|
+
* §10, staged Q5). Zero dependencies: hand-rolled JSON-RPC over stdio with
|
|
4
|
+
* Content-Length framing, and the SAME parser/checker the generator runs —
|
|
5
|
+
* diagnostics are generate-time truth, not a re-implementation.
|
|
6
|
+
*
|
|
7
|
+
* Capabilities:
|
|
8
|
+
* - diagnostics (on open/change): parse errors, lowering errors (B1,
|
|
9
|
+
* knob conflicts), and — when a `syncular.json` is found above the file —
|
|
10
|
+
* the full SQLite-checked analysis (types, unknown columns, naming
|
|
11
|
+
* collisions).
|
|
12
|
+
* - hover: a query name shows its lowered, checked SQL (what actually
|
|
13
|
+
* runs); an `@fragment` ref shows the fragment body.
|
|
14
|
+
* - definition: an `@fragment` ref jumps to its declaration (same file —
|
|
15
|
+
* fragments are file-scoped).
|
|
16
|
+
*
|
|
17
|
+
* The server core is a pure(ish) class (`SyqlLanguageServer.handle`)
|
|
18
|
+
* so tests drive it without stdio; `runLspStdio` is the thin transport.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
21
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
22
|
+
import { TypegenError } from './errors';
|
|
23
|
+
import { buildIr, loadMigrations, makeQueryDb } from './generate';
|
|
24
|
+
import type { IrDocument } from './ir';
|
|
25
|
+
import { MANIFEST_FILENAME, parseManifest } from './manifest';
|
|
26
|
+
import type { QueryDb, QueryNamingOptions } from './query';
|
|
27
|
+
import { analyzeSyqlFile, parseSyqlFile } from './syql';
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// JSON-RPC / LSP shapes (the subset we speak)
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export interface RpcMessage {
|
|
34
|
+
jsonrpc?: '2.0';
|
|
35
|
+
id?: number | string | undefined;
|
|
36
|
+
method?: string;
|
|
37
|
+
params?: unknown;
|
|
38
|
+
result?: unknown;
|
|
39
|
+
error?: { code: number; message: string };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Position {
|
|
43
|
+
line: number;
|
|
44
|
+
character: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface Range {
|
|
48
|
+
start: Position;
|
|
49
|
+
end: Position;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface Diagnostic {
|
|
53
|
+
range: Range;
|
|
54
|
+
severity: number; // 1 = error
|
|
55
|
+
source: string;
|
|
56
|
+
message: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Text/position helpers
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
function offsetToPosition(text: string, offset: number): Position {
|
|
64
|
+
const clamped = Math.max(0, Math.min(offset, text.length));
|
|
65
|
+
let line = 0;
|
|
66
|
+
let lineStart = 0;
|
|
67
|
+
for (let i = 0; i < clamped; i++) {
|
|
68
|
+
if (text[i] === '\n') {
|
|
69
|
+
line += 1;
|
|
70
|
+
lineStart = i + 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { line, character: clamped - lineStart };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function positionToOffset(text: string, position: Position): number {
|
|
77
|
+
let line = 0;
|
|
78
|
+
let i = 0;
|
|
79
|
+
while (i < text.length && line < position.line) {
|
|
80
|
+
if (text[i] === '\n') line += 1;
|
|
81
|
+
i += 1;
|
|
82
|
+
}
|
|
83
|
+
return Math.min(text.length, i + position.character);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The `@word` / `word` under the cursor, with its range. */
|
|
87
|
+
function wordAt(
|
|
88
|
+
text: string,
|
|
89
|
+
offset: number,
|
|
90
|
+
): { word: string; start: number; end: number } | null {
|
|
91
|
+
const isWord = (ch: string | undefined): boolean =>
|
|
92
|
+
ch !== undefined && /[A-Za-z0-9_@]/.test(ch);
|
|
93
|
+
if (!isWord(text[offset]) && !isWord(text[offset - 1])) return null;
|
|
94
|
+
let start = isWord(text[offset]) ? offset : offset - 1;
|
|
95
|
+
while (start > 0 && isWord(text[start - 1])) start -= 1;
|
|
96
|
+
let end = start;
|
|
97
|
+
while (end < text.length && isWord(text[end])) end += 1;
|
|
98
|
+
return { word: text.slice(start, end), start, end };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function uriToPath(uri: string): string {
|
|
102
|
+
return uri.startsWith('file://') ? decodeURIComponent(uri.slice(7)) : uri;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Project context (manifest discovery, cached per directory)
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
interface ProjectContext {
|
|
110
|
+
readonly ir: IrDocument;
|
|
111
|
+
readonly db: QueryDb;
|
|
112
|
+
readonly naming: QueryNamingOptions;
|
|
113
|
+
readonly manifestDir: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function findManifestDir(fromPath: string): string | null {
|
|
117
|
+
let dir = dirname(fromPath);
|
|
118
|
+
for (let i = 0; i < 20; i++) {
|
|
119
|
+
if (existsSync(resolve(dir, MANIFEST_FILENAME))) return dir;
|
|
120
|
+
const parent = dirname(dir);
|
|
121
|
+
if (parent === dir) return null;
|
|
122
|
+
dir = parent;
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// The server
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
export class SyqlLanguageServer {
|
|
132
|
+
readonly #documents = new Map<string, string>();
|
|
133
|
+
#contexts = new Map<string, ProjectContext | null>();
|
|
134
|
+
|
|
135
|
+
/** Handle one incoming message; returns the outgoing messages. */
|
|
136
|
+
handle(message: RpcMessage): RpcMessage[] {
|
|
137
|
+
const { method, id } = message;
|
|
138
|
+
if (method === 'initialize') {
|
|
139
|
+
return [
|
|
140
|
+
{
|
|
141
|
+
jsonrpc: '2.0',
|
|
142
|
+
id,
|
|
143
|
+
result: {
|
|
144
|
+
capabilities: {
|
|
145
|
+
textDocumentSync: 1, // full
|
|
146
|
+
hoverProvider: true,
|
|
147
|
+
definitionProvider: true,
|
|
148
|
+
},
|
|
149
|
+
serverInfo: { name: 'syncular-syql-lsp', version: '0.0.1' },
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
];
|
|
153
|
+
}
|
|
154
|
+
if (method === 'textDocument/didOpen') {
|
|
155
|
+
const params = message.params as {
|
|
156
|
+
textDocument: { uri: string; text: string };
|
|
157
|
+
};
|
|
158
|
+
this.#documents.set(params.textDocument.uri, params.textDocument.text);
|
|
159
|
+
return [this.#publishDiagnostics(params.textDocument.uri)];
|
|
160
|
+
}
|
|
161
|
+
if (method === 'textDocument/didChange') {
|
|
162
|
+
const params = message.params as {
|
|
163
|
+
textDocument: { uri: string };
|
|
164
|
+
contentChanges: { text: string }[];
|
|
165
|
+
};
|
|
166
|
+
const last = params.contentChanges[params.contentChanges.length - 1];
|
|
167
|
+
if (last !== undefined) {
|
|
168
|
+
this.#documents.set(params.textDocument.uri, last.text);
|
|
169
|
+
}
|
|
170
|
+
return [this.#publishDiagnostics(params.textDocument.uri)];
|
|
171
|
+
}
|
|
172
|
+
if (method === 'textDocument/didClose') {
|
|
173
|
+
const params = message.params as { textDocument: { uri: string } };
|
|
174
|
+
this.#documents.delete(params.textDocument.uri);
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
if (method === 'textDocument/hover') {
|
|
178
|
+
return [{ jsonrpc: '2.0', id, result: this.#hover(message.params) }];
|
|
179
|
+
}
|
|
180
|
+
if (method === 'textDocument/definition') {
|
|
181
|
+
return [{ jsonrpc: '2.0', id, result: this.#definition(message.params) }];
|
|
182
|
+
}
|
|
183
|
+
if (method === 'shutdown') {
|
|
184
|
+
return [{ jsonrpc: '2.0', id, result: null }];
|
|
185
|
+
}
|
|
186
|
+
if (id !== undefined && method !== undefined) {
|
|
187
|
+
return [
|
|
188
|
+
{
|
|
189
|
+
jsonrpc: '2.0',
|
|
190
|
+
id,
|
|
191
|
+
error: { code: -32601, message: `unhandled method ${method}` },
|
|
192
|
+
},
|
|
193
|
+
];
|
|
194
|
+
}
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Reset cached manifest contexts (e.g. after schema edits). */
|
|
199
|
+
invalidateProjects(): void {
|
|
200
|
+
this.#contexts = new Map();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
#context(uri: string): ProjectContext | null {
|
|
204
|
+
const path = uriToPath(uri);
|
|
205
|
+
const manifestDir = findManifestDir(path);
|
|
206
|
+
if (manifestDir === null) return null;
|
|
207
|
+
const cached = this.#contexts.get(manifestDir);
|
|
208
|
+
if (cached !== undefined) return cached;
|
|
209
|
+
try {
|
|
210
|
+
const manifest = parseManifest(
|
|
211
|
+
JSON.parse(
|
|
212
|
+
readFileSync(resolve(manifestDir, MANIFEST_FILENAME), 'utf8'),
|
|
213
|
+
),
|
|
214
|
+
);
|
|
215
|
+
const migrations = loadMigrations(
|
|
216
|
+
resolve(manifestDir, manifest.migrations),
|
|
217
|
+
);
|
|
218
|
+
const ir = buildIr(manifest, migrations);
|
|
219
|
+
const { db } = makeQueryDb(ir);
|
|
220
|
+
const targets: QueryNamingOptions['targets'] = ['ts'];
|
|
221
|
+
const context: ProjectContext = {
|
|
222
|
+
ir,
|
|
223
|
+
db,
|
|
224
|
+
manifestDir,
|
|
225
|
+
naming: {
|
|
226
|
+
naming: manifest.naming,
|
|
227
|
+
targets,
|
|
228
|
+
backend: manifest.queryBackend,
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
this.#contexts.set(manifestDir, context);
|
|
232
|
+
return context;
|
|
233
|
+
} catch {
|
|
234
|
+
this.#contexts.set(manifestDir, null);
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#publishDiagnostics(uri: string): RpcMessage {
|
|
240
|
+
const text = this.#documents.get(uri) ?? '';
|
|
241
|
+
const diagnostics = this.#diagnose(uri, text);
|
|
242
|
+
return {
|
|
243
|
+
jsonrpc: '2.0',
|
|
244
|
+
method: 'textDocument/publishDiagnostics',
|
|
245
|
+
params: { uri, diagnostics },
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#diagnose(uri: string, text: string): Diagnostic[] {
|
|
250
|
+
const path = uriToPath(uri);
|
|
251
|
+
const context = this.#context(uri);
|
|
252
|
+
try {
|
|
253
|
+
if (context !== null) {
|
|
254
|
+
// The full generate-time analysis: parse + lower + SQLite check.
|
|
255
|
+
const rel = relative(context.manifestDir, path).split('\\').join('/');
|
|
256
|
+
analyzeSyqlFile(rel, text, context.ir, context.db, context.naming);
|
|
257
|
+
} else {
|
|
258
|
+
// No manifest reachable: parser + lowering only.
|
|
259
|
+
const parsed = parseSyqlFile(path, text);
|
|
260
|
+
void parsed;
|
|
261
|
+
}
|
|
262
|
+
return [];
|
|
263
|
+
} catch (error) {
|
|
264
|
+
return [this.#errorToDiagnostic(error, text)];
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#errorToDiagnostic(error: unknown, text: string): Diagnostic {
|
|
269
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
270
|
+
let range: Range = {
|
|
271
|
+
start: { line: 0, character: 0 },
|
|
272
|
+
end: { line: 0, character: 1 },
|
|
273
|
+
};
|
|
274
|
+
if (error instanceof TypegenError) {
|
|
275
|
+
// Cursor errors carry `file:line`; lowering errors carry
|
|
276
|
+
// `file (query name)` — anchor those at the declaration.
|
|
277
|
+
const lineMatch = /:(\d+): /.exec(message);
|
|
278
|
+
const queryMatch = /\(query ([A-Za-z][A-Za-z0-9]*)\)/.exec(message);
|
|
279
|
+
if (lineMatch !== null) {
|
|
280
|
+
const line = Number.parseInt(lineMatch[1] as string, 10) - 1;
|
|
281
|
+
range = {
|
|
282
|
+
start: { line, character: 0 },
|
|
283
|
+
end: { line, character: 200 },
|
|
284
|
+
};
|
|
285
|
+
} else if (queryMatch !== null) {
|
|
286
|
+
try {
|
|
287
|
+
const parsed = parseSyqlFile('doc', text);
|
|
288
|
+
const decl = parsed.queries.find((q) => q.name === queryMatch[1]);
|
|
289
|
+
if (decl !== undefined) {
|
|
290
|
+
const start = offsetToPosition(text, decl.offset);
|
|
291
|
+
range = {
|
|
292
|
+
start,
|
|
293
|
+
end: { line: start.line, character: start.character + 200 },
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
// fall through to line 0
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return { range, severity: 1, source: 'syncular', message };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
#hover(params: unknown): unknown {
|
|
305
|
+
const p = params as {
|
|
306
|
+
textDocument: { uri: string };
|
|
307
|
+
position: Position;
|
|
308
|
+
};
|
|
309
|
+
const text = this.#documents.get(p.textDocument.uri);
|
|
310
|
+
if (text === undefined) return null;
|
|
311
|
+
const offset = positionToOffset(text, p.position);
|
|
312
|
+
const found = wordAt(text, offset);
|
|
313
|
+
if (found === null) return null;
|
|
314
|
+
|
|
315
|
+
let parsed: ReturnType<typeof parseSyqlFile>;
|
|
316
|
+
try {
|
|
317
|
+
parsed = parseSyqlFile('doc', text);
|
|
318
|
+
} catch {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// `@fragment` ref → the fragment's body.
|
|
323
|
+
if (found.word.startsWith('@')) {
|
|
324
|
+
const fragment = parsed.fragments.find(
|
|
325
|
+
(f) => f.name === found.word.slice(1),
|
|
326
|
+
);
|
|
327
|
+
if (fragment === undefined) return null;
|
|
328
|
+
return {
|
|
329
|
+
contents: {
|
|
330
|
+
kind: 'markdown',
|
|
331
|
+
value: `\`\`\`sql\n${fragment.body}\n\`\`\``,
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// A query name → the lowered, checked SQL (needs the project schema).
|
|
337
|
+
const query = parsed.queries.find((q) => q.name === found.word);
|
|
338
|
+
if (query !== undefined) {
|
|
339
|
+
const context = this.#context(p.textDocument.uri);
|
|
340
|
+
if (context === null) return null;
|
|
341
|
+
try {
|
|
342
|
+
const analyzed = analyzeSyqlFile(
|
|
343
|
+
'doc.syql',
|
|
344
|
+
text,
|
|
345
|
+
context.ir,
|
|
346
|
+
context.db,
|
|
347
|
+
context.naming,
|
|
348
|
+
).find((q) => q.name === found.word);
|
|
349
|
+
if (analyzed === undefined) return null;
|
|
350
|
+
const lines = [
|
|
351
|
+
'```sql',
|
|
352
|
+
analyzed.sql,
|
|
353
|
+
'```',
|
|
354
|
+
'',
|
|
355
|
+
`tables: ${analyzed.tables.join(', ')}`,
|
|
356
|
+
...(analyzed.variants !== undefined
|
|
357
|
+
? [`variants: ${analyzed.variants.length} enumerated statements`]
|
|
358
|
+
: []),
|
|
359
|
+
];
|
|
360
|
+
return { contents: { kind: 'markdown', value: lines.join('\n') } };
|
|
361
|
+
} catch {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
#definition(params: unknown): unknown {
|
|
369
|
+
const p = params as {
|
|
370
|
+
textDocument: { uri: string };
|
|
371
|
+
position: Position;
|
|
372
|
+
};
|
|
373
|
+
const text = this.#documents.get(p.textDocument.uri);
|
|
374
|
+
if (text === undefined) return null;
|
|
375
|
+
const offset = positionToOffset(text, p.position);
|
|
376
|
+
const found = wordAt(text, offset);
|
|
377
|
+
if (found === null || !found.word.startsWith('@')) return null;
|
|
378
|
+
let parsed: ReturnType<typeof parseSyqlFile>;
|
|
379
|
+
try {
|
|
380
|
+
parsed = parseSyqlFile('doc', text);
|
|
381
|
+
} catch {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
const fragment = parsed.fragments.find(
|
|
385
|
+
(f) => f.name === found.word.slice(1),
|
|
386
|
+
);
|
|
387
|
+
if (fragment === undefined) return null;
|
|
388
|
+
const start = offsetToPosition(text, fragment.offset);
|
|
389
|
+
return {
|
|
390
|
+
uri: p.textDocument.uri,
|
|
391
|
+
range: {
|
|
392
|
+
start,
|
|
393
|
+
end: {
|
|
394
|
+
line: start.line,
|
|
395
|
+
character:
|
|
396
|
+
start.character + 'fragment '.length + fragment.name.length,
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
// stdio transport (Content-Length framing)
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
/** Run the language server over stdio until `exit`. */
|
|
408
|
+
export async function runLspStdio(): Promise<void> {
|
|
409
|
+
const server = new SyqlLanguageServer();
|
|
410
|
+
let buffer = Buffer.alloc(0);
|
|
411
|
+
const write = (message: RpcMessage): void => {
|
|
412
|
+
const body = Buffer.from(JSON.stringify(message), 'utf8');
|
|
413
|
+
process.stdout.write(
|
|
414
|
+
Buffer.concat([
|
|
415
|
+
Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'),
|
|
416
|
+
body,
|
|
417
|
+
]),
|
|
418
|
+
);
|
|
419
|
+
};
|
|
420
|
+
for await (const chunk of process.stdin) {
|
|
421
|
+
buffer = Buffer.concat([buffer, chunk as Buffer]);
|
|
422
|
+
for (;;) {
|
|
423
|
+
const headerEnd = buffer.indexOf('\r\n\r\n');
|
|
424
|
+
if (headerEnd === -1) break;
|
|
425
|
+
const header = buffer.subarray(0, headerEnd).toString('ascii');
|
|
426
|
+
const lengthMatch = /Content-Length: (\d+)/i.exec(header);
|
|
427
|
+
if (lengthMatch === null) {
|
|
428
|
+
buffer = buffer.subarray(headerEnd + 4);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
const length = Number.parseInt(lengthMatch[1] as string, 10);
|
|
432
|
+
if (buffer.length < headerEnd + 4 + length) break;
|
|
433
|
+
const body = buffer.subarray(headerEnd + 4, headerEnd + 4 + length);
|
|
434
|
+
buffer = buffer.subarray(headerEnd + 4 + length);
|
|
435
|
+
let message: RpcMessage;
|
|
436
|
+
try {
|
|
437
|
+
message = JSON.parse(body.toString('utf8')) as RpcMessage;
|
|
438
|
+
} catch {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (message.method === 'exit') return;
|
|
442
|
+
for (const outgoing of server.handle(message)) write(outgoing);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
package/src/manifest.ts
CHANGED
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
* WP-49 passthrough slot copied verbatim into the IR.
|
|
41
41
|
*/
|
|
42
42
|
import { TypegenError } from './errors';
|
|
43
|
+
import type { NamingMode } from './naming';
|
|
44
|
+
|
|
45
|
+
/** §7/§8 `.syql` conditional-lowering backend selection. */
|
|
46
|
+
export type ManifestQueryBackend = 'neutralize' | 'variants' | 'auto';
|
|
43
47
|
|
|
44
48
|
export const MANIFEST_FILENAME = 'syncular.json';
|
|
45
49
|
|
|
@@ -116,6 +120,14 @@ export interface Manifest {
|
|
|
116
120
|
/** Directory of `.sql` named-query files (default `./queries`). Only read
|
|
117
121
|
* when at least one output requests a named-queries file. */
|
|
118
122
|
readonly queries: string;
|
|
123
|
+
/** §5 casing: "camel" (default) maps snake_case SQL names to camelCase in
|
|
124
|
+
* generated row types/params (queries lower with `AS` aliases); "preserve"
|
|
125
|
+
* keeps SQL-truth names everywhere. */
|
|
126
|
+
readonly naming: NamingMode;
|
|
127
|
+
/** §7 `.syql` backend: "neutralize" (default), "variants" (enumerate
|
|
128
|
+
* whenever eligible), or "auto" (enumerate at ≤ 2 optional groups). The
|
|
129
|
+
* per-query `variants` knob always forces enumeration. */
|
|
130
|
+
readonly queryBackend: ManifestQueryBackend;
|
|
119
131
|
readonly output: ManifestOutput;
|
|
120
132
|
readonly schemaVersions: readonly ManifestSchemaVersion[];
|
|
121
133
|
readonly tables: readonly ManifestTable[];
|
|
@@ -343,6 +355,8 @@ export function parseManifest(raw: unknown): Manifest {
|
|
|
343
355
|
'manifestVersion',
|
|
344
356
|
'migrations',
|
|
345
357
|
'queries',
|
|
358
|
+
'naming',
|
|
359
|
+
'queryBackend',
|
|
346
360
|
'output',
|
|
347
361
|
'schemaVersions',
|
|
348
362
|
'tables',
|
|
@@ -362,6 +376,28 @@ export function parseManifest(raw: unknown): Manifest {
|
|
|
362
376
|
: asString(obj.migrations, 'migrations');
|
|
363
377
|
const queries =
|
|
364
378
|
obj.queries === undefined ? './queries' : asString(obj.queries, 'queries');
|
|
379
|
+
let naming: NamingMode = 'camel';
|
|
380
|
+
if (obj.naming !== undefined) {
|
|
381
|
+
if (obj.naming !== 'camel' && obj.naming !== 'preserve') {
|
|
382
|
+
fail(
|
|
383
|
+
`naming must be "camel" or "preserve", got ${JSON.stringify(obj.naming)}`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
naming = obj.naming;
|
|
387
|
+
}
|
|
388
|
+
let queryBackend: ManifestQueryBackend = 'neutralize';
|
|
389
|
+
if (obj.queryBackend !== undefined) {
|
|
390
|
+
if (
|
|
391
|
+
obj.queryBackend !== 'neutralize' &&
|
|
392
|
+
obj.queryBackend !== 'variants' &&
|
|
393
|
+
obj.queryBackend !== 'auto'
|
|
394
|
+
) {
|
|
395
|
+
fail(
|
|
396
|
+
`queryBackend must be "neutralize", "variants" or "auto", got ${JSON.stringify(obj.queryBackend)}`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
queryBackend = obj.queryBackend;
|
|
400
|
+
}
|
|
365
401
|
let ir = './syncular.ir.json';
|
|
366
402
|
let module = './syncular.generated.ts';
|
|
367
403
|
let queriesOut: string | undefined;
|
|
@@ -436,6 +472,8 @@ export function parseManifest(raw: unknown): Manifest {
|
|
|
436
472
|
manifestVersion: 1,
|
|
437
473
|
migrations,
|
|
438
474
|
queries,
|
|
475
|
+
naming,
|
|
476
|
+
queryBackend,
|
|
439
477
|
output: outputSpec,
|
|
440
478
|
schemaVersions,
|
|
441
479
|
tables,
|