@syncular/typegen 0.4.0 → 0.5.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.
@@ -0,0 +1,350 @@
1
+ /** Embedded SQL-template node parser for revision-1 SYQL (§§6, 8, and 10). */
2
+ import { isSyqlTrivia, SyqlFrontendError, } from './syql-lexer.js';
3
+ const CAMEL_IDENT_RE = /^[a-z][A-Za-z0-9]*$/;
4
+ const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
5
+ function spanBetween(start, end) {
6
+ return {
7
+ file: start.span.file,
8
+ start: start.span.start,
9
+ end: end.span.end,
10
+ };
11
+ }
12
+ function emptySpanAtStart(span) {
13
+ return { file: span.file, start: span.start, end: span.start };
14
+ }
15
+ class EmbeddedParser {
16
+ #file;
17
+ #tokens;
18
+ #span;
19
+ #mode;
20
+ #index = 0;
21
+ constructor(file, tokens, span, mode) {
22
+ this.#file = file;
23
+ this.#tokens = tokens;
24
+ this.#span = span;
25
+ this.#mode = mode;
26
+ }
27
+ parse() {
28
+ const nodes = [];
29
+ let rawStart = 0;
30
+ while (this.#index < this.#tokens.length) {
31
+ const next = this.#peek();
32
+ if (next === undefined)
33
+ break;
34
+ this.#index = next.index;
35
+ const embeddedKind = this.#embeddedKind(next.token);
36
+ if (embeddedKind !== undefined) {
37
+ this.#pushRaw(nodes, rawStart, next.index);
38
+ if (embeddedKind === 'when')
39
+ nodes.push(this.#parseWhen());
40
+ else if (embeddedKind === 'scope' || embeddedKind === 'cover') {
41
+ nodes.push(this.#parseDirective(embeddedKind));
42
+ }
43
+ else {
44
+ nodes.push(this.#parsePredicateCall());
45
+ }
46
+ rawStart = this.#index;
47
+ continue;
48
+ }
49
+ this.#validateRawToken(next.token);
50
+ this.#index = next.index + 1;
51
+ }
52
+ this.#pushRaw(nodes, rawStart, this.#tokens.length);
53
+ return { kind: 'template', mode: this.#mode, nodes, span: this.#span };
54
+ }
55
+ #embeddedKind(token) {
56
+ if (token.kind === 'at-identifier') {
57
+ if (token.text === '@scope')
58
+ return 'scope';
59
+ if (token.text === '@cover')
60
+ return 'cover';
61
+ return 'predicate-call';
62
+ }
63
+ if (token.kind === 'identifier' &&
64
+ token.text === 'when' &&
65
+ this.#peekAfter(token)?.token.text === '(') {
66
+ return 'when';
67
+ }
68
+ return undefined;
69
+ }
70
+ #parsePredicateCall() {
71
+ const startIndex = this.#index;
72
+ const nameToken = this.#take();
73
+ if (this.#mode === 'order') {
74
+ this.#fail('SYQL3006_FORBIDDEN_TEMPLATE_NODE', nameToken, 'predicate calls are not allowed in sort profiles');
75
+ }
76
+ const name = nameToken.text.slice(1);
77
+ if (!CAMEL_IDENT_RE.test(name) || name.toLowerCase().startsWith('__syql')) {
78
+ this.#fail('SYQL3003_INVALID_PREDICATE_CALL', nameToken, `predicate call name ${JSON.stringify(name)} must match [a-z][A-Za-z0-9]* and not use __syql`);
79
+ }
80
+ this.#expectText('(', 'after predicate name');
81
+ const args = [];
82
+ if (this.#peek()?.token.text !== ')') {
83
+ for (;;) {
84
+ args.push(this.#parseBindReference());
85
+ if (this.#peek()?.token.text !== ',')
86
+ break;
87
+ this.#take();
88
+ if (this.#peek()?.token.text === ')')
89
+ break;
90
+ }
91
+ }
92
+ const close = this.#expectText(')', 'to close predicate call');
93
+ return {
94
+ kind: 'predicate-call',
95
+ name,
96
+ arguments: args,
97
+ tokens: this.#tokens.slice(startIndex, this.#index),
98
+ span: spanBetween(nameToken, close),
99
+ };
100
+ }
101
+ #parseWhen() {
102
+ const startIndex = this.#index;
103
+ const start = this.#take();
104
+ if (this.#mode !== 'statement') {
105
+ this.#fail('SYQL3006_FORBIDDEN_TEMPLATE_NODE', start, `when is not allowed in a ${this.#mode} template`);
106
+ }
107
+ this.#expectText('(', 'after when');
108
+ const controls = [];
109
+ const controlSpans = [];
110
+ const seen = new Set();
111
+ if (this.#peek()?.token.text === ')') {
112
+ this.#fail('SYQL3004_INVALID_WHEN', this.#peek()?.token ?? start, 'when requires at least one control');
113
+ }
114
+ for (;;) {
115
+ const control = this.#expectIdentifier('when control');
116
+ this.#validateCamel(control, 'when control', 'SYQL3004_INVALID_WHEN');
117
+ if (seen.has(control.text)) {
118
+ this.#fail('SYQL3004_INVALID_WHEN', control, `duplicate when control ${JSON.stringify(control.text)}`);
119
+ }
120
+ seen.add(control.text);
121
+ controls.push(control.text);
122
+ controlSpans.push(control.span);
123
+ if (this.#peek()?.token.text !== ',')
124
+ break;
125
+ this.#take();
126
+ if (this.#peek()?.token.text === ')')
127
+ break;
128
+ }
129
+ this.#expectText(')', 'after when controls');
130
+ const open = this.#expectText('{', 'to open when body');
131
+ const closeIndex = this.#matchingBraceIndex(open);
132
+ const close = this.#tokens[closeIndex];
133
+ const bodyTokens = this.#tokens.slice(this.#index, closeIndex);
134
+ if (bodyTokens.every(isSyqlTrivia)) {
135
+ this.#fail('SYQL3004_INVALID_WHEN', close, 'when body must not be empty');
136
+ }
137
+ const bodySpan = {
138
+ file: this.#file,
139
+ start: open.span.end,
140
+ end: close.span.start,
141
+ };
142
+ const body = parseSyqlEmbeddedTemplate(this.#file, bodyTokens, bodySpan, 'condition');
143
+ this.#index = closeIndex + 1;
144
+ return {
145
+ kind: 'when',
146
+ controls,
147
+ controlSpans,
148
+ body,
149
+ tokens: this.#tokens.slice(startIndex, this.#index),
150
+ span: spanBetween(start, close),
151
+ };
152
+ }
153
+ #parseDirective(kind) {
154
+ const startIndex = this.#index;
155
+ const start = this.#take();
156
+ if (this.#mode !== 'statement') {
157
+ this.#fail('SYQL3006_FORBIDDEN_TEMPLATE_NODE', start, `@${kind} is not allowed in a ${this.#mode} template`);
158
+ }
159
+ this.#expectText('(', `after @${kind}`);
160
+ const bindings = [];
161
+ if (this.#peek()?.token.text === ')') {
162
+ this.#fail('SYQL3005_INVALID_REACTIVE_DIRECTIVE', this.#peek()?.token ?? start, `@${kind} requires at least one scope binding`);
163
+ }
164
+ for (;;) {
165
+ bindings.push(this.#parseScopeBinding());
166
+ if (this.#peek()?.token.text !== ',')
167
+ break;
168
+ this.#take();
169
+ if (this.#peek()?.token.text === ')')
170
+ break;
171
+ }
172
+ const close = this.#expectText(')', `to close @${kind}`);
173
+ return {
174
+ kind,
175
+ bindings,
176
+ tokens: this.#tokens.slice(startIndex, this.#index),
177
+ span: spanBetween(start, close),
178
+ };
179
+ }
180
+ #parseScopeBinding() {
181
+ const qualifier = this.#expectIdentifier('scope table or alias');
182
+ this.#validateSqlIdent(qualifier, 'scope table or alias');
183
+ this.#expectText('.', 'in qualified scope column');
184
+ const columnName = this.#expectIdentifier('scope column');
185
+ this.#validateSqlIdent(columnName, 'scope column');
186
+ const column = {
187
+ qualifier: qualifier.text,
188
+ name: columnName.text,
189
+ span: spanBetween(qualifier, columnName),
190
+ };
191
+ const operator = this.#take();
192
+ if (operator === undefined ||
193
+ (operator.text !== '=' && operator.text !== 'in')) {
194
+ this.#fail('SYQL3005_INVALID_REACTIVE_DIRECTIVE', operator ?? columnName, 'scope binding must use = or lowercase in');
195
+ }
196
+ const values = [];
197
+ let end;
198
+ if (operator.text === '=') {
199
+ const value = this.#parseBindReference();
200
+ values.push(value);
201
+ end = value.token;
202
+ }
203
+ else {
204
+ this.#expectText('(', 'after scope in');
205
+ if (this.#peek()?.token.text === ')') {
206
+ this.#fail('SYQL3005_INVALID_REACTIVE_DIRECTIVE', this.#peek()?.token ?? operator, 'scope in list must not be empty');
207
+ }
208
+ const names = new Set();
209
+ for (;;) {
210
+ const value = this.#parseBindReference();
211
+ if (names.has(value.name)) {
212
+ this.#fail('SYQL3005_INVALID_REACTIVE_DIRECTIVE', value.token, `duplicate scope bind :${value.name}`);
213
+ }
214
+ names.add(value.name);
215
+ values.push(value);
216
+ if (this.#peek()?.token.text !== ',')
217
+ break;
218
+ this.#take();
219
+ if (this.#peek()?.token.text === ')')
220
+ break;
221
+ }
222
+ end = this.#expectText(')', 'to close scope in list');
223
+ }
224
+ return {
225
+ kind: 'scope-binding',
226
+ column,
227
+ operator: operator.text === '=' ? 'equal' : 'in',
228
+ values,
229
+ span: spanBetween(qualifier, end),
230
+ };
231
+ }
232
+ #parseBindReference() {
233
+ const token = this.#take();
234
+ if (token?.kind !== 'bind') {
235
+ this.#fail('SYQL3002_INVALID_BIND', token ?? this.#tokens[this.#tokens.length - 1], 'expected a :camelCase bind reference');
236
+ }
237
+ const name = token.text.slice(1);
238
+ if (!CAMEL_IDENT_RE.test(name) || name.toLowerCase().startsWith('__syql')) {
239
+ this.#fail('SYQL3002_INVALID_BIND', token, `bind ${JSON.stringify(token.text)} must match :[a-z][A-Za-z0-9]* and not use __syql`);
240
+ }
241
+ return { kind: 'bind-reference', name, token, span: token.span };
242
+ }
243
+ #validateRawToken(token) {
244
+ if (token.kind === 'bind') {
245
+ if (this.#mode === 'order') {
246
+ this.#fail('SYQL3006_FORBIDDEN_TEMPLATE_NODE', token, 'binds are not allowed in sort profiles');
247
+ }
248
+ const name = token.text.slice(1);
249
+ if (!CAMEL_IDENT_RE.test(name) ||
250
+ name.toLowerCase().startsWith('__syql')) {
251
+ this.#fail('SYQL3002_INVALID_BIND', token, `bind ${JSON.stringify(token.text)} must match :[a-z][A-Za-z0-9]* and not use __syql`);
252
+ }
253
+ }
254
+ if (token.kind === 'punctuation' && token.text === '?') {
255
+ this.#fail('SYQL3008_FORBIDDEN_PARAMETER_FORM', token, 'SQLite ? parameters are forbidden; declare and use a :camelCase bind');
256
+ }
257
+ if (token.kind === 'operator' &&
258
+ (token.text === '$' || token.text === '@')) {
259
+ this.#fail('SYQL3008_FORBIDDEN_PARAMETER_FORM', token, `SQLite ${token.text} parameters are forbidden in SYQL templates`);
260
+ }
261
+ if (token.kind === 'punctuation' &&
262
+ (token.text === '{' || token.text === '}')) {
263
+ this.#fail('SYQL3007_UNEXPECTED_BRACE', token, 'a template brace must belong to a when(...) { ... } node');
264
+ }
265
+ }
266
+ #validateCamel(token, label, code) {
267
+ if (!CAMEL_IDENT_RE.test(token.text) ||
268
+ token.text.toLowerCase().startsWith('__syql')) {
269
+ this.#fail(code, token, `${label} must match [a-z][A-Za-z0-9]* and not use __syql`);
270
+ }
271
+ }
272
+ #validateSqlIdent(token, label) {
273
+ if (!IDENT_RE.test(token.text)) {
274
+ this.#fail('SYQL3005_INVALID_REACTIVE_DIRECTIVE', token, `${label} must be an unquoted SQLite identifier`);
275
+ }
276
+ }
277
+ #matchingBraceIndex(open) {
278
+ let depth = 1;
279
+ for (let cursor = this.#index; cursor < this.#tokens.length; cursor += 1) {
280
+ const token = this.#tokens[cursor];
281
+ if (token.kind !== 'punctuation')
282
+ continue;
283
+ if (token.text === '{')
284
+ depth += 1;
285
+ else if (token.text === '}') {
286
+ depth -= 1;
287
+ if (depth === 0)
288
+ return cursor;
289
+ }
290
+ }
291
+ this.#fail('SYQL3001_EXPECTED_EMBEDDED_TOKEN', open, 'expected } to close when body');
292
+ }
293
+ #pushRaw(nodes, start, end) {
294
+ if (start >= end)
295
+ return;
296
+ const tokens = this.#tokens.slice(start, end);
297
+ const first = tokens[0];
298
+ const last = tokens[tokens.length - 1];
299
+ nodes.push({
300
+ kind: 'raw',
301
+ text: tokens.map((token) => token.text).join(''),
302
+ tokens,
303
+ span: spanBetween(first, last),
304
+ });
305
+ }
306
+ #peek(from = this.#index) {
307
+ let index = from;
308
+ while (index < this.#tokens.length) {
309
+ const token = this.#tokens[index];
310
+ if (!isSyqlTrivia(token))
311
+ return { index, token };
312
+ index += 1;
313
+ }
314
+ return undefined;
315
+ }
316
+ #peekAfter(token) {
317
+ const index = this.#tokens.indexOf(token, this.#index);
318
+ return index < 0 ? undefined : this.#peek(index + 1);
319
+ }
320
+ #take() {
321
+ const next = this.#peek();
322
+ if (next === undefined) {
323
+ this.#index = this.#tokens.length;
324
+ return undefined;
325
+ }
326
+ this.#index = next.index + 1;
327
+ return next.token;
328
+ }
329
+ #expectText(text, context) {
330
+ const token = this.#take();
331
+ if (token?.text !== text) {
332
+ this.#fail('SYQL3001_EXPECTED_EMBEDDED_TOKEN', token ?? this.#tokens[this.#tokens.length - 1], `expected ${JSON.stringify(text)} ${context}`);
333
+ }
334
+ return token;
335
+ }
336
+ #expectIdentifier(label) {
337
+ const token = this.#take();
338
+ if (token?.kind !== 'identifier') {
339
+ this.#fail('SYQL3001_EXPECTED_EMBEDDED_TOKEN', token ?? this.#tokens[this.#tokens.length - 1], `expected ${label}`);
340
+ }
341
+ return token;
342
+ }
343
+ #fail(code, token, message) {
344
+ throw new SyqlFrontendError(code, token?.span ?? emptySpanAtStart(this.#span), message);
345
+ }
346
+ }
347
+ /** Parse embedded nodes from one already-delimited lossless template. */
348
+ export function parseSyqlEmbeddedTemplate(file, tokens, span, mode) {
349
+ return new EmbeddedParser(file, tokens, span, mode).parse();
350
+ }
package/dist/syql.d.ts CHANGED
@@ -17,6 +17,20 @@ export interface SyqlLimit {
17
17
  readonly max?: number;
18
18
  readonly default?: number;
19
19
  }
20
+ export interface SyqlDepends {
21
+ readonly table: string;
22
+ readonly variable: string;
23
+ readonly params: readonly string[];
24
+ }
25
+ export interface SyqlWindow {
26
+ readonly table: string;
27
+ readonly variable: string;
28
+ readonly units: readonly string[];
29
+ readonly fixedScopes: readonly {
30
+ readonly variable: string;
31
+ readonly param: string;
32
+ }[];
33
+ }
20
34
  export interface SyqlFragmentDecl {
21
35
  readonly name: string;
22
36
  readonly params: readonly SyqlParamDecl[];
@@ -33,6 +47,11 @@ export interface SyqlQueryDecl {
33
47
  /** §7 opt-in: lower to 2^N enumerated statements (perfect index use)
34
48
  * instead of one neutralized statement. API-invisible. */
35
49
  readonly variants?: boolean;
50
+ /** Checked escape hatches for SQL shapes the conservative analyzer cannot
51
+ * prove. They live with the query, never in React application code. */
52
+ readonly depends?: readonly SyqlDepends[];
53
+ readonly windows?: readonly SyqlWindow[];
54
+ readonly keyBy?: readonly string[];
36
55
  /** The SQL-shaped body (may contain @fragment refs and if-guards). */
37
56
  readonly body: string;
38
57
  /** Source offset of the `query` keyword (editor tooling). */
package/dist/syql.js CHANGED
@@ -234,6 +234,17 @@ function parseKnobs(cur, owner) {
234
234
  let orderBy;
235
235
  let limit;
236
236
  let variants;
237
+ const depends = [];
238
+ const windows = [];
239
+ let keyBy;
240
+ const paramList = (label) => {
241
+ const out = [cur.word(`${owner}: ${label} param`)];
242
+ while (cur.peekChar() === '|') {
243
+ cur.expect('|');
244
+ out.push(cur.word(`${owner}: ${label} param`));
245
+ }
246
+ return out;
247
+ };
237
248
  for (;;) {
238
249
  const word = cur.peekWord();
239
250
  if (word === 'variants') {
@@ -242,6 +253,49 @@ function parseKnobs(cur, owner) {
242
253
  cur.word('variants');
243
254
  variants = true;
244
255
  }
256
+ else if (word === 'depends') {
257
+ cur.word('depends');
258
+ const table = cur.word(`${owner}: dependency table`);
259
+ const on = cur.word(`${owner}: dependency \`on\``);
260
+ if (on !== 'on')
261
+ cur.fail(`${owner}: depends syntax is \`depends <table> on <scope> = <param>\``);
262
+ const variable = cur.word(`${owner}: dependency scope variable`);
263
+ cur.expect('=');
264
+ depends.push({ table, variable, params: paramList('dependency') });
265
+ }
266
+ else if (word === 'window') {
267
+ cur.word('window');
268
+ const table = cur.word(`${owner}: window table`);
269
+ const by = cur.word(`${owner}: window \`by\``);
270
+ if (by !== 'by')
271
+ cur.fail(`${owner}: window syntax is \`window <table> by <scope> = <param>\``);
272
+ const variable = cur.word(`${owner}: window scope variable`);
273
+ cur.expect('=');
274
+ const units = paramList('window unit');
275
+ const fixedScopes = [];
276
+ if (cur.peekWord() === 'fixed') {
277
+ cur.word('fixed');
278
+ for (;;) {
279
+ const fixedVariable = cur.word(`${owner}: fixed scope variable`);
280
+ cur.expect('=');
281
+ const param = cur.word(`${owner}: fixed scope param`);
282
+ fixedScopes.push({ variable: fixedVariable, param });
283
+ if (cur.peekChar() !== ',')
284
+ break;
285
+ cur.expect(',');
286
+ }
287
+ }
288
+ windows.push({ table, variable, units, fixedScopes });
289
+ }
290
+ else if (word === 'key') {
291
+ if (keyBy !== undefined)
292
+ cur.fail(`${owner}: duplicate key by declaration`);
293
+ cur.word('key');
294
+ const by = cur.word(`${owner}: key \`by\``);
295
+ if (by !== 'by')
296
+ cur.fail(`${owner}: key syntax is \`key by <result-column>\``);
297
+ keyBy = paramList('key column');
298
+ }
245
299
  else if (word === 'orderBy') {
246
300
  if (orderBy !== undefined)
247
301
  cur.fail(`${owner}: duplicate orderBy knob`);
@@ -313,6 +367,9 @@ function parseKnobs(cur, owner) {
313
367
  ...(orderBy !== undefined ? { orderBy } : {}),
314
368
  ...(limit !== undefined ? { limit } : {}),
315
369
  ...(variants !== undefined ? { variants } : {}),
370
+ ...(depends.length > 0 ? { depends } : {}),
371
+ ...(windows.length > 0 ? { windows } : {}),
372
+ ...(keyBy !== undefined ? { keyBy } : {}),
316
373
  };
317
374
  }
318
375
  }
@@ -717,6 +774,143 @@ export function lowerSyqlBody(decl, fragments, location) {
717
774
  // ---------------------------------------------------------------------------
718
775
  // Analysis (parse → lower → the shared SQLite-check pipeline)
719
776
  // ---------------------------------------------------------------------------
777
+ function checkedReactiveMetadata(decl, base, params, ir, location) {
778
+ if (decl.depends === undefined &&
779
+ decl.windows === undefined &&
780
+ decl.keyBy === undefined) {
781
+ return base.reactive;
782
+ }
783
+ const paramByName = new Map(params.map((param) => [param.name, param]));
784
+ const explicitScopes = new Map();
785
+ const explicitTables = new Set();
786
+ const tableAndScope = (tableName, variable) => {
787
+ if (!base.tables.includes(tableName)) {
788
+ throw new TypegenError(location, `reactive declaration names table ${JSON.stringify(tableName)}, but the query does not read it`);
789
+ }
790
+ const table = ir.tables.find((candidate) => candidate.name === tableName);
791
+ if (table === undefined) {
792
+ throw new TypegenError(location, `unknown reactive table ${JSON.stringify(tableName)}`);
793
+ }
794
+ const scope = table.scopes.find((candidate) => candidate.variable === variable);
795
+ if (scope === undefined) {
796
+ throw new TypegenError(location, `table ${tableName} has no scope variable ${JSON.stringify(variable)}`);
797
+ }
798
+ return { table, scope };
799
+ };
800
+ const checkedParam = (tableName, variable, name) => {
801
+ const param = paramByName.get(name);
802
+ if (param === undefined) {
803
+ throw new TypegenError(location, `reactive declaration references undeclared param :${name}`);
804
+ }
805
+ if (param.optional === true) {
806
+ throw new TypegenError(location, `reactive scope ${tableName}.${variable} cannot use optional param :${name}; an absent value is not a window unit`);
807
+ }
808
+ const { table, scope } = tableAndScope(tableName, variable);
809
+ const column = table.columns.find((candidate) => candidate.name === scope.column);
810
+ if (column === undefined || column.type !== param.type) {
811
+ throw new TypegenError(location, `reactive param :${name} has type ${param.type}, incompatible with ${tableName}.${scope.column} (${column?.type ?? 'unknown'})`);
812
+ }
813
+ return param;
814
+ };
815
+ const addScope = (table, variable, names) => {
816
+ const { scope } = tableAndScope(table, variable);
817
+ explicitTables.add(table);
818
+ let byVariable = explicitScopes.get(table);
819
+ if (byVariable === undefined) {
820
+ byVariable = new Map();
821
+ explicitScopes.set(table, byVariable);
822
+ }
823
+ let binding = byVariable.get(variable);
824
+ if (binding === undefined) {
825
+ binding = { pattern: scope.pattern, params: new Set() };
826
+ byVariable.set(variable, binding);
827
+ }
828
+ for (const name of names) {
829
+ checkedParam(table, variable, name);
830
+ binding.params.add(name);
831
+ }
832
+ };
833
+ for (const dependency of decl.depends ?? []) {
834
+ addScope(dependency.table, dependency.variable, dependency.params);
835
+ }
836
+ const coverage = [];
837
+ const seenWindows = new Set();
838
+ for (const window of decl.windows ?? []) {
839
+ const key = `${window.table}\0${window.variable}`;
840
+ if (seenWindows.has(key)) {
841
+ throw new TypegenError(location, `duplicate window declaration for ${window.table}.${window.variable}`);
842
+ }
843
+ seenWindows.add(key);
844
+ const { table } = tableAndScope(window.table, window.variable);
845
+ addScope(window.table, window.variable, window.units);
846
+ const fixedByVariable = new Map();
847
+ for (const fixed of window.fixedScopes) {
848
+ if (fixed.variable === window.variable) {
849
+ throw new TypegenError(location, `window ${window.table}.${window.variable} repeats its unit scope as fixed`);
850
+ }
851
+ if (fixedByVariable.has(fixed.variable)) {
852
+ throw new TypegenError(location, `window ${window.table} repeats fixed scope ${fixed.variable}`);
853
+ }
854
+ checkedParam(window.table, fixed.variable, fixed.param);
855
+ fixedByVariable.set(fixed.variable, fixed.param);
856
+ addScope(window.table, fixed.variable, [fixed.param]);
857
+ }
858
+ const missing = table.scopes
859
+ .map((scope) => scope.variable)
860
+ .filter((variable) => variable !== window.variable && !fixedByVariable.has(variable));
861
+ if (missing.length > 0) {
862
+ throw new TypegenError(location, `window ${window.table}.${window.variable} must fix the remaining scope variables: ${missing.join(', ')}`);
863
+ }
864
+ coverage.push({
865
+ table: window.table,
866
+ variable: window.variable,
867
+ units: window.units,
868
+ fixedScopes: table.scopes
869
+ .filter((scope) => scope.variable !== window.variable)
870
+ .map((scope) => ({
871
+ variable: scope.variable,
872
+ params: [fixedByVariable.get(scope.variable)],
873
+ })),
874
+ });
875
+ }
876
+ const dependencies = base.reactive.dependencies.map((dependency) => {
877
+ if (!explicitTables.has(dependency.table))
878
+ return dependency;
879
+ const scopes = [...(explicitScopes.get(dependency.table)?.entries() ?? [])]
880
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
881
+ .map(([variable, binding]) => ({
882
+ table: dependency.table,
883
+ variable,
884
+ pattern: binding.pattern,
885
+ params: [...binding.params],
886
+ }));
887
+ return { table: dependency.table, scopes };
888
+ });
889
+ let rowKey = base.reactive.rowKey;
890
+ if (decl.keyBy !== undefined) {
891
+ const fields = decl.keyBy.map((name) => {
892
+ const column = base.columns.find((candidate) => candidate.name === name || candidate.langName === name);
893
+ if (column === undefined) {
894
+ throw new TypegenError(location, `key by column ${JSON.stringify(name)} is not projected by the query`);
895
+ }
896
+ return column.langName;
897
+ });
898
+ if (new Set(fields).size !== fields.length) {
899
+ throw new TypegenError(location, 'key by contains a duplicate result column');
900
+ }
901
+ rowKey = fields;
902
+ }
903
+ return {
904
+ dependencies,
905
+ coverage: decl.windows === undefined
906
+ ? base.reactive.coverage
907
+ : [
908
+ ...base.reactive.coverage.filter((item) => !seenWindows.has(`${item.table}\0${item.variable}`)),
909
+ ...coverage,
910
+ ],
911
+ ...(rowKey !== undefined ? { rowKey } : {}),
912
+ };
913
+ }
720
914
  /** Analyze every query of one `.syql` file into `AnalyzedQuery` units —
721
915
  * byte-compatible with the `.sql` frontend's output (§1: nothing below the
722
916
  * IR knows the frontend). */
@@ -801,6 +995,7 @@ export function analyzeSyqlFile(relPath, content, ir, db, naming) {
801
995
  });
802
996
  const mode = naming?.naming ?? 'camel';
803
997
  const mapCol = (name) => mode === 'preserve' ? name : snakeToCamel(name);
998
+ const reactive = checkedReactiveMetadata(decl, base, params, ir, location);
804
999
  const orderBy = decl.orderBy === undefined
805
1000
  ? undefined
806
1001
  : {
@@ -932,6 +1127,7 @@ export function analyzeSyqlFile(relPath, content, ir, db, naming) {
932
1127
  sourceSql: decl.body.trim(),
933
1128
  sql: exposedSql,
934
1129
  params,
1130
+ reactive,
935
1131
  ...(orderBy !== undefined ? { orderBy } : {}),
936
1132
  ...(decl.limit !== undefined ? { limit: decl.limit } : {}),
937
1133
  ...(positionalSqlBase !== undefined ? { positionalSqlBase } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/typegen",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Syncular schema-to-TypeScript type generator and the `syncular` CLI",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -48,7 +48,7 @@
48
48
  "!dist/**/*.test.d.ts"
49
49
  ],
50
50
  "devDependencies": {
51
- "@syncular/core": "0.3.1",
52
- "@syncular/server": "0.3.1"
51
+ "@syncular/core": "0.5.0",
52
+ "@syncular/server": "0.5.0"
53
53
  }
54
54
  }