rastack 0.0.16

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 (140) hide show
  1. package/CHANGELOG.md +43013 -0
  2. package/dist/.prettierrc +7 -0
  3. package/dist/compile/analyze.d.ts +12 -0
  4. package/dist/compile/analyze.js +233 -0
  5. package/dist/compile/index.d.ts +19 -0
  6. package/dist/compile/index.js +107 -0
  7. package/dist/compile/manifest.d.ts +4 -0
  8. package/dist/compile/manifest.js +12 -0
  9. package/dist/compile/model.d.ts +54 -0
  10. package/dist/compile/model.js +7 -0
  11. package/dist/compile/openapi.d.ts +11 -0
  12. package/dist/compile/openapi.js +233 -0
  13. package/dist/compile/program.d.ts +10 -0
  14. package/dist/compile/program.js +61 -0
  15. package/dist/csv-schema.d.ts +28 -0
  16. package/dist/csv-schema.js +162 -0
  17. package/dist/define/index.d.ts +117 -0
  18. package/dist/define/index.js +74 -0
  19. package/dist/entity-generation/delete-method.d.ts +1 -0
  20. package/dist/entity-generation/delete-method.js +31 -0
  21. package/dist/entity-generation/form.d.ts +13 -0
  22. package/dist/entity-generation/form.js +136 -0
  23. package/dist/entity-generation/get-method.d.ts +1 -0
  24. package/dist/entity-generation/get-method.js +67 -0
  25. package/dist/entity-generation/imports.d.ts +5 -0
  26. package/dist/entity-generation/imports.js +91 -0
  27. package/dist/entity-generation/sync-method.d.ts +30 -0
  28. package/dist/entity-generation/sync-method.js +158 -0
  29. package/dist/entity-generation/update-method.d.ts +1 -0
  30. package/dist/entity-generation/update-method.js +29 -0
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.js +53 -0
  33. package/dist/rad-compile.d.ts +11 -0
  34. package/dist/rad-compile.js +82 -0
  35. package/dist/rad-wasm-build.d.ts +14 -0
  36. package/dist/rad-wasm-build.js +74 -0
  37. package/dist/rad.d.ts +17 -0
  38. package/dist/rad.js +88 -0
  39. package/dist/scan.d.ts +12 -0
  40. package/dist/scan.js +79 -0
  41. package/dist/schema/camel-to-pastel.d.ts +1 -0
  42. package/dist/schema/camel-to-pastel.js +7 -0
  43. package/dist/schema/capitalise-first-letter.d.ts +1 -0
  44. package/dist/schema/capitalise-first-letter.js +7 -0
  45. package/dist/schema/extract-response.d.ts +2 -0
  46. package/dist/schema/extract-response.js +30 -0
  47. package/dist/schema/fetch-schema.d.ts +7 -0
  48. package/dist/schema/fetch-schema.js +23 -0
  49. package/dist/schema/remove-non-model-paths.d.ts +2 -0
  50. package/dist/schema/remove-non-model-paths.js +15 -0
  51. package/dist/schema/to-camel-case.d.ts +1 -0
  52. package/dist/schema/to-camel-case.js +11 -0
  53. package/dist/schema/to-pastel-case.d.ts +1 -0
  54. package/dist/schema/to-pastel-case.js +10 -0
  55. package/dist/schema-convert.d.ts +1 -0
  56. package/dist/schema-convert.js +63 -0
  57. package/dist/schema-entities.d.ts +14 -0
  58. package/dist/schema-entities.js +293 -0
  59. package/dist/schema-fetch.d.ts +1 -0
  60. package/dist/schema-fetch.js +81 -0
  61. package/dist/schema-full.d.ts +1 -0
  62. package/dist/schema-full.js +45 -0
  63. package/dist/schema-index.d.ts +1 -0
  64. package/dist/schema-index.js +34 -0
  65. package/dist/schema-params.d.ts +1 -0
  66. package/dist/schema-params.js +100 -0
  67. package/dist/seed.d.ts +17 -0
  68. package/dist/seed.js +252 -0
  69. package/hooks/form/form.ts +207 -0
  70. package/hooks/form/index.ts +8 -0
  71. package/hooks/form/interfaces.ts +217 -0
  72. package/hooks/form/structure.ts +39 -0
  73. package/hooks/form/validate-schema.ts +49 -0
  74. package/hooks/index.ts +3 -0
  75. package/hooks/query/api.ts +42 -0
  76. package/hooks/query/delete.ts +45 -0
  77. package/hooks/query/fetch.ts +48 -0
  78. package/hooks/query/index.ts +21 -0
  79. package/hooks/query/interfaces.ts +111 -0
  80. package/hooks/query/list.ts +286 -0
  81. package/hooks/query/update.ts +88 -0
  82. package/hooks/query/url.ts +31 -0
  83. package/hooks/real-time/index.ts +1 -0
  84. package/hooks/real-time/pusher.ts +43 -0
  85. package/jest.config.cjs +6 -0
  86. package/package.json +57 -0
  87. package/provider/index.ts +13 -0
  88. package/provider/provider.tsx +187 -0
  89. package/provider/types.ts +114 -0
  90. package/provider/warehouse.ts +167 -0
  91. package/provider/wasm.ts +124 -0
  92. package/runtime.ts +3 -0
  93. package/src/.prettierrc +7 -0
  94. package/src/compile/analyze.ts +224 -0
  95. package/src/compile/index.ts +86 -0
  96. package/src/compile/manifest.ts +10 -0
  97. package/src/compile/model.ts +69 -0
  98. package/src/compile/openapi.ts +266 -0
  99. package/src/compile/program.ts +40 -0
  100. package/src/csv-schema.ts +187 -0
  101. package/src/define/index.ts +162 -0
  102. package/src/entity-generation/delete-method.ts +37 -0
  103. package/src/entity-generation/form.ts +169 -0
  104. package/src/entity-generation/get-method.ts +80 -0
  105. package/src/entity-generation/imports.ts +110 -0
  106. package/src/entity-generation/sync-method.ts +222 -0
  107. package/src/entity-generation/update-method.ts +38 -0
  108. package/src/index.ts +77 -0
  109. package/src/rad-compile.ts +88 -0
  110. package/src/rad-wasm-build.ts +102 -0
  111. package/src/rad.ts +101 -0
  112. package/src/scan.ts +89 -0
  113. package/src/schema/camel-to-pastel.ts +3 -0
  114. package/src/schema/capitalise-first-letter.ts +3 -0
  115. package/src/schema/extract-response.ts +38 -0
  116. package/src/schema/fetch-schema.ts +12 -0
  117. package/src/schema/remove-non-model-paths.ts +13 -0
  118. package/src/schema/to-camel-case.ts +7 -0
  119. package/src/schema/to-pastel-case.ts +6 -0
  120. package/src/schema-convert.ts +61 -0
  121. package/src/schema-entities.ts +401 -0
  122. package/src/schema-fetch.ts +82 -0
  123. package/src/schema-full.ts +38 -0
  124. package/src/schema-index.ts +29 -0
  125. package/src/schema-params.ts +116 -0
  126. package/src/seed.ts +297 -0
  127. package/sync/engine.ts +392 -0
  128. package/sync/hooks.ts +450 -0
  129. package/sync/index.ts +8 -0
  130. package/sync/persistence.ts +237 -0
  131. package/sync/provider.tsx +91 -0
  132. package/sync/registry.ts +36 -0
  133. package/sync/store.ts +126 -0
  134. package/sync/transactions.ts +300 -0
  135. package/sync/types.ts +94 -0
  136. package/test/compile.spec.ts +192 -0
  137. package/test/csv-schema.spec.ts +143 -0
  138. package/test/schema-entities.spec.ts +688 -0
  139. package/tsconfig.json +24 -0
  140. package/types.ts +19 -0
package/src/seed.ts ADDED
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * rad seed
5
+ *
6
+ * Loads CSV files from a local directory or GCS bucket into the target
7
+ * Iceberg namespace. Performs schema inference, PII check, and idempotent
8
+ * table creation before writing rows.
9
+ *
10
+ * Usage:
11
+ * rad seed [--env dev|staging] [--source gs://bucket/ | --source ./myapp-dev/]
12
+ *
13
+ * Environment variables (set via CI secrets or .env):
14
+ * GCP_PROJECT GCP project ID
15
+ * ICEBERG_WAREHOUSE gs://... path to Iceberg warehouse root
16
+ * ICEBERG_CATALOG_URI sqlite:///... or http://... catalog URI
17
+ */
18
+
19
+ import fs from "fs";
20
+ import path from "path";
21
+ import { execSync } from "child_process";
22
+ import {
23
+ discoverCsvFiles,
24
+ inferSchema,
25
+ parseCsv,
26
+ TableSchema,
27
+ } from "./csv-schema";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Args
31
+ // ---------------------------------------------------------------------------
32
+ const args = process.argv.slice(2);
33
+
34
+ function getArg(flag: string): string | null {
35
+ const i = args.indexOf(flag);
36
+ return i !== -1 && args[i + 1] ? args[i + 1] : null;
37
+ }
38
+
39
+ const env = getArg("--env") ?? "dev";
40
+ const source = getArg("--source") ?? path.join(process.cwd(), "myapp-dev");
41
+
42
+ const repoRoot = process.cwd();
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Step 1: resolve CSV files
46
+ // ---------------------------------------------------------------------------
47
+ function resolveFiles(): string[] {
48
+ if (source.startsWith("gs://")) {
49
+ return downloadFromGcs(source);
50
+ }
51
+ return discoverCsvFiles(source);
52
+ }
53
+
54
+ function downloadFromGcs(gcsUri: string): string[] {
55
+ const tmpDir = path.join(repoRoot, ".rad", "tmp", "seed");
56
+ fs.mkdirSync(tmpDir, { recursive: true });
57
+
58
+ console.log(`Downloading CSVs from ${gcsUri} ...`);
59
+ // `rad seed` is a local/CI developer tool; `gcsUri` is an operator-supplied
60
+ // --source argument (a gs:// bucket the operator controls), not untrusted
61
+ // network input, so there is no command-injection surface here.
62
+ try {
63
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
64
+ execSync(`gcloud storage cp "${gcsUri}*.csv" "${tmpDir}/" --quiet`, {
65
+ stdio: "inherit",
66
+ });
67
+ } catch {
68
+ // gcloud storage cp uses gsutil-style globs — fall back
69
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
70
+ execSync(`gsutil -m cp "${gcsUri}*.csv" "${tmpDir}/"`, {
71
+ stdio: "inherit",
72
+ });
73
+ }
74
+
75
+ return discoverCsvFiles(tmpDir);
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Step 2: validate schemas (PII check — hard fail in CI)
80
+ // ---------------------------------------------------------------------------
81
+ function validateSchema(csvPath: string): TableSchema {
82
+ const { schema, piiViolations } = inferSchema(csvPath, repoRoot);
83
+
84
+ if (piiViolations.length > 0) {
85
+ const list = piiViolations
86
+ .map((v) => ` ${v.column}: ${v.reason}`)
87
+ .join("\n");
88
+ throw new Error(
89
+ `PII detected in ${path.basename(csvPath)}:\n${list}\n` +
90
+ `Remove PII data before seeding. Run "rad scan" to review.`,
91
+ );
92
+ }
93
+
94
+ return schema;
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Step 3: emit Iceberg DDL for a table
99
+ // ---------------------------------------------------------------------------
100
+ function icebergType(col: TableSchema["columns"][number]["type"]): string {
101
+ switch (col) {
102
+ case "integer":
103
+ return "long";
104
+ case "float":
105
+ return "double";
106
+ case "boolean":
107
+ return "boolean";
108
+ case "date":
109
+ return "date";
110
+ case "datetime":
111
+ return "timestamp";
112
+ default:
113
+ return "string";
114
+ }
115
+ }
116
+
117
+ function buildCreateTableSql(schema: TableSchema, namespace: string): string {
118
+ const cols = schema.columns
119
+ .map((c) => {
120
+ const type = icebergType(c.type);
121
+ const nullable = c.nullable ? "" : " NOT NULL";
122
+ return ` ${c.name} ${type}${nullable}`;
123
+ })
124
+ .join(",\n");
125
+
126
+ return (
127
+ `CREATE TABLE IF NOT EXISTS ${namespace}.${schema.tableName} (\n` +
128
+ `${cols}\n` +
129
+ `) USING iceberg\n` +
130
+ `TBLPROPERTIES (\n` +
131
+ ` 'write.format.default'='parquet',\n` +
132
+ ` 'write.metadata.fingerprint'='${schema.fingerprint}'\n` +
133
+ `);`
134
+ );
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Step 4: write schema fingerprints to registry
139
+ // ---------------------------------------------------------------------------
140
+ function writeFingerprint(schema: TableSchema): void {
141
+ const registryDir = path.join(repoRoot, ".rad", "registry");
142
+ fs.mkdirSync(registryDir, { recursive: true });
143
+ const registryPath = path.join(registryDir, `${schema.tableName}.json`);
144
+
145
+ let existing: Record<string, unknown> = {};
146
+ if (fs.existsSync(registryPath)) {
147
+ existing = JSON.parse(fs.readFileSync(registryPath, "utf-8"));
148
+ }
149
+
150
+ const prev = (existing as { fingerprint?: string }).fingerprint;
151
+ const changed = prev && prev !== schema.fingerprint;
152
+
153
+ const record = {
154
+ tableName: schema.tableName,
155
+ fingerprint: schema.fingerprint,
156
+ columns: schema.columns,
157
+ rowCount: schema.rowCount,
158
+ seededAt: new Date().toISOString(),
159
+ env,
160
+ previousFingerprint: prev ?? null,
161
+ };
162
+
163
+ fs.writeFileSync(registryPath, JSON.stringify(record, null, 2));
164
+
165
+ if (changed) {
166
+ console.warn(
167
+ ` ⚠ Schema change detected for "${schema.tableName}": ` +
168
+ `${prev} → ${schema.fingerprint}. A migration may be required.`,
169
+ );
170
+ }
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Step 5: build a Python seed script and run it via django-iceberg
175
+ // ---------------------------------------------------------------------------
176
+ function buildPythonSeedScript(
177
+ schemas: TableSchema[],
178
+ csvPaths: string[],
179
+ ): string {
180
+ const namespace = env;
181
+ const warehouse = process.env.ICEBERG_WAREHOUSE ?? "data/warehouse";
182
+ const catalogUri =
183
+ process.env.ICEBERG_CATALOG_URI ?? "sqlite:///data/catalog.db";
184
+
185
+ const tableBlocks = schemas
186
+ .map((schema, i) => {
187
+ const ddl = buildCreateTableSql(schema, namespace).replace(/`/g, "\\`");
188
+ const csvPathEscaped = csvPaths[i].replace(/\\/g, "/");
189
+ return `
190
+ # --- ${schema.tableName} ---
191
+ ddl = """${ddl}"""
192
+ cat.execute(ddl)
193
+ df = pl.read_csv("${csvPathEscaped}", infer_schema_length=1000)
194
+ tbl = cat.load_table("${namespace}.${schema.tableName}")
195
+ tbl.overwrite(df.to_arrow())
196
+ print(f" Seeded ${schema.tableName}: ${schema.rowCount} rows")
197
+ `;
198
+ })
199
+ .join("\n");
200
+
201
+ return `
202
+ import polars as pl
203
+ from pyiceberg.catalog import load_catalog
204
+
205
+ cat = load_catalog(
206
+ "default",
207
+ **{
208
+ "uri": "${catalogUri}",
209
+ "warehouse": "${warehouse}",
210
+ }
211
+ )
212
+
213
+ try:
214
+ cat.create_namespace("${namespace}")
215
+ except Exception:
216
+ pass # namespace already exists
217
+
218
+ ${tableBlocks}
219
+
220
+ print("Seed complete.")
221
+ `.trim();
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Main
226
+ // ---------------------------------------------------------------------------
227
+ async function main() {
228
+ console.log(`\nrad seed env=${env} source=${source}\n`);
229
+
230
+ const files = resolveFiles();
231
+ if (files.length === 0) {
232
+ console.log("No CSV files found. Nothing to seed.");
233
+ process.exit(0);
234
+ }
235
+
236
+ console.log(`Found ${files.length} CSV file(s):\n`);
237
+
238
+ const schemas: TableSchema[] = [];
239
+ for (const csvPath of files) {
240
+ const rel = path.relative(repoRoot, csvPath);
241
+ process.stdout.write(` Validating ${rel} ... `);
242
+ try {
243
+ const schema = validateSchema(csvPath);
244
+ schemas.push(schema);
245
+ console.log(
246
+ `OK [${schema.columns.length} cols, ${schema.rowCount} rows]`,
247
+ );
248
+ } catch (err) {
249
+ console.error(`FAIL\n${(err as Error).message}`);
250
+ process.exit(1);
251
+ }
252
+ }
253
+
254
+ // Write fingerprints before seeding so CI can detect schema drift
255
+ for (const schema of schemas) {
256
+ writeFingerprint(schema);
257
+ }
258
+
259
+ // Emit DDL summary
260
+ const ddlDir = path.join(repoRoot, ".rad", "ddl");
261
+ fs.mkdirSync(ddlDir, { recursive: true });
262
+ for (const schema of schemas) {
263
+ const ddl = buildCreateTableSql(schema, env);
264
+ fs.writeFileSync(path.join(ddlDir, `${schema.tableName}.sql`), ddl + "\n");
265
+ }
266
+
267
+ console.log(`\nDDL written to .rad/ddl/`);
268
+
269
+ // Run Python seed via pyiceberg if available
270
+ const seedScript = buildPythonSeedScript(schemas, files);
271
+ const scriptPath = path.join(repoRoot, ".rad", "tmp", "seed.py");
272
+ fs.mkdirSync(path.dirname(scriptPath), { recursive: true });
273
+ fs.writeFileSync(scriptPath, seedScript);
274
+
275
+ const pythonBin = process.env.PYTHON ?? "python";
276
+ try {
277
+ console.log(`\nSeeding Iceberg namespace "${env}" ...\n`);
278
+ // Runs an internally-generated seed script with the operator-configured
279
+ // python binary; no untrusted input, so no command-injection surface.
280
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
281
+ execSync(`${pythonBin} "${scriptPath}"`, { stdio: "inherit" });
282
+ } catch {
283
+ console.error(
284
+ "\nPython seed step failed. Ensure pyiceberg and polars are installed:\n" +
285
+ " pip install pyiceberg polars pyarrow\n\n" +
286
+ `Generated seed script: ${scriptPath}`,
287
+ );
288
+ process.exit(1);
289
+ }
290
+
291
+ console.log("\nDone.");
292
+ }
293
+
294
+ main().catch((err) => {
295
+ console.error(err);
296
+ process.exit(1);
297
+ });
package/sync/engine.ts ADDED
@@ -0,0 +1,392 @@
1
+ import { AxiosInstance } from "axios";
2
+ import { createPersistence, SyncPersistence } from "./persistence";
3
+ import { getEntity, getRegisteredEntities } from "./registry";
4
+ import { SyncStore } from "./store";
5
+ import { TransactionQueue } from "./transactions";
6
+ import {
7
+ BootstrapResponse,
8
+ ChangesResponse,
9
+ EngineStatus,
10
+ EntityDescriptor,
11
+ EntityKey,
12
+ entityKey,
13
+ SyncActionPacket,
14
+ SyncEngineConfig,
15
+ } from "./types";
16
+
17
+ const CURSOR_META_KEY = "lastSyncId";
18
+ const SCHEMA_META_KEY = "schemaHash";
19
+ const READY_META_KEY = "readyEntities";
20
+
21
+ /**
22
+ * The sync engine. Read path: local hydrate -> delta catch-up -> full
23
+ * bootstrap (only when there is no usable local state), then a long-poll
24
+ * `/changes` loop for the app's lifetime. Lazy entities are excluded from
25
+ * the initial bootstrap and partial-bootstrapped on first use. Write path:
26
+ * the TransactionQueue replays optimistic writes through the generated REST
27
+ * endpoints; deltas arriving here rebase and resolve those transactions.
28
+ * A server schema-hash change or a cursor past the server's change-log
29
+ * retention (HTTP 410) drops local state and re-bootstraps.
30
+ */
31
+ export class SyncEngine {
32
+ readonly store: SyncStore;
33
+ readonly queue: TransactionQueue;
34
+ readonly persistence: SyncPersistence;
35
+
36
+ readonly api: AxiosInstance;
37
+ private syncBasePath: string;
38
+ private wait: number;
39
+ private onError?: (error: unknown) => void;
40
+
41
+ private status: EngineStatus = "idle";
42
+ private statusListeners = new Set<() => void>();
43
+ private running = false;
44
+ private lastSyncId: number | null = null;
45
+ private schemaHash: string | undefined;
46
+ private readyKeys = new Set<EntityKey>();
47
+ private inflightBootstraps = new Map<EntityKey, Promise<void>>();
48
+ private pendingEnsures = new Set<EntityKey>();
49
+ private applyLock: Promise<void> = Promise.resolve();
50
+ private pollController: AbortController | null = null;
51
+ private onOnline = () => this.wake();
52
+
53
+ constructor(api: AxiosInstance, config: SyncEngineConfig = {}) {
54
+ this.api = api;
55
+ this.syncBasePath = config.syncBasePath || "/api/sync/v1";
56
+ this.wait = config.wait ?? 25;
57
+ this.onError = config.onError;
58
+ this.store = new SyncStore();
59
+ this.persistence = createPersistence(
60
+ config.persist !== false,
61
+ config.databaseName || "rad-sync",
62
+ );
63
+ this.queue = new TransactionQueue({
64
+ api,
65
+ persistence: this.persistence,
66
+ store: this.store,
67
+ getEntity: (key) => getEntity(key),
68
+ onError: config.onError
69
+ ? (error) => config.onError!(error)
70
+ : undefined,
71
+ });
72
+ }
73
+
74
+ getStatus(): EngineStatus {
75
+ return this.status;
76
+ }
77
+
78
+ subscribeStatus(listener: () => void): () => void {
79
+ this.statusListeners.add(listener);
80
+ return () => {
81
+ this.statusListeners.delete(listener);
82
+ };
83
+ }
84
+
85
+ async start(): Promise<void> {
86
+ if (this.running) {
87
+ return;
88
+ }
89
+ this.running = true;
90
+ if (typeof window !== "undefined") {
91
+ window.addEventListener("online", this.onOnline);
92
+ }
93
+ try {
94
+ this.setStatus("hydrating");
95
+ const cursor = await this.persistence.getMeta(CURSOR_META_KEY);
96
+ this.schemaHash = await this.persistence.getMeta(SCHEMA_META_KEY);
97
+ const readyList: EntityKey[] =
98
+ (await this.persistence.getMeta(READY_META_KEY)) || [];
99
+ this.readyKeys = new Set(readyList);
100
+ if (typeof cursor === "number") {
101
+ this.lastSyncId = cursor;
102
+ await this.hydrateFromLocal();
103
+ }
104
+ await this.queue.resume();
105
+
106
+ const instant = this.entities().filter((entity) => !entity.lazy);
107
+ const missing = instant
108
+ .map(entityKey)
109
+ .filter((key) => !this.readyKeys.has(key));
110
+ if (this.lastSyncId === null || missing.length > 0) {
111
+ this.setStatus("bootstrapping");
112
+ await this.bootstrapEntities(
113
+ this.lastSyncId === null ? instant.map(entityKey) : missing,
114
+ );
115
+ }
116
+ if (this.pendingEnsures.size > 0) {
117
+ const ensures = Array.from(this.pendingEnsures);
118
+ this.pendingEnsures.clear();
119
+ await this.bootstrapEntities(
120
+ ensures.filter((key) => !this.readyKeys.has(key)),
121
+ );
122
+ }
123
+ void this.changesLoop();
124
+ } catch (error) {
125
+ this.setStatus("error");
126
+ this.onError?.(error);
127
+ throw error;
128
+ }
129
+ }
130
+
131
+ stop(): void {
132
+ this.running = false;
133
+ this.pollController?.abort();
134
+ if (typeof window !== "undefined") {
135
+ window.removeEventListener("online", this.onOnline);
136
+ }
137
+ this.setStatus("idle");
138
+ }
139
+
140
+ /**
141
+ * Kick the engine: cancels the in-flight long poll so a fresh `/changes`
142
+ * request fires immediately, and retries any queued writes. This is the
143
+ * hook a push transport (Pusher, API Gateway WebSocket) calls — push only
144
+ * wakes the client, the HTTP protocol stays the source of truth.
145
+ */
146
+ wake(): void {
147
+ this.queue.wake();
148
+ this.pollController?.abort();
149
+ }
150
+
151
+ /**
152
+ * Make sure an entity's data is (being) loaded. No-op once it's ready;
153
+ * for lazy entities this performs the on-demand partial bootstrap. Read
154
+ * hooks call this on mount, so lazy data loads the first time a component
155
+ * asks for it (and late-registered entities still get synced).
156
+ */
157
+ ensureEntity(key: EntityKey): Promise<void> {
158
+ if (this.readyKeys.has(key) || !getEntity(key)) {
159
+ return Promise.resolve();
160
+ }
161
+ if (!this.running) {
162
+ // Requested before start() — remembered and loaded right after.
163
+ this.pendingEnsures.add(key);
164
+ return Promise.resolve();
165
+ }
166
+ return this.bootstrapEntities([key]);
167
+ }
168
+
169
+ isEntityReady(key: EntityKey): boolean {
170
+ return this.readyKeys.has(key);
171
+ }
172
+
173
+ /** Drop local state and re-sync from scratch. */
174
+ async reset(): Promise<void> {
175
+ this.lastSyncId = null;
176
+ this.schemaHash = undefined;
177
+ this.readyKeys = new Set();
178
+ this.inflightBootstraps.clear();
179
+ await this.persistence.clear();
180
+ this.store.clear();
181
+ this.setStatus("bootstrapping");
182
+ // Instant entities reload now; lazy ones re-fetch on next use.
183
+ await this.bootstrapEntities(
184
+ this.entities()
185
+ .filter((entity) => !entity.lazy)
186
+ .map(entityKey),
187
+ );
188
+ }
189
+
190
+ private setStatus(status: EngineStatus): void {
191
+ if (this.status !== status) {
192
+ this.status = status;
193
+ this.statusListeners.forEach((listener) => listener());
194
+ }
195
+ }
196
+
197
+ private entities(): EntityDescriptor[] {
198
+ return getRegisteredEntities();
199
+ }
200
+
201
+ private async hydrateFromLocal(): Promise<void> {
202
+ for (const entity of this.entities()) {
203
+ const key = entityKey(entity);
204
+ if (!this.readyKeys.has(key)) {
205
+ continue; // never bootstrapped (e.g. lazy and untouched) — no local truth
206
+ }
207
+ const rows = await this.persistence.getRecords(key);
208
+ this.store.hydrate(key, rows, entity.idField || "id");
209
+ }
210
+ }
211
+
212
+ /** Bootstrap (or partial-bootstrap) the given entities from the server. */
213
+ private bootstrapEntities(keys: EntityKey[]): Promise<void> {
214
+ const wanted = keys.filter(
215
+ (key) => !this.readyKeys.has(key) && !this.inflightBootstraps.has(key),
216
+ );
217
+ if (wanted.length === 0) {
218
+ const inflight = keys
219
+ .map((key) => this.inflightBootstraps.get(key))
220
+ .filter(Boolean) as Promise<void>[];
221
+ return Promise.all(inflight).then(() => undefined);
222
+ }
223
+ const run = this.fetchBootstrap(wanted).finally(() => {
224
+ wanted.forEach((key) => this.inflightBootstraps.delete(key));
225
+ });
226
+ wanted.forEach((key) => this.inflightBootstraps.set(key, run));
227
+ return run;
228
+ }
229
+
230
+ private async fetchBootstrap(keys: EntityKey[]): Promise<void> {
231
+ if (keys.length === 0) {
232
+ return;
233
+ }
234
+ const response = await this.api.get<BootstrapResponse>(
235
+ `${this.syncBasePath}/bootstrap/`,
236
+ { params: { models: keys.join(",") } },
237
+ );
238
+ const payload = response.data;
239
+ for (const key of keys) {
240
+ const entity = getEntity(key);
241
+ if (!entity) {
242
+ continue;
243
+ }
244
+ const idField = entity.idField || "id";
245
+ const rows = payload.models[key] || [];
246
+ await this.persistence.replaceRecords(
247
+ key,
248
+ rows.map((row) => ({ id: String(row[idField]), data: row })),
249
+ );
250
+ this.store.hydrate(key, rows, idField);
251
+ this.readyKeys.add(key);
252
+ }
253
+ await this.persistence.setMeta(READY_META_KEY, Array.from(this.readyKeys));
254
+
255
+ // The cursor only ever advances: a partial bootstrap snapshot is at
256
+ // least as new as any cursor we already hold.
257
+ if (this.lastSyncId === null || payload.last_sync_id > this.lastSyncId) {
258
+ this.lastSyncId = payload.last_sync_id;
259
+ await this.persistence.setMeta(CURSOR_META_KEY, this.lastSyncId);
260
+ }
261
+ if (payload.schema_hash) {
262
+ this.schemaHash = payload.schema_hash;
263
+ await this.persistence.setMeta(SCHEMA_META_KEY, this.schemaHash);
264
+ }
265
+ }
266
+
267
+ private async changesLoop(): Promise<void> {
268
+ let backoff = 1000;
269
+ while (this.running) {
270
+ this.pollController = new AbortController();
271
+ try {
272
+ const response = await this.api.get<ChangesResponse>(
273
+ `${this.syncBasePath}/changes/`,
274
+ {
275
+ params: { since: this.lastSyncId ?? 0, wait: this.wait },
276
+ signal: this.pollController.signal,
277
+ },
278
+ );
279
+ const changed = await this.checkSchemaHash(
280
+ response.data.schema_hash,
281
+ );
282
+ if (!changed) {
283
+ await this.applyChanges(response.data);
284
+ }
285
+ this.setStatus("live");
286
+ backoff = 1000;
287
+ } catch (error: any) {
288
+ if (!this.running) {
289
+ break;
290
+ }
291
+ if (this.pollController.signal.aborted) {
292
+ // wake() cancelled the poll on purpose — go straight around.
293
+ continue;
294
+ }
295
+ const status = error?.response?.status;
296
+ if (status === 410) {
297
+ // Cursor fell out of the server's change-log retention.
298
+ try {
299
+ await this.reset();
300
+ continue;
301
+ } catch (resetError) {
302
+ this.onError?.(resetError);
303
+ }
304
+ }
305
+ this.setStatus("offline");
306
+ this.onError?.(error);
307
+ await new Promise((resolve) => setTimeout(resolve, backoff));
308
+ backoff = Math.min(backoff * 2, 30000);
309
+ }
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Returns true (after resetting) when the server's schema hash no longer
315
+ * matches the one local state was built against.
316
+ */
317
+ private async checkSchemaHash(hash: string | undefined): Promise<boolean> {
318
+ if (!hash) {
319
+ return false;
320
+ }
321
+ if (this.schemaHash === undefined) {
322
+ this.schemaHash = hash;
323
+ await this.persistence.setMeta(SCHEMA_META_KEY, hash);
324
+ return false;
325
+ }
326
+ if (this.schemaHash === hash) {
327
+ return false;
328
+ }
329
+ await this.reset();
330
+ return true;
331
+ }
332
+
333
+ private applyChanges(payload: ChangesResponse): Promise<void> {
334
+ // Serialize applications so a wake-triggered poll can never interleave
335
+ // with one still being written.
336
+ this.applyLock = this.applyLock.then(() =>
337
+ this.applyChangesExclusive(payload),
338
+ );
339
+ return this.applyLock;
340
+ }
341
+
342
+ private async applyChangesExclusive(payload: ChangesResponse): Promise<void> {
343
+ for (const action of payload.actions) {
344
+ await this.applyAction(action);
345
+ }
346
+ if (payload.last_sync_id > (this.lastSyncId ?? 0)) {
347
+ this.lastSyncId = payload.last_sync_id;
348
+ await this.persistence.setMeta(CURSOR_META_KEY, this.lastSyncId);
349
+ }
350
+ }
351
+
352
+ private async applyAction(action: SyncActionPacket): Promise<void> {
353
+ const entity = getEntity(action.model);
354
+ if (!entity) {
355
+ return; // an entity this bundle doesn't use
356
+ }
357
+ const key = action.model;
358
+ if (entity.lazy && !this.readyKeys.has(key)) {
359
+ return; // not loaded yet — the on-demand bootstrap will include it
360
+ }
361
+ const idField = entity.idField || "id";
362
+ const id = String(action.id);
363
+ const deleted =
364
+ action.op === "D" || (action.data && action.data.deleted_at != null);
365
+
366
+ if (deleted) {
367
+ await this.persistence.deleteRecord(key, id);
368
+ this.store.remove(key, id);
369
+ } else if (action.data) {
370
+ await this.persistence.putRecords(key, [{ id, data: action.data }]);
371
+ const pending = this.queue.pendingFor(key, id);
372
+ if (pending.some((txn) => txn.op === "delete")) {
373
+ // Locally deleted, not yet confirmed: keep it gone from the UI.
374
+ } else if (pending.length > 0) {
375
+ // Rebase: server row underneath, unconfirmed local edits on top
376
+ // (last-writer-wins — the local write supersedes on its next send).
377
+ const overlay = pending.reduce(
378
+ (acc, txn) => ({ ...acc, ...(txn.payload || {}) }),
379
+ {} as Record<string, any>,
380
+ );
381
+ this.store.upsert(key, id, {
382
+ ...action.data,
383
+ ...overlay,
384
+ [idField]: action.data[idField],
385
+ });
386
+ } else {
387
+ this.store.upsert(key, id, action.data);
388
+ }
389
+ }
390
+ await this.queue.resolveForRow(key, id);
391
+ }
392
+ }