@syncular/typegen 0.1.3 → 0.2.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 (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/dist/init.js ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `syncular init` — drop a starter `syncular.json` + `migrations/0001_initial`
3
+ * into an existing project (the "add syncular to my app" path). Writes nothing
4
+ * that would clobber an existing manifest or migration; fails loud instead.
5
+ *
6
+ * The starter mirrors the `create-app` minimal template's schema shape so the
7
+ * two on-ramps agree. Kept dependency-free and pure of process concerns.
8
+ */
9
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
10
+ import { join, resolve } from 'node:path';
11
+ import { MANIFEST_FILENAME } from './manifest.js';
12
+ const STARTER_MANIFEST = `{
13
+ "manifestVersion": 1,
14
+ "migrations": "./migrations",
15
+ "queries": "./queries",
16
+ "output": {
17
+ "ir": "./syncular.ir.json",
18
+ "module": "./src/syncular.generated.ts",
19
+ "queries": "./src/syncular.queries.ts"
20
+ },
21
+ "schemaVersions": [{ "version": 1, "through": "0001_initial" }],
22
+ "tables": [{ "name": "notes", "scopes": ["list:{list_id}"] }],
23
+ "subscriptions": [
24
+ {
25
+ "name": "notesInList",
26
+ "table": "notes",
27
+ "scopes": { "list_id": ["{listId}"] }
28
+ }
29
+ ]
30
+ }
31
+ `;
32
+ const STARTER_MIGRATION = `-- Your first table. typegen reads this for the schema SHAPE (column types,
33
+ -- primary key); the server manages its own sync_* tables and never runs it.
34
+ CREATE TABLE notes (
35
+ id TEXT PRIMARY KEY,
36
+ list_id TEXT NOT NULL,
37
+ body TEXT NOT NULL,
38
+ updated_at_ms INTEGER NOT NULL
39
+ );
40
+ `;
41
+ // A starter NAMED query (the typed .sql read tier). typegen type-checks it
42
+ // against the schema via SQLite and emits a typed \`notesInList(client, listId)\`
43
+ // + \`NotesInListRow\` into src/syncular.queries.ts. :listId infers to TEXT
44
+ // (compared against notes.list_id).
45
+ const STARTER_QUERY = `-- Every note in a list, id-ordered. Typed by SQLite against your schema.
46
+ SELECT id, list_id, body, updated_at_ms
47
+ FROM notes
48
+ WHERE list_id = :listId
49
+ ORDER BY id
50
+ `;
51
+ /** Errors that carry a friendly, actionable message for the CLI. */
52
+ export class InitError extends Error {
53
+ constructor(message) {
54
+ super(message);
55
+ this.name = 'InitError';
56
+ }
57
+ }
58
+ /**
59
+ * Create the starter files under `dir`. Refuses to overwrite an existing
60
+ * manifest or `migrations/0001_initial/up.sql`.
61
+ */
62
+ export function initProject(dir) {
63
+ const root = resolve(dir);
64
+ const manifestPath = join(root, MANIFEST_FILENAME);
65
+ const migrationDir = join(root, 'migrations', '0001_initial');
66
+ const migrationPath = join(migrationDir, 'up.sql');
67
+ const queriesDir = join(root, 'queries');
68
+ const queryPath = join(queriesDir, 'notes-in-list.sql');
69
+ if (existsSync(manifestPath)) {
70
+ throw new InitError(`${manifestPath} already exists — refusing to overwrite. ` +
71
+ 'Edit it directly, or run `syncular generate`.');
72
+ }
73
+ if (existsSync(migrationPath)) {
74
+ throw new InitError(`${migrationPath} already exists — refusing to overwrite.`);
75
+ }
76
+ if (existsSync(queryPath)) {
77
+ throw new InitError(`${queryPath} already exists — refusing to overwrite.`);
78
+ }
79
+ mkdirSync(migrationDir, { recursive: true });
80
+ mkdirSync(queriesDir, { recursive: true });
81
+ writeFileSync(manifestPath, STARTER_MANIFEST, 'utf8');
82
+ writeFileSync(migrationPath, STARTER_MIGRATION, 'utf8');
83
+ writeFileSync(queryPath, STARTER_QUERY, 'utf8');
84
+ return { written: [manifestPath, migrationPath, queryPath] };
85
+ }
package/dist/ir.d.ts ADDED
@@ -0,0 +1,90 @@
1
+ export declare const IR_VERSION = 1;
2
+ /** The §2.4 column types (wire tags 1..8, in this order; tag 7 = blob_ref,
3
+ * §5.9; tag 8 = crdt, §5.10). */
4
+ export type IrColumnType = 'string' | 'integer' | 'float' | 'boolean' | 'json' | 'bytes' | 'blob_ref' | 'crdt';
5
+ export interface IrColumn {
6
+ readonly name: string;
7
+ readonly type: IrColumnType;
8
+ readonly nullable: boolean;
9
+ /** For a `crdt` column (§5.10.1): the merger name (default `yjs-doc`).
10
+ * Present only for `crdt`; serialized after `nullable` when set. */
11
+ readonly crdtType?: string;
12
+ /** §5.11: this column is encrypted end-to-end. When set, `type` is `bytes`
13
+ * (the wire type) and `declaredType` is the pre-flip app type. Both are IR
14
+ * metadata (never on the wire, like `crdtType`). Present only for encrypted
15
+ * columns, so non-encrypted columns stay byte-identical in the IR. */
16
+ readonly encrypted?: boolean;
17
+ /** §5.11: the app-side type of an encrypted column. Present iff `encrypted`. */
18
+ readonly declaredType?: IrColumnType;
19
+ }
20
+ /** One §3.1 scope pattern, fully resolved (no re-parsing downstream). */
21
+ export interface IrScope {
22
+ readonly pattern: string;
23
+ readonly variable: string;
24
+ readonly column: string;
25
+ }
26
+ /**
27
+ * One local secondary index declared by a `CREATE [UNIQUE] INDEX` migration.
28
+ * Applied on BOTH sides: the client materializes it in its local SQLite, and
29
+ * the server creates it on the relational per-app row table
30
+ * (DESIGN-relational-server-storage.md "user indexes"; the sync read path
31
+ * itself still uses the scope inverted-index). `columns` preserves declared
32
+ * order (a compound index is order-sensitive). Index order within a table is
33
+ * declaration order.
34
+ */
35
+ export interface IrIndex {
36
+ readonly name: string;
37
+ readonly columns: readonly string[];
38
+ readonly unique: boolean;
39
+ }
40
+ export interface IrTable {
41
+ readonly name: string;
42
+ readonly primaryKey: string;
43
+ /** Declaration order — this IS the §2.4 row-codec positional order. */
44
+ readonly columns: readonly IrColumn[];
45
+ readonly scopes: readonly IrScope[];
46
+ /** Local secondary indexes (§migration subset), in declaration order.
47
+ * Additive under irVersion 1: absent/empty for tables that declare none. */
48
+ readonly indexes: readonly IrIndex[];
49
+ /** Reserved per-table hook slot (WP-49); empty object for now. */
50
+ readonly extensions: Readonly<Record<string, unknown>>;
51
+ }
52
+ export type IrScopeValue = {
53
+ readonly kind: 'literal';
54
+ readonly value: string;
55
+ } | {
56
+ readonly kind: 'parameter';
57
+ readonly name: string;
58
+ };
59
+ export interface IrSubscriptionScope {
60
+ readonly variable: string;
61
+ readonly values: readonly IrScopeValue[];
62
+ }
63
+ export interface IrSubscription {
64
+ readonly name: string;
65
+ readonly table: string;
66
+ /** Sorted by variable for stable diffs. */
67
+ readonly scopes: readonly IrSubscriptionScope[];
68
+ }
69
+ export interface IrSchemaVersion {
70
+ readonly version: number;
71
+ /** Migration names this version added, in application order. */
72
+ readonly migrations: readonly string[];
73
+ }
74
+ export interface IrDocument {
75
+ readonly irVersion: number;
76
+ /** The current (latest) generated schema version (§1.5). */
77
+ readonly schemaVersion: number;
78
+ readonly schemaVersions: readonly IrSchemaVersion[];
79
+ /** Manifest order — the handler-declared bootstrap order (§4.7). */
80
+ readonly tables: readonly IrTable[];
81
+ readonly subscriptions: readonly IrSubscription[];
82
+ /** Reserved document-level hook slot (WP-49); empty object for now. */
83
+ readonly extensions: Readonly<Record<string, unknown>>;
84
+ }
85
+ /** Recursively sort object keys so extension payloads serialize stably. */
86
+ export declare function canonicalizeExtensions(value: unknown): unknown;
87
+ /** Serialize with a fixed key order — byte-deterministic for equal IRs. */
88
+ export declare function serializeIr(ir: IrDocument): string;
89
+ /** Hash of the exact IR file bytes; stamped into generated modules. */
90
+ export declare function irHash(irJson: string): string;
package/dist/ir.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Neutral schema IR (REVISE B5).
3
+ *
4
+ * The IR is a versioned, language-neutral JSON document: tables with the
5
+ * SPEC §2.4 column types (+ nullability + primary key), scope patterns
6
+ * (§3.1), the schema-version history (§1.5 gating), subscription templates,
7
+ * and a reserved `extensions` slot (the WP-49 apply/read-model hooks home —
8
+ * empty today, present in the schema so later emitters share one shape).
9
+ * Serialization is deterministic: fixed key order, sorted extension keys,
10
+ * 2-space indent, trailing newline — so the IR file diffs cleanly and the
11
+ * Swift/Kotlin emitters can hash it the same way the TS emitter does.
12
+ */
13
+ import { createHash } from 'node:crypto';
14
+ export const IR_VERSION = 1;
15
+ /** Recursively sort object keys so extension payloads serialize stably. */
16
+ export function canonicalizeExtensions(value) {
17
+ if (Array.isArray(value))
18
+ return value.map(canonicalizeExtensions);
19
+ if (value !== null && typeof value === 'object') {
20
+ const entries = Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
21
+ const out = {};
22
+ for (const [key, entry] of entries) {
23
+ out[key] = canonicalizeExtensions(entry);
24
+ }
25
+ return out;
26
+ }
27
+ return value;
28
+ }
29
+ function canonicalScopeValue(value) {
30
+ return value.kind === 'literal'
31
+ ? { kind: 'literal', value: value.value }
32
+ : { kind: 'parameter', name: value.name };
33
+ }
34
+ /** Serialize with a fixed key order — byte-deterministic for equal IRs. */
35
+ export function serializeIr(ir) {
36
+ const doc = {
37
+ irVersion: ir.irVersion,
38
+ schemaVersion: ir.schemaVersion,
39
+ schemaVersions: ir.schemaVersions.map((v) => ({
40
+ version: v.version,
41
+ migrations: v.migrations,
42
+ })),
43
+ tables: ir.tables.map((table) => ({
44
+ name: table.name,
45
+ primaryKey: table.primaryKey,
46
+ columns: table.columns.map((column) => ({
47
+ name: column.name,
48
+ type: column.type,
49
+ nullable: column.nullable,
50
+ // §5.10.1: crdtType is IR metadata (never on the wire); emitted only
51
+ // for crdt columns so non-crdt column diffs stay byte-identical.
52
+ ...(column.crdtType !== undefined ? { crdtType: column.crdtType } : {}),
53
+ // §5.11: encrypted/declaredType are IR metadata (never on the wire);
54
+ // emitted only for encrypted columns so non-encrypted diffs stay
55
+ // byte-identical.
56
+ ...(column.encrypted === true
57
+ ? { encrypted: true, declaredType: column.declaredType }
58
+ : {}),
59
+ })),
60
+ scopes: table.scopes.map((scope) => ({
61
+ pattern: scope.pattern,
62
+ variable: scope.variable,
63
+ column: scope.column,
64
+ })),
65
+ // Indexes are additive under irVersion 1: emitted only when the table
66
+ // declares at least one, so index-free tables (every pre-index manifest)
67
+ // stay byte-identical and --check fresh. Declaration order preserved.
68
+ ...(table.indexes.length > 0
69
+ ? {
70
+ indexes: table.indexes.map((index) => ({
71
+ name: index.name,
72
+ columns: index.columns,
73
+ unique: index.unique,
74
+ })),
75
+ }
76
+ : {}),
77
+ extensions: canonicalizeExtensions(table.extensions),
78
+ })),
79
+ subscriptions: ir.subscriptions.map((sub) => ({
80
+ name: sub.name,
81
+ table: sub.table,
82
+ scopes: sub.scopes.map((scope) => ({
83
+ variable: scope.variable,
84
+ values: scope.values.map(canonicalScopeValue),
85
+ })),
86
+ })),
87
+ extensions: canonicalizeExtensions(ir.extensions),
88
+ };
89
+ return `${JSON.stringify(doc, null, 2)}\n`;
90
+ }
91
+ /** Hash of the exact IR file bytes; stamped into generated modules. */
92
+ export function irHash(irJson) {
93
+ return `sha256:${createHash('sha256').update(irJson, 'utf8').digest('hex')}`;
94
+ }
@@ -0,0 +1,76 @@
1
+ export declare const MANIFEST_FILENAME = "syncular.json";
2
+ /** Same shorthand the server schema accepts (§3.1). */
3
+ export type ManifestScopeSpec = string | {
4
+ pattern: string;
5
+ column: string;
6
+ };
7
+ export interface ManifestTable {
8
+ readonly name: string;
9
+ readonly scopes: readonly ManifestScopeSpec[];
10
+ readonly extensions: Readonly<Record<string, unknown>>;
11
+ /**
12
+ * §5.11: columns to encrypt end-to-end. App config (not DDL): the IR flips
13
+ * each named column's wire type to `bytes` and records `encrypted` +
14
+ * `declaredType`. Absent/empty for tables with no encryption (byte-identical
15
+ * IR to before this rung). Hard errors (encrypted scope/crdt/pk column, or
16
+ * an unknown column) are enforced at generate time.
17
+ */
18
+ readonly encryptedColumns: readonly string[];
19
+ }
20
+ export interface ManifestSubscription {
21
+ readonly name: string;
22
+ readonly table: string;
23
+ readonly scopes: Readonly<Record<string, readonly string[]>>;
24
+ }
25
+ export interface ManifestSchemaVersion {
26
+ readonly version: number;
27
+ readonly through: string;
28
+ }
29
+ /** Swift emitter output: a `.swift` path plus optional options. */
30
+ export interface SwiftOutput {
31
+ readonly path: string;
32
+ /** The generated enum namespace (default `SyncularSchema`). */
33
+ readonly enumName: string;
34
+ /** Opt-in named-queries output path (a sibling `.swift` file). */
35
+ readonly queriesPath?: string;
36
+ }
37
+ /** Kotlin emitter output: a `.kt` path plus its package declaration. */
38
+ export interface KotlinOutput {
39
+ readonly path: string;
40
+ /** The Kotlin package declaration (default `syncular.generated`). */
41
+ readonly package: string;
42
+ /** The generated top-level object name (default `SyncularSchema`). */
43
+ readonly objectName: string;
44
+ /** Opt-in named-queries output path (a sibling `.kt` file). */
45
+ readonly queriesPath?: string;
46
+ }
47
+ /** Dart emitter output: a `.dart` path plus optional options. */
48
+ export interface DartOutput {
49
+ readonly path: string;
50
+ /** Opt-in named-queries output path (a sibling `.dart` file). */
51
+ readonly queriesPath?: string;
52
+ }
53
+ export interface ManifestOutput {
54
+ readonly ir: string;
55
+ readonly module: string;
56
+ /** Opt-in TS named-queries output path (a sibling `.ts` file). Undefined →
57
+ * named queries are not generated for TS. */
58
+ readonly queries?: string;
59
+ /** Opt-in native emitters; undefined → that language is not generated. */
60
+ readonly swift?: SwiftOutput;
61
+ readonly kotlin?: KotlinOutput;
62
+ readonly dart?: DartOutput;
63
+ }
64
+ export interface Manifest {
65
+ readonly manifestVersion: 1;
66
+ readonly migrations: string;
67
+ /** Directory of `.sql` named-query files (default `./queries`). Only read
68
+ * when at least one output requests a named-queries file. */
69
+ readonly queries: string;
70
+ readonly output: ManifestOutput;
71
+ readonly schemaVersions: readonly ManifestSchemaVersion[];
72
+ readonly tables: readonly ManifestTable[];
73
+ readonly subscriptions: readonly ManifestSubscription[];
74
+ readonly extensions: Readonly<Record<string, unknown>>;
75
+ }
76
+ export declare function parseManifest(raw: unknown): Manifest;
@@ -0,0 +1,303 @@
1
+ /**
2
+ * `syncular.json` manifest (REVISE B5) — designed here, minimal but
3
+ * forward-extensible:
4
+ *
5
+ * ```json
6
+ * {
7
+ * "manifestVersion": 1,
8
+ * "migrations": "./migrations",
9
+ * "output": { "ir": "./syncular.ir.json",
10
+ * "module": "./syncular.generated.ts",
11
+ * "swift": "./Sources/App/Syncular.generated.swift",
12
+ * "kotlin": { "path": "./Syncular.generated.kt",
13
+ * "package": "dev.example" },
14
+ * "dart": "./lib/syncular.generated.dart" },
15
+ * "schemaVersions": [{ "version": 1, "through": "0001_initial" }],
16
+ * "tables": [{ "name": "tasks", "scopes": ["project:{project_id}"] }],
17
+ * "subscriptions": [{ "name": "projectTasks", "table": "tasks",
18
+ * "scopes": { "project_id": ["{projectId}"] } }],
19
+ * "extensions": {}
20
+ * }
21
+ * ```
22
+ *
23
+ * - `tables` is ordered: it is the handler-declared bootstrap order (§4.7).
24
+ * - `schemaVersions` is the §1.5 version history: strictly increasing
25
+ * versions, each naming the last migration it includes (`through`); the
26
+ * final entry must cover the final migration.
27
+ * - Subscription scope values are literals or whole-value `{param}`
28
+ * placeholders (partial templates are unsupported).
29
+ * - `output.ir` / `output.module` are the always-emitted TS defaults.
30
+ * `output.swift` / `output.kotlin` / `output.dart` are OPT-IN native
31
+ * emitters — a bare string is the output path; an object carries
32
+ * language-appropriate options (Kotlin `package`/`objectName`, Swift
33
+ * `enumName`; Dart takes `path` only). This is additive within the `output`
34
+ * object (the same forward-extension shape `output.ir`/`output.module`
35
+ * already use); no `manifestVersion` bump — new *recognized* keys, not
36
+ * tolerated-unknown ones. Absent → that language is not generated (TS
37
+ * stays the default).
38
+ * - Unknown keys are hard errors everywhere (fail loud; growth happens by
39
+ * bumping `manifestVersion`), except inside `extensions`, the reserved
40
+ * WP-49 passthrough slot copied verbatim into the IR.
41
+ */
42
+ import { TypegenError } from './errors.js';
43
+ export const MANIFEST_FILENAME = 'syncular.json';
44
+ const SOURCE = MANIFEST_FILENAME;
45
+ function fail(message) {
46
+ throw new TypegenError(SOURCE, message);
47
+ }
48
+ function asObject(value, context) {
49
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
50
+ fail(`${context} must be an object`);
51
+ }
52
+ return value;
53
+ }
54
+ function asString(value, context) {
55
+ if (typeof value !== 'string' || value.length === 0) {
56
+ fail(`${context} must be a non-empty string`);
57
+ }
58
+ return value;
59
+ }
60
+ function rejectUnknownKeys(obj, allowed, context) {
61
+ for (const key of Object.keys(obj)) {
62
+ if (!allowed.includes(key)) {
63
+ fail(`${context} has unknown key ${JSON.stringify(key)}`);
64
+ }
65
+ }
66
+ }
67
+ function parseExtensions(value, context) {
68
+ if (value === undefined)
69
+ return {};
70
+ return asObject(value, `${context}.extensions`);
71
+ }
72
+ function parseScopeSpec(value, context) {
73
+ if (typeof value === 'string')
74
+ return value;
75
+ const obj = asObject(value, context);
76
+ rejectUnknownKeys(obj, ['pattern', 'column'], context);
77
+ return {
78
+ pattern: asString(obj.pattern, `${context}.pattern`),
79
+ column: asString(obj.column, `${context}.column`),
80
+ };
81
+ }
82
+ /** Parse a native-emitter output spec: a bare path string, or an object with
83
+ * `path` + language options. Returns the resolved output or throws. */
84
+ function parseSwiftOutput(value) {
85
+ if (typeof value === 'string') {
86
+ return {
87
+ path: asString(value, 'output.swift'),
88
+ enumName: 'SyncularSchema',
89
+ };
90
+ }
91
+ const obj = asObject(value, 'output.swift');
92
+ rejectUnknownKeys(obj, ['path', 'enumName', 'queriesPath'], 'output.swift');
93
+ return {
94
+ path: asString(obj.path, 'output.swift.path'),
95
+ enumName: obj.enumName === undefined
96
+ ? 'SyncularSchema'
97
+ : asString(obj.enumName, 'output.swift.enumName'),
98
+ ...(obj.queriesPath !== undefined
99
+ ? { queriesPath: asString(obj.queriesPath, 'output.swift.queriesPath') }
100
+ : {}),
101
+ };
102
+ }
103
+ function parseKotlinOutput(value) {
104
+ if (typeof value === 'string') {
105
+ return {
106
+ path: asString(value, 'output.kotlin'),
107
+ package: 'syncular.generated',
108
+ objectName: 'SyncularSchema',
109
+ };
110
+ }
111
+ const obj = asObject(value, 'output.kotlin');
112
+ rejectUnknownKeys(obj, ['path', 'package', 'objectName', 'queriesPath'], 'output.kotlin');
113
+ return {
114
+ path: asString(obj.path, 'output.kotlin.path'),
115
+ package: obj.package === undefined
116
+ ? 'syncular.generated'
117
+ : asString(obj.package, 'output.kotlin.package'),
118
+ objectName: obj.objectName === undefined
119
+ ? 'SyncularSchema'
120
+ : asString(obj.objectName, 'output.kotlin.objectName'),
121
+ ...(obj.queriesPath !== undefined
122
+ ? { queriesPath: asString(obj.queriesPath, 'output.kotlin.queriesPath') }
123
+ : {}),
124
+ };
125
+ }
126
+ function parseDartOutput(value) {
127
+ if (typeof value === 'string') {
128
+ return { path: asString(value, 'output.dart') };
129
+ }
130
+ const obj = asObject(value, 'output.dart');
131
+ rejectUnknownKeys(obj, ['path', 'queriesPath'], 'output.dart');
132
+ return {
133
+ path: asString(obj.path, 'output.dart.path'),
134
+ ...(obj.queriesPath !== undefined
135
+ ? { queriesPath: asString(obj.queriesPath, 'output.dart.queriesPath') }
136
+ : {}),
137
+ };
138
+ }
139
+ function parseTable(value, index) {
140
+ const context = `tables[${index}]`;
141
+ const obj = asObject(value, context);
142
+ rejectUnknownKeys(obj, ['name', 'scopes', 'extensions', 'encryptedColumns'], context);
143
+ const name = asString(obj.name, `${context}.name`);
144
+ if (!Array.isArray(obj.scopes) || obj.scopes.length === 0) {
145
+ fail(`table ${name}: scopes must be a non-empty array (§3.1)`);
146
+ }
147
+ let encryptedColumns = [];
148
+ if (obj.encryptedColumns !== undefined) {
149
+ if (!Array.isArray(obj.encryptedColumns)) {
150
+ fail(`table ${name}: encryptedColumns must be an array of column names`);
151
+ }
152
+ encryptedColumns = obj.encryptedColumns.map((c, i) => asString(c, `table ${name} encryptedColumns[${i}]`));
153
+ const seen = new Set();
154
+ for (const col of encryptedColumns) {
155
+ if (seen.has(col)) {
156
+ fail(`table ${name}: duplicate encryptedColumns entry ${JSON.stringify(col)}`);
157
+ }
158
+ seen.add(col);
159
+ }
160
+ }
161
+ return {
162
+ name,
163
+ scopes: obj.scopes.map((spec, i) => parseScopeSpec(spec, `table ${name} scopes[${i}]`)),
164
+ extensions: parseExtensions(obj.extensions, `table ${name}`),
165
+ encryptedColumns,
166
+ };
167
+ }
168
+ const IDENTIFIER_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
169
+ function parseSubscription(value, index) {
170
+ const context = `subscriptions[${index}]`;
171
+ const obj = asObject(value, context);
172
+ rejectUnknownKeys(obj, ['name', 'table', 'scopes'], context);
173
+ const name = asString(obj.name, `${context}.name`);
174
+ if (!IDENTIFIER_RE.test(name)) {
175
+ fail(`subscription ${JSON.stringify(name)}: name must be a valid identifier`);
176
+ }
177
+ const table = asString(obj.table, `subscription ${name}: table`);
178
+ const scopesObj = asObject(obj.scopes, `subscription ${name}: scopes`);
179
+ const scopes = {};
180
+ for (const [variable, values] of Object.entries(scopesObj)) {
181
+ if (!Array.isArray(values) || values.length === 0) {
182
+ fail(`subscription ${name}: scopes.${variable} must be a non-empty array`);
183
+ }
184
+ scopes[variable] = values.map((v, i) => asString(v, `subscription ${name}: scopes.${variable}[${i}]`));
185
+ }
186
+ if (Object.keys(scopes).length === 0) {
187
+ fail(`subscription ${name}: scopes must not be empty`);
188
+ }
189
+ return { name, table, scopes };
190
+ }
191
+ function parseSchemaVersion(value, index) {
192
+ const context = `schemaVersions[${index}]`;
193
+ const obj = asObject(value, context);
194
+ rejectUnknownKeys(obj, ['version', 'through'], context);
195
+ const version = obj.version;
196
+ if (typeof version !== 'number' ||
197
+ !Number.isInteger(version) ||
198
+ version < 1) {
199
+ fail(`${context}.version must be an integer ≥ 1 (§1.5)`);
200
+ }
201
+ return {
202
+ version,
203
+ through: asString(obj.through, `${context}.through`),
204
+ };
205
+ }
206
+ export function parseManifest(raw) {
207
+ const obj = asObject(raw, 'manifest');
208
+ rejectUnknownKeys(obj, [
209
+ 'manifestVersion',
210
+ 'migrations',
211
+ 'queries',
212
+ 'output',
213
+ 'schemaVersions',
214
+ 'tables',
215
+ 'subscriptions',
216
+ 'extensions',
217
+ ], 'manifest');
218
+ if (obj.manifestVersion !== 1) {
219
+ fail(`manifestVersion must be 1, got ${JSON.stringify(obj.manifestVersion)}`);
220
+ }
221
+ const migrations = obj.migrations === undefined
222
+ ? './migrations'
223
+ : asString(obj.migrations, 'migrations');
224
+ const queries = obj.queries === undefined ? './queries' : asString(obj.queries, 'queries');
225
+ let ir = './syncular.ir.json';
226
+ let module = './syncular.generated.ts';
227
+ let queriesOut;
228
+ let swift;
229
+ let kotlin;
230
+ let dart;
231
+ if (obj.output !== undefined) {
232
+ const output = asObject(obj.output, 'output');
233
+ rejectUnknownKeys(output, ['ir', 'module', 'queries', 'swift', 'kotlin', 'dart'], 'output');
234
+ if (output.ir !== undefined)
235
+ ir = asString(output.ir, 'output.ir');
236
+ if (output.module !== undefined) {
237
+ module = asString(output.module, 'output.module');
238
+ }
239
+ if (output.queries !== undefined) {
240
+ queriesOut = asString(output.queries, 'output.queries');
241
+ }
242
+ if (output.swift !== undefined)
243
+ swift = parseSwiftOutput(output.swift);
244
+ if (output.kotlin !== undefined)
245
+ kotlin = parseKotlinOutput(output.kotlin);
246
+ if (output.dart !== undefined)
247
+ dart = parseDartOutput(output.dart);
248
+ }
249
+ // Build `output` with only the keys that are set — `exactOptionalPropertyTypes`
250
+ // forbids explicit `undefined` on optional properties.
251
+ const outputSpec = {
252
+ ir,
253
+ module,
254
+ ...(queriesOut !== undefined ? { queries: queriesOut } : {}),
255
+ ...(swift !== undefined ? { swift } : {}),
256
+ ...(kotlin !== undefined ? { kotlin } : {}),
257
+ ...(dart !== undefined ? { dart } : {}),
258
+ };
259
+ if (!Array.isArray(obj.schemaVersions) || obj.schemaVersions.length === 0) {
260
+ fail('schemaVersions must be a non-empty array (§1.5 version history)');
261
+ }
262
+ const schemaVersions = obj.schemaVersions.map(parseSchemaVersion);
263
+ for (let i = 1; i < schemaVersions.length; i++) {
264
+ const prev = schemaVersions[i - 1];
265
+ const next = schemaVersions[i];
266
+ if (next.version <= prev.version) {
267
+ fail(`schemaVersions must be strictly increasing (${prev.version} then ${next.version})`);
268
+ }
269
+ }
270
+ if (!Array.isArray(obj.tables) || obj.tables.length === 0) {
271
+ fail('tables must be a non-empty array');
272
+ }
273
+ const tables = obj.tables.map(parseTable);
274
+ const tableNames = new Set();
275
+ for (const table of tables) {
276
+ if (tableNames.has(table.name)) {
277
+ fail(`duplicate table ${JSON.stringify(table.name)}`);
278
+ }
279
+ tableNames.add(table.name);
280
+ }
281
+ const subscriptions = Array.isArray(obj.subscriptions)
282
+ ? obj.subscriptions.map(parseSubscription)
283
+ : obj.subscriptions === undefined
284
+ ? []
285
+ : fail('subscriptions must be an array');
286
+ const subNames = new Set();
287
+ for (const sub of subscriptions) {
288
+ if (subNames.has(sub.name)) {
289
+ fail(`duplicate subscription ${JSON.stringify(sub.name)}`);
290
+ }
291
+ subNames.add(sub.name);
292
+ }
293
+ return {
294
+ manifestVersion: 1,
295
+ migrations,
296
+ queries,
297
+ output: outputSpec,
298
+ schemaVersions,
299
+ tables,
300
+ subscriptions,
301
+ extensions: parseExtensions(obj.extensions, 'manifest'),
302
+ };
303
+ }