@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.
- package/README.md +436 -94
- package/dist/cli.d.ts +1 -2
- package/dist/cli.js +159 -73
- package/dist/emit-dart.d.ts +17 -0
- package/dist/emit-dart.js +201 -0
- package/dist/emit-kotlin.d.ts +16 -0
- package/dist/emit-kotlin.js +201 -0
- package/dist/emit-queries-dart.d.ts +2 -0
- package/dist/emit-queries-dart.js +160 -0
- package/dist/emit-queries-kotlin.d.ts +2 -0
- package/dist/emit-queries-kotlin.js +162 -0
- package/dist/emit-queries-swift.d.ts +2 -0
- package/dist/emit-queries-swift.js +146 -0
- package/dist/emit-queries.d.ts +2 -0
- package/dist/emit-queries.js +129 -0
- package/dist/emit-swift.d.ts +18 -0
- package/dist/emit-swift.js +235 -0
- package/dist/emit.d.ts +16 -0
- package/dist/emit.js +191 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +10 -0
- package/dist/generate.d.ts +53 -18
- package/dist/generate.js +391 -77
- package/dist/index.d.ts +17 -14
- package/dist/index.js +16 -13
- package/dist/init.d.ts +12 -0
- package/dist/init.js +85 -0
- package/dist/ir.d.ts +90 -0
- package/dist/ir.js +94 -0
- package/dist/manifest.d.ts +76 -0
- package/dist/manifest.js +303 -0
- package/dist/query.d.ts +96 -0
- package/dist/query.js +719 -0
- package/dist/sql.d.ts +13 -0
- package/dist/sql.js +440 -0
- package/package.json +14 -34
- package/src/cli.ts +161 -82
- package/src/emit-dart.ts +245 -0
- package/src/emit-kotlin.ts +257 -0
- package/src/emit-queries-dart.ts +203 -0
- package/src/emit-queries-kotlin.ts +209 -0
- package/src/emit-queries-swift.ts +198 -0
- package/src/emit-queries.ts +183 -0
- package/src/emit-swift.ts +295 -0
- package/src/emit.ts +239 -0
- package/src/errors.ts +11 -0
- package/src/generate.ts +574 -105
- package/src/index.ts +16 -13
- package/src/init.ts +100 -0
- package/src/ir.ts +197 -0
- package/src/manifest.ts +445 -0
- package/src/query.ts +918 -0
- package/src/sql.ts +533 -0
- package/dist/app-contract.d.ts +0 -154
- package/dist/app-contract.d.ts.map +0 -1
- package/dist/app-contract.js +0 -250
- package/dist/app-contract.js.map +0 -1
- package/dist/checksums.d.ts +0 -6
- package/dist/checksums.d.ts.map +0 -1
- package/dist/checksums.js +0 -173
- package/dist/checksums.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/generate.d.ts.map +0 -1
- package/dist/generate.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/introspect-postgres.d.ts +0 -8
- package/dist/introspect-postgres.d.ts.map +0 -1
- package/dist/introspect-postgres.js +0 -94
- package/dist/introspect-postgres.js.map +0 -1
- package/dist/introspect-sqlite.d.ts +0 -10
- package/dist/introspect-sqlite.d.ts.map +0 -1
- package/dist/introspect-sqlite.js +0 -97
- package/dist/introspect-sqlite.js.map +0 -1
- package/dist/introspect.d.ts +0 -8
- package/dist/introspect.d.ts.map +0 -1
- package/dist/introspect.js +0 -18
- package/dist/introspect.js.map +0 -1
- package/dist/map-types.d.ts +0 -20
- package/dist/map-types.d.ts.map +0 -1
- package/dist/map-types.js +0 -151
- package/dist/map-types.js.map +0 -1
- package/dist/render.d.ts +0 -23
- package/dist/render.d.ts.map +0 -1
- package/dist/render.js +0 -140
- package/dist/render.js.map +0 -1
- package/dist/types.d.ts +0 -122
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -5
- package/dist/types.js.map +0 -1
- package/src/app-contract.ts +0 -531
- package/src/checksums.ts +0 -257
- package/src/introspect-postgres.ts +0 -149
- package/src/introspect-sqlite.ts +0 -156
- package/src/introspect.ts +0 -36
- package/src/map-types.ts +0 -189
- package/src/render.ts +0 -196
- package/src/types.ts +0 -137
package/README.md
CHANGED
|
@@ -1,120 +1,462 @@
|
|
|
1
1
|
# @syncular/typegen
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
10
|
+
```sh
|
|
11
|
+
syncular generate [--manifest-dir <dir>] [--check] [--watch]
|
|
12
|
+
syncular init [--manifest-dir <dir>]
|
|
13
|
+
```
|
|
6
14
|
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
32
|
+
---
|
|
17
33
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
54
|
+
],
|
|
55
|
+
"subscriptions": [
|
|
56
|
+
{
|
|
57
|
+
"name": "projectTasks",
|
|
58
|
+
"table": "tasks",
|
|
59
|
+
"scopes": { "project_id": ["{projectId}"] }
|
|
39
60
|
}
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
}
|
|
61
|
+
],
|
|
62
|
+
"extensions": {}
|
|
63
|
+
}
|
|
43
64
|
```
|
|
44
65
|
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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 `IrTable.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
|
-
|
|
100
|
-
Rust-codegen handoff from the typed module:
|
|
276
|
+
Each generated file exports, per language:
|
|
101
277
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
108
|
-
and
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
439
|
+
**Emitted shape, per language** (one query, five outputs — abbreviated):
|
|
114
440
|
|
|
115
|
-
|
|
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
|
-
-
|
|
118
|
-
|
|
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
|
-
|
|
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