@syncular/typegen 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/typegen",
3
- "version": "0.6.0",
3
+ "version": "0.8.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.6.0",
52
- "@syncular/server": "0.6.0"
51
+ "@syncular/core": "0.8.0",
52
+ "@syncular/server": "0.8.0"
53
53
  }
54
54
  }
package/src/cli.ts CHANGED
@@ -131,15 +131,13 @@ function runGeneratePrint(args: GenerateArgs, name: string): void {
131
131
  console.log(
132
132
  `-- ${input.name}: optional group (${input.members.map((member) => `${member.name}: ${member.type}${member.nullable ? ' | null' : ''}`).join(', ')})`,
133
133
  );
134
- } else if (input.kind === 'switch') {
135
- console.log(`-- ${input.name}: switch (default false)`);
136
134
  } else if (input.kind === 'sort') {
137
135
  console.log(
138
136
  `-- ${input.name}: sort [${input.profiles.map((profile) => profile.name).join(', ')}] (default ${input.defaultProfile})`,
139
137
  );
140
138
  } else {
141
139
  console.log(
142
- `-- ${input.name}: page 1..${input.maxSize} (default ${input.defaultSize})`,
140
+ `-- ${input.name}: limit 1..${input.maxSize} (default ${input.defaultSize})`,
143
141
  );
144
142
  }
145
143
  }
@@ -103,7 +103,7 @@ function syqlDartType(type: IrColumnType, nullable: boolean): string {
103
103
  function syqlControlActive(query: AnalyzedQuery, name: string): string {
104
104
  const input = syqlInput(query, name);
105
105
  const access = camelCase(input.langName);
106
- if (input.kind === 'switch') return access;
106
+ if (input.kind === 'value' && input.default === false) return access;
107
107
  if (input.kind === 'value' && input.nullable) return `${access}.isPresent`;
108
108
  if (input.kind === 'value' || input.kind === 'group') {
109
109
  return `${access} != null`;
@@ -119,7 +119,7 @@ function syqlBindExpr(query: AnalyzedQuery, bind: QuerySyqlPlanBind): string {
119
119
  }
120
120
  const input = syqlInput(query, bind.input);
121
121
  const access = camelCase(input.langName);
122
- if (bind.kind === 'page') return `effective${typeName(access)}`;
122
+ if (bind.kind === 'limit') return `effective${typeName(access)}`;
123
123
  if (bind.kind === 'group-member') {
124
124
  if (input.kind !== 'group') throw new Error('group bind/input mismatch');
125
125
  const member = input.members.find(
@@ -134,6 +134,7 @@ function syqlBindExpr(query: AnalyzedQuery, bind: QuerySyqlPlanBind): string {
134
134
  return `${access} == null ? null : ${value}`;
135
135
  }
136
136
  if (input.kind !== 'value') throw new Error('value bind/input mismatch');
137
+ if (input.default === false) return paramValue(input.type, access);
137
138
  if (input.required) {
138
139
  return input.nullable
139
140
  ? optionalParamValue(input.type, access)
@@ -189,6 +190,7 @@ function emitSyqlDartRunner(query: AnalyzedQuery): string[] {
189
190
  if (input.kind === 'value') {
190
191
  const type = syqlDartType(input.type, input.nullable);
191
192
  if (input.required) args.push(`required ${type} ${name}`);
193
+ else if (input.default === false) args.push(`bool ${name} = false`);
192
194
  else if (input.nullable) {
193
195
  args.push(
194
196
  `SyqlQueryPresence<${type}> ${name} = const SyqlQueryPresence.absent()`,
@@ -196,8 +198,6 @@ function emitSyqlDartRunner(query: AnalyzedQuery): string[] {
196
198
  } else args.push(`${type}? ${name}`);
197
199
  } else if (input.kind === 'group') {
198
200
  args.push(`${typeName(query.name)}${typeName(input.langName)}? ${name}`);
199
- } else if (input.kind === 'switch') {
200
- args.push(`bool ${name} = false`);
201
201
  } else if (input.kind === 'sort') {
202
202
  const defaultCase =
203
203
  input.profiles.find((profile) => profile.name === input.defaultProfile)
@@ -212,13 +212,13 @@ function emitSyqlDartRunner(query: AnalyzedQuery): string[] {
212
212
  lines.push(
213
213
  `List<${Row}> syncular${Pascal}Query(SyncularClient client${argsClause}) {`,
214
214
  );
215
- const page = metadata.inputs.find((input) => input.kind === 'page');
216
- if (page?.kind === 'page') {
215
+ const page = metadata.inputs.find((input) => input.kind === 'limit');
216
+ if (page?.kind === 'limit') {
217
217
  const name = camelCase(page.langName);
218
218
  lines.push(
219
219
  ` final effective${typeName(name)} = ${name} ?? ${page.defaultSize};`,
220
220
  ` if (effective${typeName(name)} < 1 || effective${typeName(name)} > ${page.maxSize}) {`,
221
- ` throw SyqlQueryInputException('SYQL_RUNTIME_INVALID_PAGE', ${quote(`${query.name}: invalid page size`)});`,
221
+ ` throw SyqlQueryInputException('SYQL_RUNTIME_INVALID_LIMIT', ${quote(`${query.name}: invalid limit`)});`,
222
222
  ' }',
223
223
  );
224
224
  }
@@ -107,7 +107,7 @@ function syqlKotlinType(type: IrColumnType, nullable: boolean): string {
107
107
  function syqlControlActive(query: AnalyzedQuery, name: string): string {
108
108
  const input = syqlInput(query, name);
109
109
  const access = camelCase(input.langName);
110
- if (input.kind === 'switch') return access;
110
+ if (input.kind === 'value' && input.default === false) return access;
111
111
  if (input.kind === 'value' && input.nullable) {
112
112
  return `${access} is SyncularQueryPresence.Present`;
113
113
  }
@@ -130,7 +130,7 @@ function syqlBindExpr(query: AnalyzedQuery, bind: QuerySyqlPlanBind): string {
130
130
  }
131
131
  const input = syqlInput(query, bind.input);
132
132
  const access = camelCase(input.langName);
133
- if (bind.kind === 'page')
133
+ if (bind.kind === 'limit')
134
134
  return `JsonValue.of(effective${typeName(access)}.toDouble())`;
135
135
  if (bind.kind === 'group-member') {
136
136
  if (input.kind !== 'group') throw new Error('group bind/input mismatch');
@@ -146,6 +146,7 @@ function syqlBindExpr(query: AnalyzedQuery, bind: QuerySyqlPlanBind): string {
146
146
  return `${access}?.let { value -> ${present} } ?: JsonValue.Null`;
147
147
  }
148
148
  if (input.kind !== 'value') throw new Error('value bind/input mismatch');
149
+ if (input.default === false) return paramValue(input.type, access);
149
150
  if (input.required) {
150
151
  return input.nullable
151
152
  ? optionalParamValue(input.type, access)
@@ -201,6 +202,7 @@ function emitSyqlKotlinRunner(query: AnalyzedQuery): string[] {
201
202
  if (input.kind === 'value') {
202
203
  const type = syqlKotlinType(input.type, input.nullable);
203
204
  if (input.required) args.push(`${name}: ${type}`);
205
+ else if (input.default === false) args.push(`${name}: Boolean = false`);
204
206
  else if (input.nullable) {
205
207
  args.push(
206
208
  `${name}: SyncularQueryPresence<${type}> = SyncularQueryPresence.Absent`,
@@ -210,8 +212,6 @@ function emitSyqlKotlinRunner(query: AnalyzedQuery): string[] {
210
212
  args.push(
211
213
  `${name}: ${typeName(query.name)}${typeName(input.langName)}? = null`,
212
214
  );
213
- } else if (input.kind === 'switch') {
214
- args.push(`${name}: Boolean = false`);
215
215
  } else if (input.kind === 'sort') {
216
216
  const defaultCase =
217
217
  input.profiles.find((profile) => profile.name === input.defaultProfile)
@@ -223,13 +223,13 @@ function emitSyqlKotlinRunner(query: AnalyzedQuery): string[] {
223
223
  }
224
224
  }
225
225
  lines.push(` fun ${query.name}(${args.join(', ')}): List<${Row}> {`);
226
- const page = metadata.inputs.find((input) => input.kind === 'page');
227
- if (page?.kind === 'page') {
226
+ const page = metadata.inputs.find((input) => input.kind === 'limit');
227
+ if (page?.kind === 'limit') {
228
228
  const name = camelCase(page.langName);
229
229
  lines.push(
230
230
  ` val effective${typeName(name)} = ${name} ?: ${page.defaultSize}L`,
231
231
  ` if (effective${typeName(name)} < 1L || effective${typeName(name)} > ${page.maxSize}L) {`,
232
- ` throw SyncularQueryInputException("SYQL_RUNTIME_INVALID_PAGE", ${quote(`${query.name}: invalid page size`)})`,
232
+ ` throw SyncularQueryInputException("SYQL_RUNTIME_INVALID_LIMIT", ${quote(`${query.name}: invalid limit`)})`,
233
233
  ' }',
234
234
  );
235
235
  }
@@ -115,7 +115,7 @@ function syqlSwiftType(type: IrColumnType, nullable: boolean): string {
115
115
  function syqlControlActive(query: AnalyzedQuery, name: string): string {
116
116
  const input = syqlInput(query, name);
117
117
  const access = camelCase(input.langName);
118
- if (input.kind === 'switch') return access;
118
+ if (input.kind === 'value' && input.default === false) return access;
119
119
  if (input.kind === 'value' && input.nullable) {
120
120
  return `{ if case .present = ${access} { return true }; return false }()`;
121
121
  }
@@ -138,7 +138,7 @@ function syqlBindExpr(query: AnalyzedQuery, bind: QuerySyqlPlanBind): string {
138
138
  }
139
139
  const input = syqlInput(query, bind.input);
140
140
  const access = camelCase(input.langName);
141
- if (bind.kind === 'page')
141
+ if (bind.kind === 'limit')
142
142
  return `.number(Double(effective${typeName(access)}))`;
143
143
  if (bind.kind === 'group-member') {
144
144
  if (input.kind !== 'group') throw new Error('group bind/input mismatch');
@@ -154,6 +154,7 @@ function syqlBindExpr(query: AnalyzedQuery, bind: QuerySyqlPlanBind): string {
154
154
  return `${access}.map { value in ${present} } ?? .null`;
155
155
  }
156
156
  if (input.kind !== 'value') throw new Error('value bind/input mismatch');
157
+ if (input.default === false) return paramValue(input.type, access);
157
158
  if (input.required) {
158
159
  return input.nullable
159
160
  ? optionalParamValue(input.type, access)
@@ -216,6 +217,7 @@ function emitSyqlSwiftRunner(query: AnalyzedQuery): string[] {
216
217
  if (input.kind === 'value') {
217
218
  const type = syqlSwiftType(input.type, input.nullable);
218
219
  if (input.required) args.push(`${name}: ${type}`);
220
+ else if (input.default === false) args.push(`${name}: Bool = false`);
219
221
  else if (input.nullable)
220
222
  args.push(`${name}: SyncularQueryPresence<${type}> = .absent`);
221
223
  else args.push(`${name}: ${type}? = nil`);
@@ -223,8 +225,6 @@ function emitSyqlSwiftRunner(query: AnalyzedQuery): string[] {
223
225
  args.push(
224
226
  `${name}: ${typeName(query.name)}${typeName(input.langName)}? = nil`,
225
227
  );
226
- } else if (input.kind === 'switch') {
227
- args.push(`${name}: Bool = false`);
228
228
  } else if (input.kind === 'sort') {
229
229
  const defaultCase =
230
230
  input.profiles.find((profile) => profile.name === input.defaultProfile)
@@ -239,13 +239,13 @@ function emitSyqlSwiftRunner(query: AnalyzedQuery): string[] {
239
239
  lines.push(
240
240
  ` public static func ${query.name}(${args.join(', ')}) throws -> [${Row}] {`,
241
241
  );
242
- const page = metadata.inputs.find((input) => input.kind === 'page');
243
- if (page?.kind === 'page') {
242
+ const page = metadata.inputs.find((input) => input.kind === 'limit');
243
+ if (page?.kind === 'limit') {
244
244
  const name = camelCase(page.langName);
245
245
  lines.push(
246
246
  ` let effective${typeName(name)} = ${name} ?? ${page.defaultSize}`,
247
247
  ` guard effective${typeName(name)} >= 1 && effective${typeName(name)} <= ${page.maxSize} else {`,
248
- ` throw SyncularQueryInputError(code: "SYQL_RUNTIME_INVALID_PAGE", message: ${quote(`${query.name}: invalid page size`)})`,
248
+ ` throw SyncularQueryInputError(code: "SYQL_RUNTIME_INVALID_LIMIT", message: ${quote(`${query.name}: invalid limit`)})`,
249
249
  ' }',
250
250
  );
251
251
  }
@@ -67,7 +67,8 @@ function reactiveParam(query: AnalyzedQuery, name: string): string {
67
67
  );
68
68
  if (input?.kind !== 'value')
69
69
  throw new Error(`unknown reactive input ${name}`);
70
- return `String(params.${propertyKey(input.langName)})`;
70
+ const access = `params.${propertyKey(input.langName)}`;
71
+ return `String(${input.default === false ? `${access} ?? false` : access})`;
71
72
  }
72
73
  const param = query.params.find((candidate) => candidate.name === name);
73
74
  if (param === undefined) throw new Error(`unknown reactive param ${name}`);
@@ -117,7 +118,8 @@ function syqlControlActive(
117
118
  ): string {
118
119
  const input = syqlInput(query, control);
119
120
  const access = `${params}.${propertyKey(input.langName)}`;
120
- if (input.kind === 'switch') return `${access} === true`;
121
+ if (input.kind === 'value' && input.default === false)
122
+ return `${access} === true`;
121
123
  if (input.kind === 'value' || input.kind === 'group') {
122
124
  return `${access} !== undefined`;
123
125
  }
@@ -136,8 +138,8 @@ function syqlBindExpr(
136
138
  }
137
139
  const input = syqlInput(query, bind.input);
138
140
  const access = `${params}.${propertyKey(input.langName)}`;
139
- if (bind.kind === 'page') {
140
- if (input.kind !== 'page') throw new Error('page bind/input mismatch');
141
+ if (bind.kind === 'limit') {
142
+ if (input.kind !== 'limit') throw new Error('limit bind/input mismatch');
141
143
  return `${access} ?? ${input.defaultSize}`;
142
144
  }
143
145
  if (bind.kind === 'group-member') {
@@ -150,6 +152,7 @@ function syqlBindExpr(
150
152
  return `${access}?.${propertyKey(member.langName)} ?? null`;
151
153
  }
152
154
  if (input.kind !== 'value') throw new Error('value bind/input mismatch');
155
+ if (input.default === false) return `${access} ?? false`;
153
156
  if (input.required) return access;
154
157
  return input.nullable ? `${access}?.value ?? null` : `${access} ?? null`;
155
158
  }
@@ -197,17 +200,13 @@ function emitSyqlValidation(query: AnalyzedQuery, Params: string): string[] {
197
200
  );
198
201
  }
199
202
  lines.push(' }');
200
- } else if (input.kind === 'switch') {
201
- lines.push(
202
- ` if (${access} !== undefined && typeof ${access} !== 'boolean') throw new SyqlInputError('SYQL_RUNTIME_INVALID_INPUT', ${quote(`${query.name}: invalid switch ${input.name}`)});`,
203
- );
204
203
  } else if (input.kind === 'sort') {
205
204
  lines.push(
206
205
  ` if (${access} !== undefined && ![${input.profiles.map((profile) => quote(profile.langName)).join(', ')}].includes(${access})) throw new SyqlInputError('SYQL_RUNTIME_INVALID_SORT', ${quote(`${query.name}: invalid sort profile`)});`,
207
206
  );
208
207
  } else {
209
208
  lines.push(
210
- ` if (${access} !== undefined && (!Number.isSafeInteger(${access}) || ${access} < 1 || ${access} > ${input.maxSize})) throw new SyqlInputError('SYQL_RUNTIME_INVALID_PAGE', ${quote(`${query.name}: page size must be an integer from 1 through ${input.maxSize}`)});`,
209
+ ` if (${access} !== undefined && (!Number.isSafeInteger(${access}) || ${access} < 1 || ${access} > ${input.maxSize})) throw new SyqlInputError('SYQL_RUNTIME_INVALID_LIMIT', ${quote(`${query.name}: limit must be an integer from 1 through ${input.maxSize}`)});`,
211
210
  );
212
211
  }
213
212
  }
@@ -267,8 +266,6 @@ function emitSyqlQuery(query: AnalyzedQuery, hash: string): string {
267
266
  lines.push(
268
267
  ` ${key}?: ${pascalCase(query.name)}${pascalCase(input.langName)};`,
269
268
  );
270
- } else if (input.kind === 'switch') {
271
- lines.push(` ${key}?: boolean;`);
272
269
  } else if (input.kind === 'sort') {
273
270
  lines.push(
274
271
  ` ${key}?: ${input.profiles.map((profile) => quote(profile.langName)).join(' | ')};`,
@@ -580,7 +577,7 @@ export function emitQueriesModule(
580
577
  " | 'SYQL_RUNTIME_INVALID_INPUT'",
581
578
  " | 'SYQL_RUNTIME_INVALID_GROUP'",
582
579
  " | 'SYQL_RUNTIME_INVALID_SORT'",
583
- " | 'SYQL_RUNTIME_INVALID_PAGE';",
580
+ " | 'SYQL_RUNTIME_INVALID_LIMIT';",
584
581
  '',
585
582
  'export class SyqlInputError extends Error {',
586
583
  " readonly name = 'SyqlInputError';",
package/src/fmt.ts CHANGED
@@ -53,7 +53,7 @@ function templates(file: SyqlSyntaxFile): readonly SyqlTemplate[] {
53
53
  return file.declarations.flatMap((declaration) => {
54
54
  if (declaration.kind === 'predicate') return [declaration.body];
55
55
  return [
56
- declaration.sql.body,
56
+ declaration.statement,
57
57
  ...(declaration.sort?.profiles.map((profile) => profile.order) ?? []),
58
58
  ];
59
59
  });
@@ -268,6 +268,8 @@ function formatTokens(parsed: SyqlSyntaxFile): string {
268
268
  let justClosedImportList = false;
269
269
  let seenTopLevel = false;
270
270
  let pendingBetween = 0;
271
+ let inlineParameterRecord = false;
272
+ let justClosedInlineParameterRecord = false;
271
273
 
272
274
  let declarationParameters: DeclarationParameters | undefined;
273
275
  let awaitsDeclarationParameters = false;
@@ -279,15 +281,26 @@ function formatTokens(parsed: SyqlSyntaxFile): string {
279
281
  const topLevelDeclaration =
280
282
  braceDepth === 0 &&
281
283
  token.kind === 'identifier' &&
282
- (lower === 'import' || lower === 'predicate' || lower === 'query');
284
+ (lower === 'import' ||
285
+ lower === 'predicate' ||
286
+ lower === 'query' ||
287
+ lower === 'sync') &&
288
+ !(lower === 'query' && previous?.text === 'sync');
283
289
  if (topLevelDeclaration) {
284
290
  if (seenTopLevel) writer.blankline();
285
291
  seenTopLevel = true;
286
292
  previous = undefined;
287
293
  awaitsDeclarationParameters = lower === 'predicate' || lower === 'query';
294
+ } else if (
295
+ braceDepth === 0 &&
296
+ lower === 'query' &&
297
+ previous?.text === 'sync'
298
+ ) {
299
+ awaitsDeclarationParameters = true;
288
300
  } else if (
289
301
  previous?.text === '}' &&
290
302
  !justClosedImportList &&
303
+ !justClosedInlineParameterRecord &&
291
304
  token.text !== ';' &&
292
305
  token.text !== ','
293
306
  ) {
@@ -326,6 +339,12 @@ function formatTokens(parsed: SyqlSyntaxFile): string {
326
339
  if (previous?.kind === 'identifier' && previous.text === 'import') {
327
340
  writer.write('{', true);
328
341
  importList = true;
342
+ } else if (
343
+ declarationParameters !== undefined &&
344
+ previous?.text === ':'
345
+ ) {
346
+ writer.write('{', true);
347
+ inlineParameterRecord = true;
329
348
  } else {
330
349
  writer.openBlock();
331
350
  braceDepth += 1;
@@ -334,7 +353,11 @@ function formatTokens(parsed: SyqlSyntaxFile): string {
334
353
  continue;
335
354
  }
336
355
  if (token.text === '}') {
337
- if (importList && braceDepth === 0) {
356
+ if (inlineParameterRecord) {
357
+ writer.write('}', true);
358
+ inlineParameterRecord = false;
359
+ justClosedInlineParameterRecord = true;
360
+ } else if (importList && braceDepth === 0) {
338
361
  writer.write('}', true);
339
362
  importList = false;
340
363
  justClosedImportList = true;
@@ -345,6 +368,7 @@ function formatTokens(parsed: SyqlSyntaxFile): string {
345
368
  previous = token;
346
369
  continue;
347
370
  }
371
+ justClosedInlineParameterRecord = false;
348
372
  justClosedImportList = false;
349
373
 
350
374
  if (token.text === '(') {
@@ -365,7 +389,11 @@ function formatTokens(parsed: SyqlSyntaxFile): string {
365
389
  continue;
366
390
  }
367
391
 
368
- if (token.text === ',' && declarationParameters?.depth === parenDepth) {
392
+ if (
393
+ token.text === ',' &&
394
+ declarationParameters?.depth === parenDepth &&
395
+ !inlineParameterRecord
396
+ ) {
369
397
  const next = tokens[index + 1];
370
398
  if (!(declarationParameters.multiline === false && next?.text === ')')) {
371
399
  writer.write(',', false);
package/src/lsp.ts CHANGED
@@ -148,19 +148,19 @@ function spanRange(text: string, span: SyqlSourceSpan): Range {
148
148
  function typeText(type: SyqlValueType | undefined): string {
149
149
  return type === undefined
150
150
  ? 'inferred'
151
- : `${type.base}${type.nullable ? ' | null' : ''}`;
151
+ : `${type.base === 'boolean' ? 'bool' : type.base}${type.nullable ? ' | null' : ''}`;
152
152
  }
153
153
 
154
154
  function parameterHover(parameter: SyqlQueryParameter): string {
155
- if (parameter.kind === 'switch') {
156
- return `switch \`${parameter.name}\` — false/absent or true`;
155
+ if (parameter.kind === 'range') {
156
+ return `${parameter.optional ? 'optional' : 'required'} inclusive range \`${parameter.name}: range<${typeText(parameter.type)}>\``;
157
157
  }
158
158
  if (parameter.kind === 'group') {
159
159
  return `optional group \`${parameter.name}\`\n\n${parameter.members
160
160
  .map((member) => `- \`${member.name}: ${typeText(member.type)}\``)
161
161
  .join('\n')}`;
162
162
  }
163
- return `${parameter.optional ? 'optional' : 'required'} input \`${parameter.name}: ${typeText(parameter.type)}\``;
163
+ return `${parameter.optional ? 'optional' : parameter.default === false ? 'default-false' : 'required'} input \`${parameter.name}: ${typeText(parameter.type)}\``;
164
164
  }
165
165
 
166
166
  export class SyqlLanguageServer {
@@ -432,22 +432,10 @@ export class SyqlLanguageServer {
432
432
  const request = this.#request(params);
433
433
  if (request === null) return null;
434
434
  const { found, view, offset } = request;
435
- if (found.word === '@scope' || found.word === '@cover') {
436
- return {
437
- contents: {
438
- kind: 'markdown',
439
- value:
440
- found.word === '@scope'
441
- ? '`@scope` constructs exact reactive dependency keys and the same SQL predicate.'
442
- : '`@cover` constructs exact dependencies plus checked window coverage.',
443
- },
444
- };
445
- }
446
- if (found.word.startsWith('@')) {
447
- const predicate = view.semantic.predicateScopes
448
- .get(view.file)
449
- ?.get(found.word.slice(1));
450
- if (predicate === undefined) return null;
435
+ const predicate = view.semantic.predicateScopes
436
+ .get(view.file)
437
+ ?.get(found.word);
438
+ if (predicate !== undefined) {
451
439
  return {
452
440
  contents: {
453
441
  kind: 'markdown',
@@ -488,11 +476,11 @@ export class SyqlLanguageServer {
488
476
  },
489
477
  };
490
478
  }
491
- if (query.page?.control === found.word) {
479
+ if (query.limit?.control === found.word) {
492
480
  return {
493
481
  contents: {
494
482
  kind: 'markdown',
495
- value: `page control \`${found.word}\`: default ${query.page.defaultSize}, maximum ${query.page.maxSize}`,
483
+ value: `limit control \`${found.word}\`: default ${query.limit.defaultSize}, maximum ${query.limit.maxSize}`,
496
484
  },
497
485
  };
498
486
  }
@@ -529,10 +517,10 @@ export class SyqlLanguageServer {
529
517
 
530
518
  #definition(params: unknown): unknown {
531
519
  const request = this.#request(params);
532
- if (request === null || !request.found.word.startsWith('@')) return null;
520
+ if (request === null) return null;
533
521
  const predicate = request.view.semantic.predicateScopes
534
522
  .get(request.view.file)
535
- ?.get(request.found.word.slice(1));
523
+ ?.get(request.found.word);
536
524
  if (predicate === undefined) return null;
537
525
  const text =
538
526
  this.#openText(predicate.module.file) ?? predicate.module.source;
@@ -544,17 +532,17 @@ export class SyqlLanguageServer {
544
532
 
545
533
  #references(params: unknown): unknown {
546
534
  const request = this.#request(params);
547
- if (request === null || !request.found.word.startsWith('@')) return [];
535
+ if (request === null) return [];
548
536
  const target = request.view.semantic.predicateScopes
549
537
  .get(request.view.file)
550
- ?.get(request.found.word.slice(1));
538
+ ?.get(request.found.word);
551
539
  if (target === undefined) return [];
552
540
  const locations: unknown[] = [];
553
541
  for (const module of request.view.semantic.graph.modules) {
554
542
  const scope = request.view.semantic.predicateScopes.get(module.file);
555
543
  for (const token of module.tokens) {
556
- if (token.kind !== 'at-identifier') continue;
557
- const resolved = scope?.get(token.text.slice(1));
544
+ if (token.kind !== 'identifier') continue;
545
+ const resolved = scope?.get(token.text);
558
546
  if (resolved?.id !== target.id) continue;
559
547
  locations.push({
560
548
  uri: pathToFileURL(module.file).href,
package/src/query.ts CHANGED
@@ -87,6 +87,9 @@ const DECLTYPE_MAP: Readonly<Record<string, IrColumnType>> = {
87
87
  CRDT: 'crdt',
88
88
  };
89
89
 
90
+ /** Local client protocol column; query-only and never part of schema IR. */
91
+ const SYNC_VERSION_COLUMN = '_sync_version';
92
+
90
93
  /** A param type is one of the §2.4 types (the columns params compare to). */
91
94
  export type QueryParamType = IrColumnType;
92
95
 
@@ -161,9 +164,8 @@ export interface QueryNamingOptions {
161
164
 
162
165
  const DEFAULT_NAMING: QueryNamingOptions = { naming: 'camel', targets: ['ts'] };
163
166
 
164
- /** Revision-1 SYQL public inputs. These are deliberately separate from SQL
165
- * binds: groups and switches are public values without corresponding SQLite
166
- * parameters, while compiler-generated parameters are never public. */
167
+ /** SYQL public inputs. These are deliberately separate from SQL binds: groups
168
+ * and compiler-generated parameters have distinct public/runtime shapes. */
167
169
  export type QuerySyqlPublicInput =
168
170
  | {
169
171
  readonly kind: 'value';
@@ -172,6 +174,7 @@ export type QuerySyqlPublicInput =
172
174
  readonly type: QueryParamType;
173
175
  readonly nullable: boolean;
174
176
  readonly required: boolean;
177
+ readonly default?: false;
175
178
  }
176
179
  | {
177
180
  readonly kind: 'group';
@@ -184,12 +187,6 @@ export type QuerySyqlPublicInput =
184
187
  readonly nullable: boolean;
185
188
  }[];
186
189
  }
187
- | {
188
- readonly kind: 'switch';
189
- readonly name: string;
190
- readonly langName: string;
191
- readonly default: false;
192
- }
193
190
  | {
194
191
  readonly kind: 'sort';
195
192
  readonly name: string;
@@ -201,7 +198,7 @@ export type QuerySyqlPublicInput =
201
198
  }[];
202
199
  }
203
200
  | {
204
- readonly kind: 'page';
201
+ readonly kind: 'limit';
205
202
  readonly name: string;
206
203
  readonly langName: string;
207
204
  readonly defaultSize: number;
@@ -231,7 +228,7 @@ export type QuerySyqlPlanBind =
231
228
  readonly controls: readonly string[];
232
229
  }
233
230
  | {
234
- readonly kind: 'page';
231
+ readonly kind: 'limit';
235
232
  readonly name: string;
236
233
  readonly type: 'integer';
237
234
  readonly input: string;
@@ -250,7 +247,7 @@ export interface QuerySyqlStatement {
250
247
  /** The target-neutral physical plan every emitter must implement exactly. */
251
248
  export interface QuerySyqlExecutionPlan {
252
249
  readonly backend: Exclude<QueryBackend, 'auto'>;
253
- /** One bit per optional scalar/group/switch, in declaration order. */
250
+ /** One bit per conditional activation control, in declaration order. */
254
251
  readonly activationControls: readonly string[];
255
252
  readonly conditions: readonly {
256
253
  readonly controls: readonly string[];
@@ -819,6 +816,20 @@ function resolveSource(
819
816
  return null;
820
817
  }
821
818
 
819
+ /** Resolve the one supported query-only protocol column. SQLite still proves
820
+ * qualifier/ambiguity against the synthesized local tables. */
821
+ function isSyncVersionSource(
822
+ item: SelectItem,
823
+ refs: readonly TableRef[],
824
+ ): boolean {
825
+ const match = PLAIN_REF_RE.exec(item.expr);
826
+ if (match === null || match[2] !== SYNC_VERSION_COLUMN) return false;
827
+ const qualifier = match[1];
828
+ return qualifier === undefined
829
+ ? refs.length === 1
830
+ : refs.some((ref) => ref.alias === qualifier);
831
+ }
832
+
822
833
  // -- param type inference -----------------------------------------------------
823
834
  //
824
835
  // Infer a param's type from `col <op> :name` or `col IN (:name, …)` where `col`
@@ -1168,6 +1179,10 @@ export function synthesizeDdl(ir: IrDocument): string {
1168
1179
  const pk = c.name === table.primaryKey ? ' PRIMARY KEY' : '';
1169
1180
  return ` ${c.name} ${SQL_TYPE[c.type]}${pk}${notNull}`;
1170
1181
  });
1182
+ // The materialized local client table always carries this hidden protocol
1183
+ // token. It is absent from IR mutation types but may be explicitly aliased
1184
+ // by a named read query for optimistic concurrency.
1185
+ cols.push(` ${SYNC_VERSION_COLUMN} INTEGER NOT NULL DEFAULT -1`);
1171
1186
  lines.push(`CREATE TABLE ${table.name} (\n${cols.join(',\n')}\n);`);
1172
1187
  // Create the declared indexes too — harmless for prepare()/decltype, and
1173
1188
  // keeps the type-check DB's shape honest with what the client materializes.
@@ -1291,6 +1306,21 @@ export function analyzeStatement(
1291
1306
  const source = item !== undefined ? resolveSource(item, refs, ir) : null;
1292
1307
  const sqlName = sqlNames[index] ?? colName;
1293
1308
  const langName = langNames[index] ?? colName;
1309
+ if (item !== undefined && isSyncVersionSource(item, refs)) {
1310
+ if (sqlName === SYNC_VERSION_COLUMN) {
1311
+ throw new TypegenError(
1312
+ file,
1313
+ `${SYNC_VERSION_COLUMN} is stripped from app-facing query rows unless explicitly aliased (for example \`${SYNC_VERSION_COLUMN} AS server_version\`)`,
1314
+ );
1315
+ }
1316
+ return {
1317
+ name: sqlName,
1318
+ langName,
1319
+ type: 'integer',
1320
+ nullable: false,
1321
+ fidelity: 'exact',
1322
+ };
1323
+ }
1294
1324
  if (source !== null) {
1295
1325
  // Exact: IR column type + IR nullability. (decltype agrees; we prefer
1296
1326
  // the IR type so blob_ref/crdt/json semantic types survive.)