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.
- package/CHANGELOG.md +43013 -0
- package/dist/.prettierrc +7 -0
- package/dist/compile/analyze.d.ts +12 -0
- package/dist/compile/analyze.js +233 -0
- package/dist/compile/index.d.ts +19 -0
- package/dist/compile/index.js +107 -0
- package/dist/compile/manifest.d.ts +4 -0
- package/dist/compile/manifest.js +12 -0
- package/dist/compile/model.d.ts +54 -0
- package/dist/compile/model.js +7 -0
- package/dist/compile/openapi.d.ts +11 -0
- package/dist/compile/openapi.js +233 -0
- package/dist/compile/program.d.ts +10 -0
- package/dist/compile/program.js +61 -0
- package/dist/csv-schema.d.ts +28 -0
- package/dist/csv-schema.js +162 -0
- package/dist/define/index.d.ts +117 -0
- package/dist/define/index.js +74 -0
- package/dist/entity-generation/delete-method.d.ts +1 -0
- package/dist/entity-generation/delete-method.js +31 -0
- package/dist/entity-generation/form.d.ts +13 -0
- package/dist/entity-generation/form.js +136 -0
- package/dist/entity-generation/get-method.d.ts +1 -0
- package/dist/entity-generation/get-method.js +67 -0
- package/dist/entity-generation/imports.d.ts +5 -0
- package/dist/entity-generation/imports.js +91 -0
- package/dist/entity-generation/sync-method.d.ts +30 -0
- package/dist/entity-generation/sync-method.js +158 -0
- package/dist/entity-generation/update-method.d.ts +1 -0
- package/dist/entity-generation/update-method.js +29 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +53 -0
- package/dist/rad-compile.d.ts +11 -0
- package/dist/rad-compile.js +82 -0
- package/dist/rad-wasm-build.d.ts +14 -0
- package/dist/rad-wasm-build.js +74 -0
- package/dist/rad.d.ts +17 -0
- package/dist/rad.js +88 -0
- package/dist/scan.d.ts +12 -0
- package/dist/scan.js +79 -0
- package/dist/schema/camel-to-pastel.d.ts +1 -0
- package/dist/schema/camel-to-pastel.js +7 -0
- package/dist/schema/capitalise-first-letter.d.ts +1 -0
- package/dist/schema/capitalise-first-letter.js +7 -0
- package/dist/schema/extract-response.d.ts +2 -0
- package/dist/schema/extract-response.js +30 -0
- package/dist/schema/fetch-schema.d.ts +7 -0
- package/dist/schema/fetch-schema.js +23 -0
- package/dist/schema/remove-non-model-paths.d.ts +2 -0
- package/dist/schema/remove-non-model-paths.js +15 -0
- package/dist/schema/to-camel-case.d.ts +1 -0
- package/dist/schema/to-camel-case.js +11 -0
- package/dist/schema/to-pastel-case.d.ts +1 -0
- package/dist/schema/to-pastel-case.js +10 -0
- package/dist/schema-convert.d.ts +1 -0
- package/dist/schema-convert.js +63 -0
- package/dist/schema-entities.d.ts +14 -0
- package/dist/schema-entities.js +293 -0
- package/dist/schema-fetch.d.ts +1 -0
- package/dist/schema-fetch.js +81 -0
- package/dist/schema-full.d.ts +1 -0
- package/dist/schema-full.js +45 -0
- package/dist/schema-index.d.ts +1 -0
- package/dist/schema-index.js +34 -0
- package/dist/schema-params.d.ts +1 -0
- package/dist/schema-params.js +100 -0
- package/dist/seed.d.ts +17 -0
- package/dist/seed.js +252 -0
- package/hooks/form/form.ts +207 -0
- package/hooks/form/index.ts +8 -0
- package/hooks/form/interfaces.ts +217 -0
- package/hooks/form/structure.ts +39 -0
- package/hooks/form/validate-schema.ts +49 -0
- package/hooks/index.ts +3 -0
- package/hooks/query/api.ts +42 -0
- package/hooks/query/delete.ts +45 -0
- package/hooks/query/fetch.ts +48 -0
- package/hooks/query/index.ts +21 -0
- package/hooks/query/interfaces.ts +111 -0
- package/hooks/query/list.ts +286 -0
- package/hooks/query/update.ts +88 -0
- package/hooks/query/url.ts +31 -0
- package/hooks/real-time/index.ts +1 -0
- package/hooks/real-time/pusher.ts +43 -0
- package/jest.config.cjs +6 -0
- package/package.json +57 -0
- package/provider/index.ts +13 -0
- package/provider/provider.tsx +187 -0
- package/provider/types.ts +114 -0
- package/provider/warehouse.ts +167 -0
- package/provider/wasm.ts +124 -0
- package/runtime.ts +3 -0
- package/src/.prettierrc +7 -0
- package/src/compile/analyze.ts +224 -0
- package/src/compile/index.ts +86 -0
- package/src/compile/manifest.ts +10 -0
- package/src/compile/model.ts +69 -0
- package/src/compile/openapi.ts +266 -0
- package/src/compile/program.ts +40 -0
- package/src/csv-schema.ts +187 -0
- package/src/define/index.ts +162 -0
- package/src/entity-generation/delete-method.ts +37 -0
- package/src/entity-generation/form.ts +169 -0
- package/src/entity-generation/get-method.ts +80 -0
- package/src/entity-generation/imports.ts +110 -0
- package/src/entity-generation/sync-method.ts +222 -0
- package/src/entity-generation/update-method.ts +38 -0
- package/src/index.ts +77 -0
- package/src/rad-compile.ts +88 -0
- package/src/rad-wasm-build.ts +102 -0
- package/src/rad.ts +101 -0
- package/src/scan.ts +89 -0
- package/src/schema/camel-to-pastel.ts +3 -0
- package/src/schema/capitalise-first-letter.ts +3 -0
- package/src/schema/extract-response.ts +38 -0
- package/src/schema/fetch-schema.ts +12 -0
- package/src/schema/remove-non-model-paths.ts +13 -0
- package/src/schema/to-camel-case.ts +7 -0
- package/src/schema/to-pastel-case.ts +6 -0
- package/src/schema-convert.ts +61 -0
- package/src/schema-entities.ts +401 -0
- package/src/schema-fetch.ts +82 -0
- package/src/schema-full.ts +38 -0
- package/src/schema-index.ts +29 -0
- package/src/schema-params.ts +116 -0
- package/src/seed.ts +297 -0
- package/sync/engine.ts +392 -0
- package/sync/hooks.ts +450 -0
- package/sync/index.ts +8 -0
- package/sync/persistence.ts +237 -0
- package/sync/provider.tsx +91 -0
- package/sync/registry.ts +36 -0
- package/sync/store.ts +126 -0
- package/sync/transactions.ts +300 -0
- package/sync/types.ts +94 -0
- package/test/compile.spec.ts +192 -0
- package/test/csv-schema.spec.ts +143 -0
- package/test/schema-entities.spec.ts +688 -0
- package/tsconfig.json +24 -0
- 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
|
+
}
|