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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
ReactNode,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { AxiosInstance } from "axios";
|
|
9
|
+
import { API } from "../hooks/query/api";
|
|
10
|
+
import { SyncEngine } from "./engine";
|
|
11
|
+
import { SyncEngineConfig } from "./types";
|
|
12
|
+
|
|
13
|
+
interface RadSyncContextValue {
|
|
14
|
+
engine: SyncEngine;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const RadSyncContext = createContext<RadSyncContextValue | null>(null);
|
|
18
|
+
|
|
19
|
+
export interface RadSyncProviderProps extends SyncEngineConfig {
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
/** Axios instance to sync through. Defaults to the shared rastack API client. */
|
|
22
|
+
api?: AxiosInstance;
|
|
23
|
+
/** When provided, a Bearer token is attached to every request on the instance. */
|
|
24
|
+
getToken?: () => string | Promise<string>;
|
|
25
|
+
/** Start the engine on mount (default true). */
|
|
26
|
+
autoStart?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Mounts the sync engine for the app. This is the only client-side
|
|
31
|
+
* configuration a consumer does — every generated hook finds the engine
|
|
32
|
+
* through this context.
|
|
33
|
+
*/
|
|
34
|
+
export function RadSyncProvider({
|
|
35
|
+
children,
|
|
36
|
+
api,
|
|
37
|
+
getToken,
|
|
38
|
+
autoStart = true,
|
|
39
|
+
...config
|
|
40
|
+
}: RadSyncProviderProps) {
|
|
41
|
+
const instance = api || API;
|
|
42
|
+
const engine = useMemo(
|
|
43
|
+
() => new SyncEngine(instance, config),
|
|
44
|
+
// The engine is created once for the provider's lifetime.
|
|
45
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
46
|
+
[instance],
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
let interceptor: number | undefined;
|
|
51
|
+
if (getToken) {
|
|
52
|
+
interceptor = instance.interceptors.request.use(async (request) => {
|
|
53
|
+
const token = await getToken();
|
|
54
|
+
request.headers = request.headers || ({} as any);
|
|
55
|
+
(request.headers as any).Authorization = `Bearer ${token}`;
|
|
56
|
+
return request;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
if (autoStart) {
|
|
60
|
+
void engine.start().catch(() => {
|
|
61
|
+
// Reported through config.onError and engine status.
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return () => {
|
|
65
|
+
engine.stop();
|
|
66
|
+
if (interceptor !== undefined) {
|
|
67
|
+
instance.interceptors.request.eject(interceptor);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
71
|
+
}, [engine]);
|
|
72
|
+
|
|
73
|
+
const value = useMemo(() => ({ engine }), [engine]);
|
|
74
|
+
return (
|
|
75
|
+
<RadSyncContext.Provider value={value}>{children}</RadSyncContext.Provider>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function useSyncEngine(): SyncEngine {
|
|
80
|
+
const context = useContext(RadSyncContext);
|
|
81
|
+
if (!context) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
"No RadSyncProvider found. Wrap your app in <RadSyncProvider> to use sync-mode entity hooks.",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return context.engine;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function useSyncEngineOptional(): SyncEngine | null {
|
|
90
|
+
return useContext(RadSyncContext)?.engine ?? null;
|
|
91
|
+
}
|
package/sync/registry.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { EntityDescriptor, EntityKey, entityKey } from "./types";
|
|
2
|
+
|
|
3
|
+
const entities = new Map<EntityKey, EntityDescriptor>();
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Registers a sync-mode entity. Called from generated entity modules at
|
|
7
|
+
* import time, so every entity a bundle uses is known before the engine
|
|
8
|
+
* starts. Idempotent per (app, model).
|
|
9
|
+
*/
|
|
10
|
+
export function defineEntity<TRow = any>(
|
|
11
|
+
descriptor: Omit<EntityDescriptor<TRow>, "__row">,
|
|
12
|
+
): EntityDescriptor<TRow> {
|
|
13
|
+
const key = entityKey(descriptor as EntityDescriptor);
|
|
14
|
+
const existing = entities.get(key);
|
|
15
|
+
if (existing) {
|
|
16
|
+
return existing as EntityDescriptor<TRow>;
|
|
17
|
+
}
|
|
18
|
+
const entity: EntityDescriptor<TRow> = {
|
|
19
|
+
idField: "id",
|
|
20
|
+
...descriptor,
|
|
21
|
+
};
|
|
22
|
+
entities.set(key, entity as EntityDescriptor);
|
|
23
|
+
return entity;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getRegisteredEntities(): EntityDescriptor[] {
|
|
27
|
+
return Array.from(entities.values());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getEntity(key: EntityKey): EntityDescriptor | undefined {
|
|
31
|
+
return entities.get(key);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function clearEntityRegistry(): void {
|
|
35
|
+
entities.clear();
|
|
36
|
+
}
|
package/sync/store.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { EntityKey } from "./types";
|
|
2
|
+
|
|
3
|
+
type Listener = () => void;
|
|
4
|
+
|
|
5
|
+
interface EntityCache {
|
|
6
|
+
rows: Map<string, Record<string, any>>;
|
|
7
|
+
/** Stable array reference for useSyncExternalStore; rebuilt on change. */
|
|
8
|
+
listSnapshot: Record<string, any>[] | null;
|
|
9
|
+
ready: boolean;
|
|
10
|
+
listeners: Set<Listener>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The in-memory object pool. One cache per entity, keyed `${app}.${model}`.
|
|
15
|
+
* The store is only the live working set: durable state lives in the
|
|
16
|
+
* persistence layer and is only written from server deltas, so local disk
|
|
17
|
+
* always remains a subset of server state (Linear's invariant).
|
|
18
|
+
*/
|
|
19
|
+
export class SyncStore {
|
|
20
|
+
private caches = new Map<EntityKey, EntityCache>();
|
|
21
|
+
|
|
22
|
+
private cache(entity: EntityKey): EntityCache {
|
|
23
|
+
let cache = this.caches.get(entity);
|
|
24
|
+
if (!cache) {
|
|
25
|
+
cache = {
|
|
26
|
+
rows: new Map(),
|
|
27
|
+
listSnapshot: null,
|
|
28
|
+
ready: false,
|
|
29
|
+
listeners: new Set(),
|
|
30
|
+
};
|
|
31
|
+
this.caches.set(entity, cache);
|
|
32
|
+
}
|
|
33
|
+
return cache;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get(entity: EntityKey, id: string): Record<string, any> | undefined {
|
|
37
|
+
return this.cache(entity).rows.get(id);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getList(entity: EntityKey): Record<string, any>[] {
|
|
41
|
+
const cache = this.cache(entity);
|
|
42
|
+
if (!cache.listSnapshot) {
|
|
43
|
+
cache.listSnapshot = Array.from(cache.rows.values());
|
|
44
|
+
}
|
|
45
|
+
return cache.listSnapshot;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
isReady(entity: EntityKey): boolean {
|
|
49
|
+
return this.cache(entity).ready;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
markReady(entity: EntityKey): void {
|
|
53
|
+
const cache = this.cache(entity);
|
|
54
|
+
if (!cache.ready) {
|
|
55
|
+
cache.ready = true;
|
|
56
|
+
this.emit(cache);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
upsert(entity: EntityKey, id: string, row: Record<string, any>): void {
|
|
61
|
+
const cache = this.cache(entity);
|
|
62
|
+
cache.rows.set(id, row);
|
|
63
|
+
this.emit(cache);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
merge(
|
|
67
|
+
entity: EntityKey,
|
|
68
|
+
id: string,
|
|
69
|
+
changes: Record<string, any>,
|
|
70
|
+
): Record<string, any> {
|
|
71
|
+
const cache = this.cache(entity);
|
|
72
|
+
const next = { ...(cache.rows.get(id) || {}), ...changes };
|
|
73
|
+
cache.rows.set(id, next);
|
|
74
|
+
this.emit(cache);
|
|
75
|
+
return next;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
remove(entity: EntityKey, id: string): void {
|
|
79
|
+
const cache = this.cache(entity);
|
|
80
|
+
if (cache.rows.delete(id)) {
|
|
81
|
+
this.emit(cache);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
replaceId(entity: EntityKey, fromId: string, toId: string): void {
|
|
86
|
+
const cache = this.cache(entity);
|
|
87
|
+
const row = cache.rows.get(fromId);
|
|
88
|
+
if (row !== undefined) {
|
|
89
|
+
cache.rows.delete(fromId);
|
|
90
|
+
cache.rows.set(toId, row);
|
|
91
|
+
this.emit(cache);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
hydrate(entity: EntityKey, rows: Record<string, any>[], idField: string): void {
|
|
96
|
+
const cache = this.cache(entity);
|
|
97
|
+
rows.forEach((row) => {
|
|
98
|
+
const id = String(row[idField]);
|
|
99
|
+
cache.rows.set(id, row);
|
|
100
|
+
});
|
|
101
|
+
cache.ready = true;
|
|
102
|
+
this.emit(cache);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
subscribe(entity: EntityKey, listener: Listener): () => void {
|
|
106
|
+
const cache = this.cache(entity);
|
|
107
|
+
cache.listeners.add(listener);
|
|
108
|
+
return () => {
|
|
109
|
+
cache.listeners.delete(listener);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
clear(): void {
|
|
114
|
+
this.caches.forEach((cache) => {
|
|
115
|
+
cache.rows.clear();
|
|
116
|
+
cache.listSnapshot = null;
|
|
117
|
+
cache.ready = false;
|
|
118
|
+
cache.listeners.forEach((listener) => listener());
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private emit(cache: EntityCache): void {
|
|
123
|
+
cache.listSnapshot = null;
|
|
124
|
+
cache.listeners.forEach((listener) => listener());
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { AxiosInstance } from "axios";
|
|
2
|
+
import { SyncPersistence } from "./persistence";
|
|
3
|
+
import { SyncStore } from "./store";
|
|
4
|
+
import {
|
|
5
|
+
EntityDescriptor,
|
|
6
|
+
EntityKey,
|
|
7
|
+
SyncTransaction,
|
|
8
|
+
TransactionOp,
|
|
9
|
+
} from "./types";
|
|
10
|
+
|
|
11
|
+
export function randomId(): string {
|
|
12
|
+
const cryptoObject =
|
|
13
|
+
typeof crypto !== "undefined" ? (crypto as Crypto) : undefined;
|
|
14
|
+
if (cryptoObject && typeof cryptoObject.randomUUID === "function") {
|
|
15
|
+
return cryptoObject.randomUUID();
|
|
16
|
+
}
|
|
17
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** RFC 4122 v4 UUID — always a server-acceptable UUID (unlike randomId's fallback). */
|
|
21
|
+
export function uuid4(): string {
|
|
22
|
+
const cryptoObject =
|
|
23
|
+
typeof crypto !== "undefined" ? (crypto as Crypto) : undefined;
|
|
24
|
+
if (cryptoObject && typeof cryptoObject.randomUUID === "function") {
|
|
25
|
+
return cryptoObject.randomUUID();
|
|
26
|
+
}
|
|
27
|
+
const bytes = new Uint8Array(16);
|
|
28
|
+
if (cryptoObject && typeof cryptoObject.getRandomValues === "function") {
|
|
29
|
+
cryptoObject.getRandomValues(bytes);
|
|
30
|
+
} else {
|
|
31
|
+
for (let i = 0; i < 16; i += 1) {
|
|
32
|
+
bytes[i] = Math.floor(Math.random() * 256);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
36
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
37
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"));
|
|
38
|
+
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex
|
|
39
|
+
.slice(6, 8)
|
|
40
|
+
.join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function tempId(): string {
|
|
44
|
+
return `tmp_${randomId()}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isTempId(id: string): boolean {
|
|
48
|
+
return id.startsWith("tmp_");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface TransactionQueueDeps {
|
|
52
|
+
api: AxiosInstance;
|
|
53
|
+
persistence: SyncPersistence;
|
|
54
|
+
store: SyncStore;
|
|
55
|
+
getEntity: (key: EntityKey) => EntityDescriptor | undefined;
|
|
56
|
+
onError?: (error: unknown, txn: SyncTransaction) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface EnqueueInput {
|
|
60
|
+
entity: EntityKey;
|
|
61
|
+
op: TransactionOp;
|
|
62
|
+
id: string;
|
|
63
|
+
method: SyncTransaction["method"];
|
|
64
|
+
url: string;
|
|
65
|
+
payload: Record<string, any> | null;
|
|
66
|
+
original: Record<string, any> | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The optimistic write path. Changes apply to the in-memory store
|
|
71
|
+
* immediately, persist to the local transaction log (so offline writes
|
|
72
|
+
* survive reload), then replay through the normal generated REST endpoints
|
|
73
|
+
* in order. Server rejection (4xx) rolls the optimistic change back;
|
|
74
|
+
* network failure retries with backoff and resumes on reconnect.
|
|
75
|
+
*/
|
|
76
|
+
export class TransactionQueue {
|
|
77
|
+
private pending: SyncTransaction[] = [];
|
|
78
|
+
private idAliases = new Map<string, string>();
|
|
79
|
+
private settlers = new Map<
|
|
80
|
+
string,
|
|
81
|
+
{ resolve: (data: any) => void; reject: (error: unknown) => void }
|
|
82
|
+
>();
|
|
83
|
+
private flushing = false;
|
|
84
|
+
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
85
|
+
private retryDelay = 1000;
|
|
86
|
+
|
|
87
|
+
constructor(private deps: TransactionQueueDeps) {}
|
|
88
|
+
|
|
89
|
+
/** Load persisted transactions after a reload and re-apply their optimistic effect. */
|
|
90
|
+
async resume(): Promise<void> {
|
|
91
|
+
const persisted = await this.deps.persistence.getTransactions();
|
|
92
|
+
for (const txn of persisted) {
|
|
93
|
+
if (txn.status === "completed") {
|
|
94
|
+
// Server already confirmed it before the reload; catch-up sync will
|
|
95
|
+
// deliver the authoritative row, so the log entry is done.
|
|
96
|
+
await this.deps.persistence.deleteTransaction(txn.txnId);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
txn.status = "queued";
|
|
100
|
+
this.pending.push(txn);
|
|
101
|
+
this.applyOptimistic(txn);
|
|
102
|
+
}
|
|
103
|
+
void this.flush();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async enqueue(input: EnqueueInput): Promise<any> {
|
|
107
|
+
const txn: SyncTransaction = {
|
|
108
|
+
txnId: randomId(),
|
|
109
|
+
status: "queued",
|
|
110
|
+
createdAt: Date.now(),
|
|
111
|
+
...input,
|
|
112
|
+
};
|
|
113
|
+
this.applyOptimistic(txn);
|
|
114
|
+
this.pending.push(txn);
|
|
115
|
+
await this.deps.persistence.putTransaction(txn);
|
|
116
|
+
const settled = new Promise<any>((resolve, reject) => {
|
|
117
|
+
this.settlers.set(txn.txnId, { resolve, reject });
|
|
118
|
+
});
|
|
119
|
+
void this.flush();
|
|
120
|
+
return settled;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Pending (not yet server-confirmed) transactions touching a row — used for rebase. */
|
|
124
|
+
pendingFor(entity: EntityKey, id: string): SyncTransaction[] {
|
|
125
|
+
return this.pending.filter(
|
|
126
|
+
(txn) =>
|
|
127
|
+
txn.entity === entity &&
|
|
128
|
+
(txn.id === id || this.idAliases.get(txn.id) === id) &&
|
|
129
|
+
txn.status !== "completed",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A server delta touched this row: confirmed transactions for it are fully synced. */
|
|
134
|
+
async resolveForRow(entity: EntityKey, id: string): Promise<void> {
|
|
135
|
+
const done = this.pending.filter(
|
|
136
|
+
(txn) =>
|
|
137
|
+
txn.entity === entity &&
|
|
138
|
+
(txn.id === id || this.idAliases.get(txn.id) === id) &&
|
|
139
|
+
txn.status === "completed",
|
|
140
|
+
);
|
|
141
|
+
for (const txn of done) {
|
|
142
|
+
this.pending = this.pending.filter((item) => item !== txn);
|
|
143
|
+
await this.deps.persistence.deleteTransaction(txn.txnId);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
hasPending(): boolean {
|
|
148
|
+
return this.pending.some((txn) => txn.status !== "completed");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
resolveAlias(id: string): string {
|
|
152
|
+
return this.idAliases.get(id) || id;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
wake(): void {
|
|
156
|
+
this.retryDelay = 1000;
|
|
157
|
+
void this.flush();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private applyOptimistic(txn: SyncTransaction): void {
|
|
161
|
+
const { store, getEntity } = this.deps;
|
|
162
|
+
const entity = getEntity(txn.entity);
|
|
163
|
+
const idField = entity?.idField || "id";
|
|
164
|
+
if (txn.op === "create") {
|
|
165
|
+
store.upsert(txn.entity, txn.id, {
|
|
166
|
+
[idField]: txn.id,
|
|
167
|
+
...(txn.payload || {}),
|
|
168
|
+
});
|
|
169
|
+
} else if (txn.op === "update") {
|
|
170
|
+
store.merge(txn.entity, txn.id, txn.payload || {});
|
|
171
|
+
} else if (txn.op === "delete") {
|
|
172
|
+
store.remove(txn.entity, txn.id);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private rollback(txn: SyncTransaction): void {
|
|
177
|
+
const { store } = this.deps;
|
|
178
|
+
if (txn.op === "create") {
|
|
179
|
+
store.remove(txn.entity, txn.id);
|
|
180
|
+
} else if (txn.op === "update" && txn.original) {
|
|
181
|
+
store.merge(txn.entity, txn.id, txn.original);
|
|
182
|
+
} else if (txn.op === "delete" && txn.original) {
|
|
183
|
+
store.upsert(txn.entity, txn.id, txn.original);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private rewriteTempIds(txn: SyncTransaction): void {
|
|
188
|
+
const realId = this.idAliases.get(txn.id);
|
|
189
|
+
if (!realId) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
txn.url = txn.url.split(txn.id).join(realId);
|
|
193
|
+
if (txn.payload) {
|
|
194
|
+
Object.keys(txn.payload).forEach((key) => {
|
|
195
|
+
if (txn.payload![key] === txn.id) {
|
|
196
|
+
txn.payload![key] = realId;
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
txn.id = realId;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private async flush(): Promise<void> {
|
|
204
|
+
if (this.flushing) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
this.flushing = true;
|
|
208
|
+
try {
|
|
209
|
+
for (;;) {
|
|
210
|
+
const txn = this.pending.find((item) => item.status === "queued");
|
|
211
|
+
if (!txn) {
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
if (typeof navigator !== "undefined" && navigator.onLine === false) {
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
this.rewriteTempIds(txn);
|
|
218
|
+
txn.status = "inflight";
|
|
219
|
+
await this.deps.persistence.putTransaction(txn);
|
|
220
|
+
try {
|
|
221
|
+
const response = await this.send(txn);
|
|
222
|
+
await this.confirm(txn, response);
|
|
223
|
+
this.retryDelay = 1000;
|
|
224
|
+
} catch (error: any) {
|
|
225
|
+
const status = error?.response?.status;
|
|
226
|
+
const isRejection =
|
|
227
|
+
typeof status === "number" &&
|
|
228
|
+
status >= 400 &&
|
|
229
|
+
status < 500 &&
|
|
230
|
+
status !== 408 &&
|
|
231
|
+
status !== 429;
|
|
232
|
+
if (isRejection) {
|
|
233
|
+
// The server refused the write (validation, permissions):
|
|
234
|
+
// roll the optimistic change back and surface the error.
|
|
235
|
+
this.rollback(txn);
|
|
236
|
+
this.pending = this.pending.filter((item) => item !== txn);
|
|
237
|
+
await this.deps.persistence.deleteTransaction(txn.txnId);
|
|
238
|
+
this.settlers.get(txn.txnId)?.reject(error);
|
|
239
|
+
this.settlers.delete(txn.txnId);
|
|
240
|
+
this.deps.onError?.(error, txn);
|
|
241
|
+
} else {
|
|
242
|
+
// Network failure or server trouble: keep the optimistic state,
|
|
243
|
+
// stay queued, retry with backoff.
|
|
244
|
+
txn.status = "queued";
|
|
245
|
+
await this.deps.persistence.putTransaction(txn);
|
|
246
|
+
this.scheduleRetry();
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} finally {
|
|
252
|
+
this.flushing = false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private async send(txn: SyncTransaction): Promise<any> {
|
|
257
|
+
const { api } = this.deps;
|
|
258
|
+
switch (txn.method) {
|
|
259
|
+
case "post":
|
|
260
|
+
return (await api.post(txn.url, txn.payload)).data;
|
|
261
|
+
case "put":
|
|
262
|
+
return (await api.put(txn.url, txn.payload)).data;
|
|
263
|
+
case "patch":
|
|
264
|
+
return (await api.patch(txn.url, txn.payload)).data;
|
|
265
|
+
case "delete":
|
|
266
|
+
return (await api.delete(txn.url)).data;
|
|
267
|
+
default:
|
|
268
|
+
throw new Error(`Unsupported method: ${txn.method}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private async confirm(txn: SyncTransaction, response: any): Promise<void> {
|
|
273
|
+
const entity = this.deps.getEntity(txn.entity);
|
|
274
|
+
const idField = entity?.idField || "id";
|
|
275
|
+
if (txn.op === "create" && response && response[idField] !== undefined) {
|
|
276
|
+
const realId = String(response[idField]);
|
|
277
|
+
if (isTempId(txn.id) && realId !== txn.id) {
|
|
278
|
+
this.idAliases.set(txn.id, realId);
|
|
279
|
+
this.deps.store.replaceId(txn.entity, txn.id, realId);
|
|
280
|
+
this.deps.store.merge(txn.entity, realId, response);
|
|
281
|
+
txn.id = realId;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
txn.status = "completed";
|
|
285
|
+
await this.deps.persistence.putTransaction(txn);
|
|
286
|
+
this.settlers.get(txn.txnId)?.resolve(response);
|
|
287
|
+
this.settlers.delete(txn.txnId);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private scheduleRetry(): void {
|
|
291
|
+
if (this.retryTimer) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
this.retryTimer = setTimeout(() => {
|
|
295
|
+
this.retryTimer = null;
|
|
296
|
+
void this.flush();
|
|
297
|
+
}, this.retryDelay);
|
|
298
|
+
this.retryDelay = Math.min(this.retryDelay * 2, 30000);
|
|
299
|
+
}
|
|
300
|
+
}
|
package/sync/types.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export type EntityKey = string; // `${app}.${model}`
|
|
2
|
+
|
|
3
|
+
export interface EntityDescriptor<TRow = any> {
|
|
4
|
+
app: string;
|
|
5
|
+
model: string;
|
|
6
|
+
/** Collection endpoint, e.g. /api/shop/v1/pets */
|
|
7
|
+
listUrl: string;
|
|
8
|
+
/** Detail endpoint builder, e.g. (id) => `/api/shop/v1/pets/${id}` */
|
|
9
|
+
detailUrl?: (id: string) => string;
|
|
10
|
+
/** Primary key field on the row. Defaults to "id". */
|
|
11
|
+
idField?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Lazy entities (Sync.load = "lazy") are excluded from the initial
|
|
14
|
+
* bootstrap and partial-bootstrapped on first use instead.
|
|
15
|
+
*/
|
|
16
|
+
lazy?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Sync.client_ids = True: the row's primary key is a client-generated
|
|
19
|
+
* UUID, so creates carry their real id from the start (no temp-id
|
|
20
|
+
* reconciliation) and offline-created rows can be referenced immediately.
|
|
21
|
+
*/
|
|
22
|
+
clientIds?: boolean;
|
|
23
|
+
/** Phantom field so TRow is used; never set at runtime. */
|
|
24
|
+
__row?: TRow;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function entityKey(entity: EntityDescriptor): EntityKey {
|
|
28
|
+
return `${entity.app}.${entity.model}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type SyncOp = "I" | "U" | "D";
|
|
32
|
+
|
|
33
|
+
export interface SyncActionPacket {
|
|
34
|
+
op: SyncOp;
|
|
35
|
+
model: EntityKey;
|
|
36
|
+
id: string;
|
|
37
|
+
data: Record<string, any> | null;
|
|
38
|
+
sync_id: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ChangesResponse {
|
|
42
|
+
actions: SyncActionPacket[];
|
|
43
|
+
last_sync_id: number;
|
|
44
|
+
/** Hash of the sync-relevant server schema; a change forces a re-bootstrap. */
|
|
45
|
+
schema_hash?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface BootstrapResponse {
|
|
49
|
+
models: { [key: EntityKey]: Record<string, any>[] };
|
|
50
|
+
last_sync_id: number;
|
|
51
|
+
schema_hash?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type TransactionOp = "create" | "update" | "delete";
|
|
55
|
+
|
|
56
|
+
export type TransactionStatus = "queued" | "inflight" | "completed";
|
|
57
|
+
|
|
58
|
+
export interface SyncTransaction {
|
|
59
|
+
txnId: string;
|
|
60
|
+
entity: EntityKey;
|
|
61
|
+
op: TransactionOp;
|
|
62
|
+
/** Row id. Creates use a temporary `tmp_*` id until the server echoes the real one. */
|
|
63
|
+
id: string;
|
|
64
|
+
method: "post" | "put" | "patch" | "delete";
|
|
65
|
+
url: string;
|
|
66
|
+
payload: Record<string, any> | null;
|
|
67
|
+
/**
|
|
68
|
+
* Rollback snapshot: for updates, the previous values of the changed
|
|
69
|
+
* fields; for deletes, the full previous row; null for creates.
|
|
70
|
+
*/
|
|
71
|
+
original: Record<string, any> | null;
|
|
72
|
+
status: TransactionStatus;
|
|
73
|
+
createdAt: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type EngineStatus =
|
|
77
|
+
| "idle"
|
|
78
|
+
| "hydrating"
|
|
79
|
+
| "bootstrapping"
|
|
80
|
+
| "live"
|
|
81
|
+
| "offline"
|
|
82
|
+
| "error";
|
|
83
|
+
|
|
84
|
+
export interface SyncEngineConfig {
|
|
85
|
+
/** Base path of the sync endpoints. */
|
|
86
|
+
syncBasePath?: string; // default /api/sync/v1
|
|
87
|
+
/** Long-poll hold time requested from /changes, seconds. */
|
|
88
|
+
wait?: number; // default 25
|
|
89
|
+
/** Persist to IndexedDB (default true; falls back to memory when unavailable). */
|
|
90
|
+
persist?: boolean;
|
|
91
|
+
/** Database name for IndexedDB. */
|
|
92
|
+
databaseName?: string; // default "rad-sync"
|
|
93
|
+
onError?: (error: unknown) => void;
|
|
94
|
+
}
|