@syncular/typegen 0.1.2 → 0.2.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.
Files changed (99) hide show
  1. package/README.md +436 -94
  2. package/dist/cli.d.ts +1 -2
  3. package/dist/cli.js +159 -73
  4. package/dist/emit-dart.d.ts +17 -0
  5. package/dist/emit-dart.js +201 -0
  6. package/dist/emit-kotlin.d.ts +16 -0
  7. package/dist/emit-kotlin.js +201 -0
  8. package/dist/emit-queries-dart.d.ts +2 -0
  9. package/dist/emit-queries-dart.js +160 -0
  10. package/dist/emit-queries-kotlin.d.ts +2 -0
  11. package/dist/emit-queries-kotlin.js +162 -0
  12. package/dist/emit-queries-swift.d.ts +2 -0
  13. package/dist/emit-queries-swift.js +146 -0
  14. package/dist/emit-queries.d.ts +2 -0
  15. package/dist/emit-queries.js +129 -0
  16. package/dist/emit-swift.d.ts +18 -0
  17. package/dist/emit-swift.js +235 -0
  18. package/dist/emit.d.ts +16 -0
  19. package/dist/emit.js +191 -0
  20. package/dist/errors.d.ts +6 -0
  21. package/dist/errors.js +10 -0
  22. package/dist/generate.d.ts +53 -18
  23. package/dist/generate.js +391 -77
  24. package/dist/index.d.ts +17 -14
  25. package/dist/index.js +16 -13
  26. package/dist/init.d.ts +12 -0
  27. package/dist/init.js +85 -0
  28. package/dist/ir.d.ts +90 -0
  29. package/dist/ir.js +94 -0
  30. package/dist/manifest.d.ts +76 -0
  31. package/dist/manifest.js +303 -0
  32. package/dist/query.d.ts +96 -0
  33. package/dist/query.js +719 -0
  34. package/dist/sql.d.ts +13 -0
  35. package/dist/sql.js +440 -0
  36. package/package.json +14 -34
  37. package/src/cli.ts +161 -82
  38. package/src/emit-dart.ts +245 -0
  39. package/src/emit-kotlin.ts +257 -0
  40. package/src/emit-queries-dart.ts +203 -0
  41. package/src/emit-queries-kotlin.ts +209 -0
  42. package/src/emit-queries-swift.ts +198 -0
  43. package/src/emit-queries.ts +183 -0
  44. package/src/emit-swift.ts +295 -0
  45. package/src/emit.ts +239 -0
  46. package/src/errors.ts +11 -0
  47. package/src/generate.ts +574 -105
  48. package/src/index.ts +16 -13
  49. package/src/init.ts +100 -0
  50. package/src/ir.ts +197 -0
  51. package/src/manifest.ts +445 -0
  52. package/src/query.ts +918 -0
  53. package/src/sql.ts +533 -0
  54. package/dist/app-contract.d.ts +0 -154
  55. package/dist/app-contract.d.ts.map +0 -1
  56. package/dist/app-contract.js +0 -250
  57. package/dist/app-contract.js.map +0 -1
  58. package/dist/checksums.d.ts +0 -6
  59. package/dist/checksums.d.ts.map +0 -1
  60. package/dist/checksums.js +0 -173
  61. package/dist/checksums.js.map +0 -1
  62. package/dist/cli.d.ts.map +0 -1
  63. package/dist/cli.js.map +0 -1
  64. package/dist/generate.d.ts.map +0 -1
  65. package/dist/generate.js.map +0 -1
  66. package/dist/index.d.ts.map +0 -1
  67. package/dist/index.js.map +0 -1
  68. package/dist/introspect-postgres.d.ts +0 -8
  69. package/dist/introspect-postgres.d.ts.map +0 -1
  70. package/dist/introspect-postgres.js +0 -94
  71. package/dist/introspect-postgres.js.map +0 -1
  72. package/dist/introspect-sqlite.d.ts +0 -10
  73. package/dist/introspect-sqlite.d.ts.map +0 -1
  74. package/dist/introspect-sqlite.js +0 -97
  75. package/dist/introspect-sqlite.js.map +0 -1
  76. package/dist/introspect.d.ts +0 -8
  77. package/dist/introspect.d.ts.map +0 -1
  78. package/dist/introspect.js +0 -18
  79. package/dist/introspect.js.map +0 -1
  80. package/dist/map-types.d.ts +0 -20
  81. package/dist/map-types.d.ts.map +0 -1
  82. package/dist/map-types.js +0 -151
  83. package/dist/map-types.js.map +0 -1
  84. package/dist/render.d.ts +0 -23
  85. package/dist/render.d.ts.map +0 -1
  86. package/dist/render.js +0 -140
  87. package/dist/render.js.map +0 -1
  88. package/dist/types.d.ts +0 -122
  89. package/dist/types.d.ts.map +0 -1
  90. package/dist/types.js +0 -5
  91. package/dist/types.js.map +0 -1
  92. package/src/app-contract.ts +0 -531
  93. package/src/checksums.ts +0 -257
  94. package/src/introspect-postgres.ts +0 -149
  95. package/src/introspect-sqlite.ts +0 -156
  96. package/src/introspect.ts +0 -36
  97. package/src/map-types.ts +0 -189
  98. package/src/render.ts +0 -196
  99. package/src/types.ts +0 -137
package/README.md CHANGED
@@ -1,120 +1,462 @@
1
1
  # @syncular/typegen
2
2
 
3
- Generate TypeScript database types from your migrations.
3
+ SQL migrations + one manifest neutral schema IR (JSON) → generated TS
4
+ module (REVISE B5). This file is the authoritative contract for the three
5
+ tool-level formats: the manifest, the IR, and the SQL subset. Wire-protocol
6
+ semantics (column types, scope patterns, schema-version gating) live in
7
+ [`../../SPEC.md`](../../SPEC.md) §2.4, §3.1, §1.5 — this document never
8
+ overrides it.
4
9
 
5
- Supports SQLite and Postgres introspection with column-level codec type overrides via `codecs`.
10
+ ```sh
11
+ syncular generate [--manifest-dir <dir>] [--check] [--watch]
12
+ syncular init [--manifest-dir <dir>]
13
+ ```
6
14
 
7
- It also includes build-time helpers for authoring the Rust-first Syncular app
8
- contract and serializing it to the generated Rust-codegen handoff.
15
+ `generate` reads `<dir>/syncular.json` plus its migrations directory and
16
+ writes the IR JSON and the generated TS module. `--check` regenerates in
17
+ memory and exits 1 unless both files on disk match **byte-exactly** (this is
18
+ the freshness contract; it is unchanged). `--watch` regenerates on any change
19
+ under the manifest dir (Bun's recursive `fs.watch`, debounced; it skips the
20
+ write when outputs are already fresh so it never loops on its own output).
21
+ `--check` and `--watch` are mutually exclusive.
9
22
 
10
- ## Install
23
+ `init` scaffolds a starter `syncular.json` + `migrations/0001_initial/up.sql`
24
+ into an existing project (the "add syncular to my app" path). It refuses to
25
+ overwrite an existing manifest or first migration. New projects usually start
26
+ from the scaffolder instead (`bun create syncular-app my-app`), which emits the
27
+ same shape plus a server + client.
11
28
 
12
- ```bash
13
- npm install @syncular/typegen
14
- ```
29
+ When `generate` cannot find the manifest or migrations, the error points at the
30
+ schema guide and suggests `syncular init`.
15
31
 
16
- ## Usage
32
+ ---
17
33
 
18
- ```ts
19
- import { codecs } from '@syncular/core';
20
- import { generateTypes } from '@syncular/typegen';
21
- import { migrations } from './migrations';
22
-
23
- await generateTypes({
24
- migrations,
25
- output: './src/db.generated.ts',
26
- dialect: 'postgres',
27
- codecs: (col) => {
28
- if (col.table === 'events' && col.column === 'payload') {
29
- return codecs.stringJson({
30
- import: { name: 'EventPayload', from: './domain' },
31
- });
34
+ ## 1. Manifest reference (`syncular.json`)
35
+
36
+ ```json
37
+ {
38
+ "manifestVersion": 1,
39
+ "migrations": "./migrations",
40
+ "output": { "ir": "./syncular.ir.json", "module": "./syncular.generated.ts" },
41
+ "schemaVersions": [
42
+ { "version": 1, "through": "0001_initial" },
43
+ { "version": 2, "through": "0002_add_task_estimate" }
44
+ ],
45
+ "tables": [
46
+ { "name": "tasks", "scopes": ["project:{project_id}"] },
47
+ {
48
+ "name": "docs",
49
+ "scopes": [
50
+ "org:{org_id}",
51
+ { "pattern": "project:{projectId}", "column": "project_id" }
52
+ ]
32
53
  }
33
- if (
34
- col.table === 'events' &&
35
- col.column === 'is_active' &&
36
- col.sqlType?.toLowerCase().includes('int')
37
- ) {
38
- return codecs.numberBoolean();
54
+ ],
55
+ "subscriptions": [
56
+ {
57
+ "name": "projectTasks",
58
+ "table": "tasks",
59
+ "scopes": { "project_id": ["{projectId}"] }
39
60
  }
40
- return undefined;
41
- },
42
- });
61
+ ],
62
+ "extensions": {}
63
+ }
43
64
  ```
44
65
 
45
- ## App Contract Authoring
66
+ | Key | Required | Semantics |
67
+ |---|---|---|
68
+ | `manifestVersion` | yes | Must be `1`. Format growth happens by bumping this, not by tolerating unknown keys. |
69
+ | `migrations` | no (default `./migrations`) | Directory of `NNNN_name/up.sql` migrations, relative to the manifest. |
70
+ | `queries` | no (default `./queries`) | Directory of `.sql` named-query files (see §6). Only read when some output requests a queries file. |
71
+ | `output.ir` | no (default `./syncular.ir.json`) | IR output path, relative to the manifest. |
72
+ | `output.module` | no (default `./syncular.generated.ts`) | Generated TS module path, relative to the manifest. |
73
+ | `output.queries` | no | Opt-in TS named-queries output path (see §6). A sibling `.ts` file. |
74
+ | `output.swift` | no | Opt-in Swift emitter (see §5). A path string, or `{ "path", "enumName", "queriesPath" }` (default `enumName` `SyncularSchema`; `queriesPath` opts into §6 Swift named queries). |
75
+ | `output.kotlin` | no | Opt-in Kotlin emitter (see §5). A path string, or `{ "path", "package", "objectName", "queriesPath" }` (defaults `syncular.generated` / `SyncularSchema`). |
76
+ | `output.dart` | no | Opt-in Dart emitter (see §5). A path string, or `{ "path", "queriesPath" }`. |
77
+ | `schemaVersions` | yes, non-empty | The §1.5 version history. See below. |
78
+ | `tables` | yes, non-empty | Synced tables. **Array order is the handler-declared bootstrap order (§4.7)** and flows unchanged into the IR and `schema.tables`. |
79
+ | `tables[].name` | yes | Must be created by a migration. |
80
+ | `tables[].scopes` | yes, ≥ 1 | §3.1 scope patterns: `'prefix:{variable}'` (column = variable) or `{ "pattern", "column" }`. The column must exist on the table; one variable must not map to two different columns. |
81
+ | `tables[].extensions` | no (default `{}`) | Opaque passthrough into the IR table's `extensions` slot. |
82
+ | `subscriptions` | no (default `[]`) | Requested-scope templates. `name` must be a valid JS identifier and unique; `table` must be a listed table; every scope key must be a scope variable of that table. |
83
+ | `subscriptions[].scopes` | yes, non-empty | Map of variable → non-empty list of values. Each value is **either** a literal **or** exactly `"{param}"`. Partial templates (`"p-{x}"`) and `"*"` (§3.2 rejects requested wildcards) are hard errors. |
84
+ | `extensions` | no (default `{}`) | Opaque passthrough into the IR document's `extensions` slot. |
46
85
 
47
- ```ts
48
- import {
49
- defineSyncularClient,
50
- scope,
51
- syncedTable,
52
- writeSyncularCodegenJson,
53
- yjsText,
54
- } from '@syncular/typegen';
55
-
56
- export const app = defineSyncularClient({
57
- tables: {
58
- notes: syncedTable({
59
- table: 'notes',
60
- serverVersion: 'server_version',
61
- scopes: [
62
- scope('user_id', {
63
- column: 'owner_user_id',
64
- source: 'actorId',
65
- }),
86
+ Unknown keys are hard errors at every level, except inside `extensions`
87
+ objects, which pass through verbatim.
88
+
89
+ **`schemaVersions` semantics.** Each entry is `{ "version", "through" }`:
90
+ `version` is an integer ≥ 1, strictly increasing across entries; `through`
91
+ names the last migration that version includes. Entries partition the
92
+ migration list in order: version *n* covers every migration after the
93
+ previous entry's `through` up to and including its own. **Gap rule**: the
94
+ final entry's `through` must be the final migration — a migration not
95
+ covered by any version is a hard error (so is a `through` naming a
96
+ missing or out-of-order migration).
97
+
98
+ **Every-migrated-table rule.** Every table created by the migrations must
99
+ appear in `tables`. A migrated-but-unlisted table is a hard error, not a
100
+ silent skip; internal/unsynced tables will be a future manifest flag.
101
+
102
+ ## 2. IR reference (`irVersion` 1)
103
+
104
+ The IR is the language-neutral contract that all emitters (TS today,
105
+ Swift/Kotlin later) consume. No TS types appear in it — column types are
106
+ the six §2.4 names.
107
+
108
+ ```json
109
+ {
110
+ "irVersion": 1,
111
+ "schemaVersion": 2,
112
+ "schemaVersions": [{ "version": 1, "migrations": ["0001_initial"] }],
113
+ "tables": [
114
+ {
115
+ "name": "tasks",
116
+ "primaryKey": "id",
117
+ "columns": [{ "name": "id", "type": "string", "nullable": false }],
118
+ "scopes": [
119
+ {
120
+ "pattern": "project:{project_id}",
121
+ "variable": "project_id",
122
+ "column": "project_id"
123
+ }
66
124
  ],
67
- crdt: {
68
- content: yjsText({ stateColumn: 'content_yjs_state' }),
69
- },
70
- }),
71
- },
72
- });
73
-
74
- await writeSyncularCodegenJson(app, './generated/syncular.codegen.json');
125
+ "extensions": {}
126
+ }
127
+ ],
128
+ "subscriptions": [
129
+ {
130
+ "name": "projectTasks",
131
+ "table": "tasks",
132
+ "scopes": [
133
+ {
134
+ "variable": "project_id",
135
+ "values": [{ "kind": "parameter", "name": "projectId" }]
136
+ }
137
+ ]
138
+ }
139
+ ],
140
+ "extensions": {}
141
+ }
75
142
  ```
76
143
 
77
- For same-shape starter apps, scaffold the initial contract from existing
78
- migrations and then edit the generated authoring/config when client and server
79
- shapes diverge:
144
+ - `schemaVersion` is the current (last) version; `schemaVersions` is the
145
+ full history with the migrations each version added, in application
146
+ order.
147
+ - `tables` is in manifest order (bootstrap order). `columns` is in SQL
148
+ declaration order (CREATE columns, then ADD COLUMNs) — **this is the
149
+ §2.4 row-codec positional order**; emitters must never reorder it.
150
+ - `scopes` entries are pre-resolved: `variable` and `column` are
151
+ materialized so non-TS emitters never re-parse `pattern`.
152
+ - `indexes` (additive, `irVersion` 1) is a per-table array of
153
+ `{ "name", "columns": [...], "unique" }` in declaration order — emitted
154
+ **only** when the table declares at least one index, so index-free tables
155
+ (every pre-index manifest) keep byte-identical IR + generated output. It is a
156
+ client-side/query-check concern; see §3's index note.
157
+ - `subscriptions[].scopes` is sorted by `variable`; each value is
158
+ `{ "kind": "literal", "value" }` or `{ "kind": "parameter", "name" }`.
159
+ - **Determinism**: equal inputs produce byte-identical output — fixed key
160
+ order (as shown above), 2-space indent, LF, trailing newline, extension
161
+ objects recursively key-sorted. The IR file diffs cleanly.
162
+ - **Identity**: the IR's identity is `sha256:<hex>` over the exact IR file
163
+ bytes (UTF-8). Every emitter must stamp this hash into its generated
164
+ output header so `--check`-style freshness verification works per
165
+ language.
166
+ - **`extensions`** (document- and table-level): reserved, opaque
167
+ passthrough from the manifest — the designed-once home for the WP-49
168
+ apply/read-model hooks. Always present, `{}` when unused. Emitters must
169
+ round-trip unknown extension content without interpreting it.
170
+ - **Head-version-only**: `tables` describes the *current* schema version
171
+ only. Per-version table snapshots (for serving multiple row codecs from
172
+ one IR) are a planned additive change under a future `irVersion` bump;
173
+ `schemaVersions` already carries the migration grouping needed to build
174
+ them.
80
175
 
81
- ```ts
82
- import {
83
- scaffoldSyncularClientContract,
84
- scope,
85
- writeSyncularCodegenJson,
86
- } from '@syncular/typegen';
87
- import { migrations } from './migrations';
88
-
89
- const app = await scaffoldSyncularClientContract({
90
- migrations,
91
- scopes: {
92
- tasks: [scope('user_id', { source: 'actorId', required: true })],
93
- },
94
- });
95
-
96
- await writeSyncularCodegenJson(app, './generated/syncular.codegen.json');
97
- ```
176
+ ## 3. SQL subset
177
+
178
+ Migrations live in `NNNN_name/up.sql` (numeric prefix orders them;
179
+ duplicate ordinals are errors). The parser accepts exactly:
180
+
181
+ - `CREATE TABLE [IF NOT EXISTS] name ( column-defs…, [PRIMARY KEY (col)] ) [WITHOUT ROWID]`
182
+ - `ALTER TABLE name ADD [COLUMN] column-def`
183
+ - `CREATE [UNIQUE] INDEX [IF NOT EXISTS] name ON table ( col [, col…] )`
184
+ - column-def: `name TYPE [PRIMARY KEY] [NOT NULL] [NULL] [DEFAULT literal]`
185
+ - `--` line comments and `/* … */` block comments
186
+
187
+ Type map (case-insensitive) to the six §2.4 types:
188
+
189
+ | SQL keyword | IR type |
190
+ |---|---|
191
+ | `TEXT` | `string` |
192
+ | `INTEGER`, `INT`, `BIGINT`, `SMALLINT` | `integer` |
193
+ | `REAL`, `FLOAT`, `DOUBLE` | `float` |
194
+ | `BOOLEAN`, `BOOL` | `boolean` |
195
+ | `JSON`, `JSONB` | `json` |
196
+ | `BLOB`, `BYTEA` | `bytes` |
197
+
198
+ Nullability: `NOT NULL` (or being the primary key) → non-nullable;
199
+ otherwise nullable. Exactly one single-column primary key per table,
200
+ inline or table-level.
201
+
202
+ **Indexes** (`CREATE [UNIQUE] INDEX`). A migration may declare local
203
+ secondary indexes on an already-created table: `CREATE INDEX name ON table
204
+ (col)`, a compound `… (a, b)` (order preserved), the `UNIQUE` variant, and
205
+ `IF NOT EXISTS`. Each index becomes an `Ir​Table.indexes` entry
206
+ (`{ name, columns, unique }`, declaration order) and is materialized as a real
207
+ SQLite index by the **clients** (the TS web-client mirror + the Rust core's
208
+ base/visible table pair) and by typegen's own named-query type-check DB. Index
209
+ columns must exist on the table; index names must be unique across the schema;
210
+ a column must not repeat within one index. **Indexes are client-side only**:
211
+ the server stores rows in a generic `sync_rows` table with an opaque payload
212
+ (no per-user-table SQL columns exist server-side), so a user-column index has
213
+ nothing to attach to there — the scope inverted-index already covers server
214
+ reads. The column list is bare column names: **ASC/DESC**, **expression**
215
+ columns (`lower(a)`), and **partial** (`WHERE …`) indexes are hard errors (the
216
+ IR models column names only, so accepting a direction/expression would silently
217
+ drop it).
218
+
219
+ **Hard errors** (each names the construct and source file): any other
220
+ statement (`CREATE TRIGGER/VIEW`, `DROP`, DML, `ALTER … RENAME`, …); unknown
221
+ or parameterized types (`VARCHAR(36)`); quoted identifiers
222
+ (`"t"`, `` `t` ``, `[t]`); table constraints (`FOREIGN KEY`, `UNIQUE`,
223
+ `CHECK`, `CONSTRAINT`); column constraints beyond the list above
224
+ (`REFERENCES`, `CHECK`, `COLLATE`, …); `DEFAULT (expression)`; composite
225
+ or missing primary keys; `PRIMARY KEY` on `ADD COLUMN`; duplicate
226
+ tables/columns; ASC/DESC, expression, or partial (`WHERE`) index columns; a
227
+ duplicate or unknown-column index; trailing clauses (`STRICT`).
228
+
229
+ **`DEFAULT` literals are accepted and ignored**: typegen extracts the
230
+ schema *shape*; executing migrations (where defaults matter) is the
231
+ host's job. Rejecting them would make real v1-style migrations unusable
232
+ as input; recording them is not needed by any emitter today.
233
+
234
+ ## 4. Generated-module contract
235
+
236
+ The emitted `*.generated.ts` module:
237
+
238
+ - Header: `// Generated by @syncular/typegen — DO NOT EDIT.` plus
239
+ `irVersion` and the `irHash` of the IR bytes it was produced from.
240
+ - **Zero imports.** `schema` satisfies the server's `ServerSchema` type
241
+ structurally (verified by the integration test) — using the generated
242
+ file adds no dependency edge to `@syncular/server`.
243
+ - Exports per table `T` (PascalCase of the table name):
244
+ - `TRow` — one field per column in row-codec order; nullable columns
245
+ are `… | null`.
246
+ - `TInsert` — non-nullable columns required; nullable columns optional
247
+ (`?: … | null`).
248
+ - `TUpdate` — primary key required; every other column optional.
249
+ - Insert/Update are **client-side input conveniences only** — the wire
250
+ stays full-row upserts (§6.1); nothing partial is ever encoded.
251
+ - Exports per subscription `s`: `sSubscription` with `name`, `table`, and
252
+ a `scopes(params)` builder returning `Record<string, string[]>`
253
+ (requested scopes), plus an `SParams` interface when the template has
254
+ `{param}` placeholders.
255
+
256
+ **Lint/freshness split**: `*.generated.ts` is excluded from biome (see
257
+ `biome.json`) — hand-format rules on machine output only create churn.
258
+ Freshness and integrity are enforced instead by `syncular generate
259
+ --check`, which is byte-exact and therefore strictly stronger; the
260
+ generated fixture is still typechecked by `tsc` and exercised by tests.
261
+
262
+ ## 5. Native emitter contracts (Swift / Kotlin / Dart)
263
+
264
+ The same IR feeds three additional emitters, opt-in per manifest via
265
+ `output.swift` / `output.kotlin` / `output.dart`. TS stays the default
266
+ (always emitted); a native emitter runs only when its key is present. Every
267
+ native file carries the same discipline as the TS module: the
268
+ `// Generated by @syncular/typegen — DO NOT EDIT.` header, `irVersion`,
269
+ and the `irHash` of the IR bytes it was produced from — so `syncular
270
+ generate --check` gates freshness byte-exactly per language. Each language's
271
+ golden fixture lives under `test/fixtures/basic/` and is asserted
272
+ byte-exactly + for determinism by `test/golden-native.test.ts` (the TS
273
+ golden's pattern). The emitters are dependency-free string builders; the
274
+ generated files depend only on the wrapper package's `JSONValue`/`JsonValue`.
98
275
 
99
- For apps that keep the contract in a module, generate or check the
100
- Rust-codegen handoff from the typed module:
276
+ Each generated file exports, per language:
101
277
 
102
- ```bash
103
- npx syncular generate --manifest-dir .
104
- npx syncular generate --manifest-dir . --check
278
+ - a **schema constant** built from the IR (byte-stable ordering) — pass it
279
+ straight to the wrapper's `create(schema:)`;
280
+ - **one typed row type per table** with a `fromRow` factory that lifts a
281
+ `query`/`readRows` row into the typed shape (returning null/nil when a
282
+ non-nullable column is missing or mistyped);
283
+ - **one requested-scope builder per subscription**, `{param}` placeholders
284
+ becoming typed function arguments.
285
+
286
+ | | Swift | Kotlin | Dart |
287
+ |---|---|---|---|
288
+ | Schema | `enum <EnumName> { static let schema: JSONValue }` | `object <ObjectName> { val schema: JsonValue }` | `const Map<String, Object?> syncularSchema` |
289
+ | Row | `struct <Table>` + `init?(row: [String: JSONValue])` | `data class <Table>` + `fromRow(row: JsonValue)` | `class <Table>` + `static <Table>? fromRow(Map<String, Object?>)` |
290
+ | Subscription | `enum subscriptions.<Name>` w/ `scopes(...)` | `object Subscriptions.<Name>` w/ `scopes(...)` | `class Syncular<Name>Subscription` w/ static `scopes(...)` |
291
+ | Imports | `Foundation`, `Syncular` | `dev.syncular.JsonValue` | (none) |
292
+
293
+ **Type mapping** (the six §2.4 types + blob_ref/crdt):
294
+
295
+ | IR type | Swift | Kotlin | Dart | notes |
296
+ |---|---|---|---|---|
297
+ | `string` | `String` | `String` | `String` | |
298
+ | `integer` | `Int` | `Long` | `int` | JVM widens to `Long`; SQLite integers ride as JSON numbers |
299
+ | `float` | `Double` | `Double` | `double` | |
300
+ | `boolean` | `Bool` | `Boolean` | `bool` | see boolean note below |
301
+ | `json` | `String` | `String` | `String` | the raw canonical JSON string |
302
+ | `bytes` | `[UInt8]` | `ByteArray` | `List<int>` | decoded from the core's `{"$bytes":"<hex>"}` marshaling |
303
+ | `blob_ref` (§5.9) | `String` | `String` | `String` | the raw canonical BlobRef JSON string; the client's blob API parses it |
304
+ | `crdt` (§5.10) | `[UInt8]` | `ByteArray` | `List<int>` | opaque bytes; the Y.Doc accessor is an app-level helper, not generated |
305
+
306
+ **Booleans and SQLite 0/1.** SQLite has no boolean type — it stores `0`/`1`.
307
+ `fromRow` therefore accepts **either** a real JSON boolean **or** a number
308
+ (`0` → false, non-zero → true), in every language. This is the honest
309
+ mapping: a row read back from the local SQLite mirror surfaces `done` as `1`,
310
+ while an optimistic in-memory row may surface it as `true`; both decode to the
311
+ typed `Bool`/`Boolean`/`bool`.
312
+
313
+ **Nullability.** A nullable column becomes an optional property (`T?` in
314
+ Swift/Kotlin, `T?` in Dart) and `fromRow` tolerates its absence; a
315
+ non-nullable column that is missing/mistyped makes `fromRow` return
316
+ null/nil (fail-soft at the decode boundary, not a crash).
317
+
318
+ ## 6. Named queries (the `.sql` → typed-function tier)
319
+
320
+ The **named-query** tier is syncular's cross-platform answer to sqlc /
321
+ SQLDelight: you write a `.sql` file, and typegen transpiles it into a typed
322
+ function on every platform — killing query↔type drift *by construction*. It is
323
+ the type-safe **read** tier; Kysely stays the TS **dynamic** tier, and raw
324
+ `query(sql, params)` stays the escape hatch.
325
+
326
+ **File & naming convention.** Named queries live in a `queries/` directory
327
+ next to `migrations/` (override with the top-level `"queries"` manifest key).
328
+ The directory is **walked recursively** — subfolders are pure organization.
329
+ Each language's queries file is a **separate output** (`output.queries` for TS,
330
+ and `queriesPath` on `output.swift`/`output.kotlin`/`output.dart`), so
331
+ schema-only consumers never churn when a query changes. A queries output is
332
+ opt-in per language; when none is requested the `queries/` dir is not even
333
+ read.
334
+
335
+ A query's **default name** is its path relative to the queries root — every
336
+ folder segment plus the filename stem, joined and camelCased:
337
+
338
+ | Path | Default name |
339
+ |---|---|
340
+ | `list-todos.sql` | `listTodos` (flat files are unchanged) |
341
+ | `billing/invoices/list.sql` | `billingInvoicesList` |
342
+ | `reporting/tasks-by-priority.sql` | `reportingTasksByPriority` |
343
+
344
+ Every path segment **and** the filename stem must be lowercase kebab-case; a
345
+ stray `List.sql` or `Billing/` is a **hard error** naming the offending
346
+ segment. (Flat single-statement layouts keep exactly today's names, so
347
+ existing consumers are unchanged.)
348
+
349
+ **Name override.** A comment line `-- name: billingInvoicesList` in a
350
+ statement's leading comment block overrides the **full** name verbatim (it must
351
+ be a valid camelCase identifier — validated loudly). The override is
352
+ deliberately the **marker form** (`-- name: ident`), *not* a bare
353
+ `-- ident` one-word comment: a plain prose comment must **never** silently
354
+ rename a query, so a stray `-- todos` above a statement is ignored and the
355
+ path-derived name stands. Prose comments remain allowed anywhere (and are
356
+ stripped from the emitted SQL).
357
+
358
+ **One or many statements per file.** A file may hold multiple statements,
359
+ split on **top-level `;`** (a small splitter that respects single-quoted
360
+ strings, `--` line comments, and `/* … */` block comments; the SELECT-only
361
+ subset has no `BEGIN/END` blocks, so top-level semicolons are unambiguous, and
362
+ a trailing `;` on the last statement is optional). The rule:
363
+
364
+ - a file with **one** statement may omit `-- name:` — it gets the path-derived
365
+ default;
366
+ - a file with **multiple** statements requires `-- name:` on **every**
367
+ statement — a missing one is a generate-time error naming the file and the
368
+ statement's position/first line.
369
+
370
+ `-- name:` and `-- param :x <type>` comments are **scoped to the statement they
371
+ directly precede** (the leading comment block between the previous statement's
372
+ `;` and this statement's first token), not file-header-scoped.
373
+
374
+ **Global uniqueness.** After collection, a duplicate name anywhere in the
375
+ manifest is a **hard error** listing **both** source locations (file + statement
376
+ position, e.g. `dup.sql#1` / `dup.sql#2`). The filesystem no longer guarantees
377
+ uniqueness once `-- name:` overrides exist, so this is checked explicitly.
378
+
379
+ **SELECT-only.** Named queries are reads. Any statement that is not a `SELECT`
380
+ is a **hard error at generate time**, pointing at `mutate()` — writes go
381
+ through the outbox (SPEC §7.1).
382
+
383
+ **Type-checking via SQLite itself** (the load-bearing trick). At generate time
384
+ typegen synthesizes the schema's DDL from the IR (a `CREATE TABLE` per table,
385
+ the reverse of the migration parser), builds an in-memory `bun:sqlite`
386
+ database, and `prepare()`s each query. **SQLite is the correctness authority**:
387
+ a bad table/column reference or a syntax error throws with SQLite's own
388
+ message. The prepared statement then yields the result column names +
389
+ `declaredTypes` (SQLite's `sqlite3_column_decltype`).
390
+
391
+ **Typing fidelity** (what `bun:sqlite` actually exposes, and its honest
392
+ boundary):
393
+
394
+ | Result column | Type source | Nullability | Fidelity |
395
+ |---|---|---|---|
396
+ | **plain column ref** (`title`, `t.title`, `title AS x`) | resolved to the IR column (decltype confirms) — exact IR type, incl. `json`/`blob_ref`/`crdt` | the IR column's `NOT NULL` | **exact** |
397
+ | **computed expression** (`count(*)`, `done + 1`, `:p AS l`) | decltype is null → documented fallback: aggregate/arithmetic → number, else string | always nullable (an expression's nullability is not knowable from decltype) | **fallback** |
398
+
399
+ `bun:sqlite` exposes `columnNames`, `declaredTypes` (once executed once — we
400
+ run the statement against the empty DB), and `paramsCount`. It does **not**
401
+ expose column origin (table/column), param **names**, or `NOT NULL` flags — so
402
+ typegen resolves plain refs against the IR itself (parse the SELECT list +
403
+ FROM/JOIN, match `name`/`alias.name`) to attach exact nullability, and parses
404
+ `:name` placeholders from the SQL text for param names. Want an exact type for
405
+ a computed column? Alias it to a plain ref, or accept the honest fallback.
406
+
407
+ **Parameters** use the `:name` convention. A param's type is **inferred** where
408
+ it compares against a plain column ref — `WHERE list_id = :listId` (equality,
409
+ `<`/`>`/`<=`/`>=`/`!=`/`LIKE`/`IS`) or `col IN (:a, :b)` — taking that column's
410
+ IR type. Where inference is ambiguous (compared to an expression, used only in
411
+ a projection, …) a per-statement `-- param` comment (in that statement's
412
+ leading comment block) overrides:
413
+
414
+ ```sql
415
+ -- param :sinceMs integer
416
+ SELECT id, title FROM tasks WHERE estimated_at > :sinceMs + 0
105
417
  ```
106
418
 
107
- This is a dev/build-time authoring layer. Generated Rust, Swift, Kotlin, JVM,
108
- and browser clients consume generated artifacts, not the TypeScript authoring
109
- module at runtime.
419
+ Missing **both** inference and a comment is a generate-time error naming the
420
+ param and the fix. A `-- param` comment for a param the query does not use is
421
+ also an error. The inference is deliberately simple and honest.
422
+
423
+ **Emitted SQL.** Wrappers take **positional** params (`query(sql, params[])`),
424
+ so the emitted SQL rewrites `:name` → `?` (repeats included) and each runner
425
+ reorders the named params object into the positional array. Comments are
426
+ stripped and whitespace collapsed to a clean one-line string.
110
427
 
111
- ## Documentation
428
+ **The tables dependency set** (for React's exact invalidation). Each query
429
+ emits a `tables` set — the FROM/JOIN tables it reads, resolved against the IR
430
+ and *validated by the same SQLite `prepare()`* (an unknown table would have
431
+ thrown). This feeds `useSyncQuery`'s `{tables}` option so a named query
432
+ re-runs exactly when a depended-on table invalidates. **Boundary**:
433
+ `bun:sqlite` exposes no authorizer and no statement table-list, and EXPLAIN
434
+ opcodes are fragile — so the set is derived by scanning every `FROM`/`JOIN`
435
+ identifier and keeping those that name an IR table. This captures subquery /
436
+ `WHERE EXISTS (…)` tables (they still appear under `FROM`/`JOIN`), and the
437
+ prepare() still guarantees the SQL itself is correct.
112
438
 
113
- - Typegen (in migrations guide): https://syncular.dev/docs/features/migrations#type-generation-with-synculartypegen
439
+ **Emitted shape, per language** (one query, five outputs — abbreviated):
114
440
 
115
- ## Links
441
+ | | Output |
442
+ |---|---|
443
+ | TS | `listTodosRow` interface + `ListTodosParams` + `listTodosTables` + `async listTodos(client, params): Promise<ListTodosRow[]>` + a `listTodosQuery` descriptor for React |
444
+ | Swift | `struct ListTodosRow` + `init?(row:)` and `<Enum>Queries.listTodos(client:listId:) throws -> [ListTodosRow]` + `listTodosTables` |
445
+ | Kotlin | `data class ListTodosRow` + `fromRow` and `<Object>Queries.listTodos(client, listId): List<ListTodosRow>` + `listTodosTables` |
446
+ | Dart | `class ListTodosRow` + `fromRow` and `syncularListTodosQuery(client, listId): List<ListTodosRow>` + `syncularListTodosQueryTables` |
116
447
 
117
- - GitHub: https://github.com/syncular/syncular
118
- - Issues: https://github.com/syncular/syncular/issues
448
+ Each projection row is its **own** generated type per query (the drift-kill:
449
+ the row shape is exactly what the SELECT returns). Native rows decode through
450
+ the same `fromRow` mapping rules (0/1 booleans, `{"$bytes":"<hex>"}` bytes) as
451
+ the schema structs. `--check` gates every queries file byte-exactly, like the
452
+ schema files; each carries the DO-NOT-EDIT header + IR hash.
119
453
 
120
- > Status: Alpha. APIs and storage layouts may change between releases.
454
+ **React helper.** `@syncular/react` exports `useNamedQuery(query, params?)`,
455
+ which takes the TS `NamedQuery` descriptor and reuses `useSyncQuery`'s
456
+ invalidation machinery with the descriptor's exact `tables` set:
457
+
458
+ ```ts
459
+ import { listTodosQuery } from './syncular.queries';
460
+ const { rows } = useNamedQuery(listTodosQuery, { listId });
461
+ // ^ ListTodosRow[] — typed by the query's own projection
462
+ ```
package/dist/cli.d.ts CHANGED
@@ -1,3 +1,2 @@
1
1
  #!/usr/bin/env bun
2
- export {};
3
- //# sourceMappingURL=cli.d.ts.map
2
+ export declare function runCli(argv: readonly string[]): void;