@syncular/typegen 0.2.1 → 0.3.1

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.
Files changed (51) hide show
  1. package/README.md +25 -10
  2. package/dist/cli.js +155 -4
  3. package/dist/emit-dart.js +3 -2
  4. package/dist/emit-kotlin.js +3 -2
  5. package/dist/emit-queries-dart.js +87 -15
  6. package/dist/emit-queries-kotlin.js +89 -15
  7. package/dist/emit-queries-swift.js +92 -14
  8. package/dist/emit-queries.js +131 -22
  9. package/dist/emit-swift.js +3 -2
  10. package/dist/emit.d.ts +2 -1
  11. package/dist/emit.js +13 -27
  12. package/dist/fmt.d.ts +3 -0
  13. package/dist/fmt.js +356 -0
  14. package/dist/generate.d.ts +16 -6
  15. package/dist/generate.js +34 -9
  16. package/dist/index.d.ts +6 -0
  17. package/dist/index.js +6 -0
  18. package/dist/lower.d.ts +54 -0
  19. package/dist/lower.js +283 -0
  20. package/dist/lsp.d.ts +20 -0
  21. package/dist/lsp.js +376 -0
  22. package/dist/manifest.d.ts +11 -0
  23. package/dist/manifest.js +20 -0
  24. package/dist/naming.d.ts +22 -0
  25. package/dist/naming.js +240 -0
  26. package/dist/query-ir.d.ts +14 -0
  27. package/dist/query-ir.js +69 -0
  28. package/dist/query.d.ts +102 -6
  29. package/dist/query.js +126 -34
  30. package/dist/syql.d.ts +83 -0
  31. package/dist/syql.js +945 -0
  32. package/package.json +2 -2
  33. package/src/cli.ts +155 -4
  34. package/src/emit-dart.ts +3 -2
  35. package/src/emit-kotlin.ts +3 -2
  36. package/src/emit-queries-dart.ts +109 -16
  37. package/src/emit-queries-kotlin.ts +111 -18
  38. package/src/emit-queries-swift.ts +106 -17
  39. package/src/emit-queries.ts +179 -28
  40. package/src/emit-swift.ts +3 -2
  41. package/src/emit.ts +18 -28
  42. package/src/fmt.ts +351 -0
  43. package/src/generate.ts +54 -11
  44. package/src/index.ts +6 -0
  45. package/src/lower.ts +331 -0
  46. package/src/lsp.ts +445 -0
  47. package/src/manifest.ts +38 -0
  48. package/src/naming.ts +271 -0
  49. package/src/query-ir.ts +82 -0
  50. package/src/query.ts +241 -34
  51. package/src/syql.ts +1171 -0
@@ -13,7 +13,8 @@
13
13
  * bytes decode). Header carries the IR hash for byte-exact `--check`.
14
14
  */
15
15
  import type { IrColumnType } from './ir';
16
- import type { AnalyzedQuery, QueryColumn } from './query';
16
+ import { snakeToCamel } from './naming';
17
+ import type { AnalyzedQuery, QueryColumn, QueryParam } from './query';
17
18
 
18
19
  const SWIFT_TYPE: Readonly<Record<IrColumnType, string>> = {
19
20
  string: 'String',
@@ -34,9 +35,9 @@ function pascalCase(name: string): string {
34
35
  .join('');
35
36
  }
36
37
 
38
+ /** Language-facing field name — the pinned §12 naming map. */
37
39
  function camelCase(name: string): string {
38
- const pascal = pascalCase(name);
39
- return pascal.charAt(0).toLowerCase() + pascal.slice(1);
40
+ return snakeToCamel(name);
40
41
  }
41
42
 
42
43
  /** The query function/type name: already camelCase; PascalCase for the type. */
@@ -49,7 +50,7 @@ function quote(value: string): string {
49
50
  }
50
51
 
51
52
  function rowAccessor(column: QueryColumn): string {
52
- const key = `row[${quote(column.name)}]`;
53
+ const key = `row[${quote(column.langName)}]`;
53
54
  switch (column.type) {
54
55
  case 'string':
55
56
  case 'json':
@@ -82,6 +83,32 @@ function paramValue(type: IrColumnType, name: string): string {
82
83
  }
83
84
  }
84
85
 
86
+ /** Bind for a §4 OPTIONAL param: `nil` rides as JSON null (the §7
87
+ * neutralization guards make it a no-op). */
88
+ function optionalParamValue(type: IrColumnType, name: string): string {
89
+ switch (type) {
90
+ case 'integer':
91
+ return `${name}.map { JSONValue.number(Double($0)) } ?? .null`;
92
+ case 'float':
93
+ return `${name}.map { JSONValue.number($0) } ?? .null`;
94
+ case 'boolean':
95
+ return `${name}.map { JSONValue.bool($0) } ?? .null`;
96
+ case 'bytes':
97
+ case 'crdt':
98
+ return `${name}.map { SyncularSchemaQueryBind.bytes($0) } ?? .null`;
99
+ default:
100
+ return `${name}.map { JSONValue.string($0) } ?? .null`;
101
+ }
102
+ }
103
+
104
+ function isOptionalParam(query: AnalyzedQuery, p: QueryParam): boolean {
105
+ return (
106
+ p.optional === true ||
107
+ p.flag === true ||
108
+ (query.limit !== undefined && p.name === 'limit')
109
+ );
110
+ }
111
+
85
112
  function emitRowStruct(query: AnalyzedQuery): string[] {
86
113
  const Row = `${typeName(query.name)}Row`;
87
114
  const lines: string[] = [];
@@ -90,13 +117,13 @@ function emitRowStruct(query: AnalyzedQuery): string[] {
90
117
  for (const column of query.columns) {
91
118
  const opt = column.nullable ? '?' : '';
92
119
  lines.push(
93
- ` public let ${camelCase(column.name)}: ${SWIFT_TYPE[column.type]}${opt}`,
120
+ ` public let ${camelCase(column.langName)}: ${SWIFT_TYPE[column.type]}${opt}`,
94
121
  );
95
122
  }
96
123
  lines.push('');
97
124
  lines.push(' public init?(row: [String: JSONValue]) {');
98
125
  for (const column of query.columns) {
99
- const name = camelCase(column.name);
126
+ const name = camelCase(column.langName);
100
127
  const accessor = rowAccessor(column);
101
128
  if (column.nullable) {
102
129
  lines.push(` self.${name} = ${accessor}`);
@@ -110,38 +137,86 @@ function emitRowStruct(query: AnalyzedQuery): string[] {
110
137
  return lines;
111
138
  }
112
139
 
140
+ /** Per-query orderBy allowlist enum (rawValue = the checked SQL column). */
141
+ function emitOrderByEnum(query: AnalyzedQuery): string[] {
142
+ if (query.orderBy === undefined) return [];
143
+ const lines: string[] = [];
144
+ lines.push(
145
+ `/// §6 orderBy allowlist for ${query.name} — checked at generate time.`,
146
+ );
147
+ lines.push(`public enum ${typeName(query.name)}OrderBy: String, Sendable {`);
148
+ for (const col of query.orderBy.allowed) {
149
+ lines.push(` case ${camelCase(col.langName)} = ${quote(col.name)}`);
150
+ }
151
+ lines.push('}');
152
+ return lines;
153
+ }
154
+
113
155
  function emitRunner(query: AnalyzedQuery): string[] {
114
156
  const Row = `${typeName(query.name)}Row`;
115
157
  const lines: string[] = [];
116
158
  lines.push(
117
159
  ` public static let ${query.name}Tables = [${query.tables.map(quote).join(', ')}]`,
118
160
  );
119
- lines.push(
120
- ` private static let ${query.name}Sql = ${quote(query.positionalSql)}`,
121
- );
161
+ if (query.orderBy !== undefined) {
162
+ lines.push(
163
+ ` private static let ${query.name}SqlBase = ${quote(query.positionalSqlBase ?? '')}`,
164
+ );
165
+ } else {
166
+ lines.push(
167
+ ` private static let ${query.name}Sql = ${quote(query.positionalSql)}`,
168
+ );
169
+ }
122
170
  lines.push('');
123
171
  lines.push(` /// Run the ${query.name} named query (SELECT-only).`);
124
- const args = query.params
125
- .map((p) => `${camelCase(p.name)}: ${SWIFT_TYPE[p.type]}`)
126
- .join(', ');
172
+ const args: string[] = [];
173
+ for (const p of query.params) {
174
+ const name = camelCase(p.langName);
175
+ if (isOptionalParam(query, p)) {
176
+ args.push(`${name}: ${SWIFT_TYPE[p.type]}? = nil`);
177
+ } else {
178
+ args.push(`${name}: ${SWIFT_TYPE[p.type]}`);
179
+ }
180
+ }
181
+ if (query.orderBy !== undefined) {
182
+ const defaultCase = camelCase(
183
+ query.orderBy.allowed.find((c) => c.name === query.orderBy?.defaultColumn)
184
+ ?.langName ?? query.orderBy.defaultColumn,
185
+ );
186
+ args.push(`orderBy: ${typeName(query.name)}OrderBy = .${defaultCase}`);
187
+ args.push(`dir: SyncularQueryDir = .${query.orderBy.defaultDir}`);
188
+ }
127
189
  const signature =
128
- query.params.length > 0
129
- ? `client: SyncularClient, ${args}`
190
+ args.length > 0
191
+ ? `client: SyncularClient, ${args.join(', ')}`
130
192
  : 'client: SyncularClient';
131
193
  lines.push(
132
194
  ` public static func ${query.name}(${signature}) throws -> [${Row}] {`,
133
195
  );
196
+ const sqlExpr =
197
+ query.orderBy !== undefined
198
+ ? `${query.name}SqlBase + " order by " + orderBy.rawValue + " " + dir.rawValue${query.positionalLimitTail !== undefined ? ` + ${quote(query.positionalLimitTail)}` : ''}`
199
+ : `${query.name}Sql`;
200
+ if (query.orderBy !== undefined) {
201
+ lines.push(` let sql = ${sqlExpr}`);
202
+ }
203
+ const sqlRef = query.orderBy !== undefined ? 'sql' : `${query.name}Sql`;
134
204
  if (query.params.length > 0) {
135
205
  const binds = query.params
136
- .map((p) => paramValue(p.type, camelCase(p.name)))
206
+ .map((p) => {
207
+ const name = camelCase(p.langName);
208
+ return isOptionalParam(query, p)
209
+ ? optionalParamValue(p.type, name)
210
+ : paramValue(p.type, name);
211
+ })
137
212
  .join(', ');
138
213
  lines.push(` let params: [JSONValue] = [${binds}]`);
139
214
  lines.push(
140
- ` return try client.query(${query.name}Sql, params: params).compactMap { row in`,
215
+ ` return try client.query(${sqlRef}, params: params).compactMap { row in`,
141
216
  );
142
217
  } else {
143
218
  lines.push(
144
- ` return try client.query(${query.name}Sql).compactMap { row in`,
219
+ ` return try client.query(${sqlRef}).compactMap { row in`,
145
220
  );
146
221
  }
147
222
  lines.push(
@@ -183,8 +258,22 @@ export function emitQueriesSwiftModule(
183
258
  ].join('\n'),
184
259
  );
185
260
 
261
+ if (queries.some((q) => q.orderBy !== undefined)) {
262
+ parts.push(
263
+ [
264
+ '/// §6 orderBy direction (shared by every orderBy-knob query).',
265
+ 'public enum SyncularQueryDir: String, Sendable {',
266
+ ' case asc = "asc"',
267
+ ' case desc = "desc"',
268
+ '}',
269
+ ].join('\n'),
270
+ );
271
+ }
272
+
186
273
  for (const query of queries) {
187
274
  parts.push(emitRowStruct(query).join('\n'));
275
+ const orderByEnum = emitOrderByEnum(query);
276
+ if (orderByEnum.length > 0) parts.push(orderByEnum.join('\n'));
188
277
  }
189
278
 
190
279
  const enumLines: string[] = [];
@@ -4,12 +4,18 @@
4
4
  *
5
5
  * - `QRow` — the projection row interface (its OWN type per query — the
6
6
  * drift-kill: the row shape is exactly what the SELECT returns),
7
- * - `QParams` — the typed params object (when the query has params),
8
- * - `qTables` the readonly table-dependency set (feeds useSyncQuery `{tables}`
7
+ * - `QParams` — the typed params object (when the query has params or
8
+ * knobs); §4 optional params are optional keys (`status?: string | null`),
9
+ * §6 knobs add `orderBy?/dir?/limit?`,
10
+ * - `qTables` — the readonly table-dependency set (feeds useRawSql `{tables}`
9
11
  * for EXACT invalidation),
10
12
  * - `q(client, params?): Promise<QRow[]>` — runs the query over the wrapper's
11
13
  * positional `query(sql, params[])` surface, reordering the named params
12
- * object into the positional array the wire expects.
14
+ * object into the positional array the wire expects (optionals bind NULL —
15
+ * the §7 neutralization guards make an absent param a no-op),
16
+ * - for an orderBy knob: a baked column map + a compose function — user
17
+ * input only ever SELECTS from the generate-time-checked allowlist (I2);
18
+ * it never becomes SQL text.
13
19
  *
14
20
  * A tiny structural `QueryClient` interface (just `query(sql, params?)`) keeps
15
21
  * the module import-free — it structurally accepts `SyncClientLike` /
@@ -17,7 +23,7 @@
17
23
  * `--check` gates freshness byte-exactly, like every other emitter.
18
24
  */
19
25
  import type { IrColumnType } from './ir';
20
- import type { AnalyzedQuery } from './query';
26
+ import type { AnalyzedQuery, QueryParam } from './query';
21
27
 
22
28
  const TS_TYPE: Readonly<Record<IrColumnType, string>> = {
23
29
  string: 'string',
@@ -46,81 +52,223 @@ function propertyKey(name: string): string {
46
52
  /** A named-query param value is the SqlValue subset its type maps to. */
47
53
  const PARAM_TS_TYPE: Readonly<Record<IrColumnType, string>> = TS_TYPE;
48
54
 
55
+ function isOptional(param: QueryParam): boolean {
56
+ return param.optional === true || param.flag === true;
57
+ }
58
+
59
+ /** The positional bind expression for one param. `access` is `params` or
60
+ * `params?` depending on whether the params object itself may be absent. */
61
+ function bindExpr(
62
+ query: AnalyzedQuery,
63
+ param: QueryParam,
64
+ access: string,
65
+ ): string {
66
+ if (query.limit !== undefined && param.name === 'limit') {
67
+ // The default + clamp live IN the SQL (`min(coalesce(?, d), m)`).
68
+ return `${access}.limit ?? null`;
69
+ }
70
+ const key = propertyKey(param.langName);
71
+ return isOptional(param) ? `${access}.${key} ?? null` : `params.${key}`;
72
+ }
73
+
49
74
  function emitQuery(query: AnalyzedQuery): string {
50
75
  const Row = `${pascalCase(query.name)}Row`;
51
76
  const Params = `${pascalCase(query.name)}Params`;
52
77
  const lines: string[] = [];
53
78
 
54
- // Row interface.
79
+ // Row interface — keyed by the language-facing names, which ARE the
80
+ // runtime result keys (§5 projection lowering aliases them in SQL).
55
81
  lines.push(
56
82
  `/** One row of the ${quote(query.name)} query (its projection). */`,
57
83
  );
58
84
  lines.push(`export interface ${Row} {`);
59
85
  for (const column of query.columns) {
60
86
  lines.push(
61
- ` ${propertyKey(column.name)}: ${TS_TYPE[column.type]}${column.nullable ? ' | null' : ''};`,
87
+ ` ${propertyKey(column.langName)}: ${TS_TYPE[column.type]}${column.nullable ? ' | null' : ''};`,
62
88
  );
63
89
  }
64
90
  lines.push('}');
65
91
  lines.push('');
66
92
 
67
- // Params interface (only when there are params).
68
- const hasParams = query.params.length > 0;
93
+ const hasParams = query.params.length > 0 || query.orderBy !== undefined;
94
+ const requiresParams = query.params.some(
95
+ (p) => !isOptional(p) && !(query.limit !== undefined && p.name === 'limit'),
96
+ );
97
+
98
+ // Params interface (params and/or knob keys).
69
99
  if (hasParams) {
70
100
  lines.push(`/** Named parameters for ${quote(query.name)}. */`);
71
101
  lines.push(`export interface ${Params} {`);
72
102
  for (const param of query.params) {
73
- lines.push(` ${propertyKey(param.name)}: ${PARAM_TS_TYPE[param.type]};`);
103
+ if (query.limit !== undefined && param.name === 'limit') continue;
104
+ const opt = isOptional(param);
105
+ lines.push(
106
+ ` ${propertyKey(param.langName)}${opt ? '?' : ''}: ${PARAM_TS_TYPE[param.type]}${opt ? ' | null' : ''};`,
107
+ );
108
+ }
109
+ if (query.orderBy !== undefined) {
110
+ const keys = query.orderBy.allowed
111
+ .map((c) => quote(c.langName))
112
+ .join(' | ');
113
+ lines.push(
114
+ ` /** §6 orderBy knob — a generate-time-checked allowlist. */`,
115
+ );
116
+ lines.push(` orderBy?: ${keys};`);
117
+ lines.push(` dir?: 'asc' | 'desc';`);
118
+ }
119
+ if (query.limit !== undefined) {
120
+ const clamp =
121
+ query.limit.max !== undefined ? ` (clamped to ${query.limit.max})` : '';
122
+ lines.push(
123
+ ` /** §6 limit knob — binds as a value${clamp}; default ${query.limit.default ?? query.limit.max}. */`,
124
+ );
125
+ lines.push(' limit?: number;');
74
126
  }
75
127
  lines.push('}');
76
128
  lines.push('');
77
129
  }
78
130
 
79
- // Tables dependency set (for useSyncQuery {tables} / useNamedQuery).
131
+ // Tables dependency set (for useRawSql {tables} / useQuery).
80
132
  lines.push(
81
- `/** Tables ${quote(query.name)} reads — the exact useSyncQuery \`{tables}\` set. */`,
133
+ `/** Tables ${quote(query.name)} reads — the exact useRawSql \`{tables}\` set. */`,
82
134
  );
83
135
  lines.push(
84
136
  `export const ${query.name}Tables = [${query.tables.map(quote).join(', ')}] as const;`,
85
137
  );
86
138
  lines.push('');
87
139
 
88
- // The runner. SQL is the positional form; params reorder into the array.
89
- const paramArg = hasParams ? `params: ${Params}` : '';
140
+ // SQL constants: the static default statement, plus (orderBy knob) the
141
+ // baked base + column map + compose function.
90
142
  const sqlConst = `${query.name}Sql`;
143
+ lines.push(`const ${sqlConst} = ${quote(query.positionalSql)};`);
144
+ const composeFn = `${query.name}ComposeSql`;
145
+ if (query.orderBy !== undefined) {
146
+ const base = `${query.name}SqlBase`;
147
+ const colsConst = `${query.name}OrderColumns`;
148
+ const defaultLang =
149
+ query.orderBy.allowed.find((c) => c.name === query.orderBy?.defaultColumn)
150
+ ?.langName ?? query.orderBy.defaultColumn;
151
+ lines.push(`const ${base} = ${quote(query.positionalSqlBase ?? '')};`);
152
+ lines.push(
153
+ `const ${colsConst} = { ${query.orderBy.allowed
154
+ .map((c) => `${propertyKey(c.langName)}: ${quote(c.name)}`)
155
+ .join(', ')} } as const;`,
156
+ );
157
+ lines.push(`function ${composeFn}(params?: ${Params}): string {`);
158
+ lines.push(
159
+ ` const column = ${colsConst}[params?.orderBy ?? ${quote(defaultLang)}] ?? ${quote(query.orderBy.defaultColumn)};`,
160
+ );
161
+ lines.push(
162
+ ` const dir = (params?.dir ?? ${quote(query.orderBy.defaultDir)}) === 'desc' ? 'desc' : 'asc';`,
163
+ );
164
+ lines.push(
165
+ ` return \`\${${base}} order by \${column} \${dir}${query.positionalLimitTail ?? ''}\`;`,
166
+ );
167
+ lines.push('}');
168
+ }
169
+
170
+ // Positional bind list.
171
+ const access = requiresParams ? 'params' : 'params?';
91
172
  const positional = hasParams
92
- ? `[${query.params.map((p) => `params.${propertyKey(p.name)}`).join(', ')}]`
173
+ ? `[${query.params.map((p) => bindExpr(query, p, access)).join(', ')}]`
93
174
  : '[]';
94
- lines.push(`const ${sqlConst} = ${quote(query.positionalSql)};`);
175
+
176
+ // §7 variant backend: one statement per provided-combination, selected by
177
+ // a bitmask over the optional groups (bit i = group i provided/true). The
178
+ // neutralization statement above stays the canonical/default form.
179
+ const selectFn = `${query.name}SelectVariant`;
180
+ const paramArgDecl = requiresParams
181
+ ? `params: ${Params}`
182
+ : `params?: ${Params}`;
183
+ if (query.variants !== undefined && query.variantGroups !== undefined) {
184
+ const variantsConst = `${query.name}Variants`;
185
+ lines.push(
186
+ `const ${variantsConst}: { sql: string; bind: (${paramArgDecl}) => QueryValue[] }[] = [`,
187
+ );
188
+ for (const variant of query.variants) {
189
+ const binds = variant.params
190
+ .map((name) => {
191
+ const param = query.params.find((p) => p.name === name);
192
+ if (param === undefined) throw new Error(`unknown param ${name}`);
193
+ return bindExpr(query, param, access);
194
+ })
195
+ .join(', ');
196
+ lines.push(` // [${variant.when.join(' + ') || 'no optional filters'}]`);
197
+ lines.push(
198
+ ` { sql: ${quote(variant.positionalSql)}, bind: (${requiresParams ? 'params' : 'params?'}) => [${binds}] },`,
199
+ );
200
+ }
201
+ lines.push('];');
202
+ lines.push(
203
+ `function ${selectFn}(${paramArgDecl}): { sql: string; bind: QueryValue[] } {`,
204
+ );
205
+ lines.push(' let mask = 0;');
206
+ query.variantGroups.forEach((group, index) => {
207
+ const langOf = (name: string): string =>
208
+ query.params.find((p) => p.name === name)?.langName ?? name;
209
+ const condition = group.flag
210
+ ? `params?.${propertyKey(langOf(group.params[0] as string))} === true`
211
+ : group.params
212
+ .map(
213
+ (name) =>
214
+ `(params?.${propertyKey(langOf(name))} ?? null) !== null`,
215
+ )
216
+ .join(' && ');
217
+ lines.push(` if (${condition}) mask |= ${1 << index};`);
218
+ });
219
+ lines.push(
220
+ ` const variant = ${variantsConst}[mask] as (typeof ${variantsConst})[number];`,
221
+ );
222
+ lines.push(' return { sql: variant.sql, bind: variant.bind(params) };');
223
+ lines.push('}');
224
+ }
95
225
  lines.push('');
226
+
227
+ // The runner.
228
+ const paramArg = !hasParams ? '' : paramArgDecl;
96
229
  lines.push(`/** Run the ${quote(query.name)} named query (SELECT-only). */`);
97
230
  lines.push(
98
231
  `export async function ${query.name}(client: QueryClient${hasParams ? `, ${paramArg}` : ''}): Promise<${Row}[]> {`,
99
232
  );
100
- if (hasParams) {
101
- lines.push(
102
- ` const rows = await client.query(${sqlConst}, ${positional});`,
103
- );
233
+ if (query.variants !== undefined) {
234
+ lines.push(` const variant = ${selectFn}(params);`);
235
+ lines.push(' const rows = await client.query(variant.sql, variant.bind);');
104
236
  } else {
105
- lines.push(` const rows = await client.query(${sqlConst});`);
237
+ const sqlExpr =
238
+ query.orderBy !== undefined ? `${composeFn}(params)` : sqlConst;
239
+ if (hasParams) {
240
+ lines.push(
241
+ ` const rows = await client.query(${sqlExpr}, ${positional});`,
242
+ );
243
+ } else {
244
+ lines.push(` const rows = await client.query(${sqlExpr});`);
245
+ }
106
246
  }
107
247
  lines.push(` return rows as unknown as ${Row}[];`);
108
248
  lines.push('}');
109
249
  lines.push('');
110
250
 
111
- // A descriptor for react's `useNamedQuery` — the SQL, the exact table
112
- // dependency set, and a `bind(params)` positional array. Typed by the
113
- // query's own Row/Params so the hook stays fully typed.
251
+ // A descriptor for react's `useQuery` — the SQL (static, composed from
252
+ // the baked allowlist, or variant-selected), the exact table dependency
253
+ // set, and a `bind(params)` positional array (always paired with the
254
+ // sqlFor selection). Typed by the query's own Row/Params.
114
255
  lines.push(
115
- `/** Descriptor for \`useNamedQuery(${query.name}Query${hasParams ? ', params' : ''})\` — sql + tables + row type. */`,
256
+ `/** Descriptor for \`useQuery(${query.name}Query${hasParams ? ', params' : ''})\` — sql + tables + row type. */`,
116
257
  );
117
258
  const paramsTypeArg = hasParams ? Params : 'undefined';
118
259
  lines.push(
119
260
  `export const ${query.name}Query: NamedQuery<${Row}, ${paramsTypeArg}> = {`,
120
261
  );
121
262
  lines.push(` sql: ${sqlConst},`);
263
+ if (query.variants !== undefined) {
264
+ lines.push(` sqlFor: (params: ${Params}) => ${selectFn}(params).sql,`);
265
+ } else if (query.orderBy !== undefined) {
266
+ lines.push(` sqlFor: (params: ${Params}) => ${composeFn}(params),`);
267
+ }
122
268
  lines.push(` tables: ${query.name}Tables,`);
123
- if (hasParams) {
269
+ if (query.variants !== undefined) {
270
+ lines.push(` bind: (params: ${Params}) => ${selectFn}(params).bind,`);
271
+ } else if (hasParams) {
124
272
  lines.push(` bind: (params: ${Params}) => ${positional},`);
125
273
  } else {
126
274
  lines.push(' bind: () => [],');
@@ -165,13 +313,16 @@ export function emitQueriesModule(
165
313
  '',
166
314
  '/** A named-query descriptor — sql + its exact table dependency set + a',
167
315
  ' * `bind(params)` → positional args. Consumed by',
168
- " * `@syncular/react`'s `useNamedQuery`. `Row` is the projection row",
169
- ' * type; `Params` is `undefined` for a param-less query. */',
316
+ " * `@syncular/react`'s `useQuery`. `Row` is the projection row",
317
+ ' * type; `Params` is `undefined` for a param-less query. `sqlFor`',
318
+ ' * (present only with an orderBy knob) composes the statement from a',
319
+ ' * generate-time-checked column allowlist. */',
170
320
  'export interface NamedQuery<Row, Params = undefined> {',
171
321
  ' readonly sql: string;',
172
322
  ' readonly tables: readonly string[];',
173
323
  ' readonly bind: (params: Params) => readonly QueryValue[];',
174
- ' /** Phantom carries the Row type for `useNamedQuery` inference. */',
324
+ ' readonly sqlFor?: (params: Params) => string;',
325
+ ' /** Phantom — carries the Row type for `useQuery` inference. */',
175
326
  ' readonly __row?: Row;',
176
327
  '}',
177
328
  ].join('\n'),
package/src/emit-swift.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  * emitter.
16
16
  */
17
17
  import type { IrColumnType, IrDocument, IrSubscription, IrTable } from './ir';
18
+ import { snakeToCamel } from './naming';
18
19
 
19
20
  /**
20
21
  * §2.4 column type → honest Swift type. `json`/`blob_ref` are the raw
@@ -49,9 +50,9 @@ function pascalCase(name: string): string {
49
50
  .join('');
50
51
  }
51
52
 
53
+ /** Language-facing field name — the pinned §12 naming map. */
52
54
  function camelCase(name: string): string {
53
- const pascal = pascalCase(name);
54
- return pascal.charAt(0).toLowerCase() + pascal.slice(1);
55
+ return snakeToCamel(name);
55
56
  }
56
57
 
57
58
  function quote(value: string): string {
package/src/emit.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  * byte-exactly.
14
14
  */
15
15
  import type { IrColumnType, IrDocument, IrSubscription, IrTable } from './ir';
16
+ import { type NamingMode, snakeToCamel } from './naming';
16
17
 
17
18
  const TS_TYPE: Readonly<Record<IrColumnType, string>> = {
18
19
  string: 'string',
@@ -114,15 +115,20 @@ function emitSchema(ir: IrDocument): string {
114
115
  return lines.join('\n');
115
116
  }
116
117
 
117
- function emitRowInterfaces(table: IrTable): string {
118
+ function emitRowInterfaces(table: IrTable, naming: NamingMode): string {
118
119
  const type = pascalCase(table.name);
120
+ // §5: mutation-input/row keys are the language-facing names; `mutate`
121
+ // normalizes them back through the schema naming map (camel or snake both
122
+ // bind — one map lookup per key).
123
+ const key = (name: string): string =>
124
+ propertyKey(naming === 'camel' ? snakeToCamel(name) : name);
119
125
  const lines: string[] = [];
120
126
  lines.push(`/** One ${table.name} row (§2.4 column order). */`);
121
127
  lines.push(`export interface ${type}Row {`);
122
128
  for (const column of table.columns) {
123
129
  const ts = appTsType(column);
124
130
  lines.push(
125
- ` ${propertyKey(column.name)}: ${ts}${column.nullable ? ' | null' : ''};`,
131
+ ` ${key(column.name)}: ${ts}${column.nullable ? ' | null' : ''};`,
126
132
  );
127
133
  }
128
134
  lines.push('}');
@@ -133,8 +139,8 @@ function emitRowInterfaces(table: IrTable): string {
133
139
  const ts = appTsType(column);
134
140
  lines.push(
135
141
  column.nullable
136
- ? ` ${propertyKey(column.name)}?: ${ts} | null;`
137
- : ` ${propertyKey(column.name)}: ${ts};`,
142
+ ? ` ${key(column.name)}?: ${ts} | null;`
143
+ : ` ${key(column.name)}: ${ts};`,
138
144
  );
139
145
  }
140
146
  lines.push('}');
@@ -146,10 +152,10 @@ function emitRowInterfaces(table: IrTable): string {
146
152
  for (const column of table.columns) {
147
153
  const ts = appTsType(column);
148
154
  if (column.name === table.primaryKey) {
149
- lines.push(` ${propertyKey(column.name)}: ${ts};`);
155
+ lines.push(` ${key(column.name)}: ${ts};`);
150
156
  } else {
151
157
  lines.push(
152
- ` ${propertyKey(column.name)}?: ${ts}${column.nullable ? ' | null' : ''};`,
158
+ ` ${key(column.name)}?: ${ts}${column.nullable ? ' | null' : ''};`,
153
159
  );
154
160
  }
155
161
  }
@@ -157,25 +163,6 @@ function emitRowInterfaces(table: IrTable): string {
157
163
  return lines.join('\n');
158
164
  }
159
165
 
160
- /**
161
- * A Kysely-compatible `Database` interface: table name → Row type. This is
162
- * the generic `@syncular/kysely`'s `SyncularDialect` is parameterized by
163
- * (`new Kysely<Database>({ dialect })`). Additive — the Row interfaces it
164
- * references are already emitted above. Property keys use the raw table name
165
- * (quoted when not an identifier) so `db.selectFrom('table-name')` types.
166
- */
167
- function emitDatabaseInterface(ir: IrDocument): string {
168
- const lines: string[] = [];
169
- lines.push('/** Kysely `Database` interface (table → Row); the generic for');
170
- lines.push(" * @syncular/kysely's SyncularDialect. */");
171
- lines.push('export interface Database {');
172
- for (const table of ir.tables) {
173
- lines.push(` ${propertyKey(table.name)}: ${pascalCase(table.name)}Row;`);
174
- }
175
- lines.push('}');
176
- return lines.join('\n');
177
- }
178
-
179
166
  function emitSubscription(sub: IrSubscription): string {
180
167
  const params: string[] = [];
181
168
  for (const scope of sub.scopes) {
@@ -218,7 +205,11 @@ function emitSubscription(sub: IrSubscription): string {
218
205
  return lines.join('\n');
219
206
  }
220
207
 
221
- export function emitModule(ir: IrDocument, hash: string): string {
208
+ export function emitModule(
209
+ ir: IrDocument,
210
+ hash: string,
211
+ naming: NamingMode = 'camel',
212
+ ): string {
222
213
  const parts: string[] = [];
223
214
  parts.push(
224
215
  [
@@ -229,9 +220,8 @@ export function emitModule(ir: IrDocument, hash: string): string {
229
220
  );
230
221
  parts.push(emitSchema(ir));
231
222
  for (const table of ir.tables) {
232
- parts.push(emitRowInterfaces(table));
223
+ parts.push(emitRowInterfaces(table, naming));
233
224
  }
234
- parts.push(emitDatabaseInterface(ir));
235
225
  for (const sub of ir.subscriptions) {
236
226
  parts.push(emitSubscription(sub));
237
227
  }