@lenne.tech/nuxt-extensions 1.20.0 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +64 -0
- package/README.md +81 -0
- package/dist/module.d.mts +3 -3
- package/dist/module.json +1 -1
- package/dist/module.mjs +14 -1
- package/dist/runtime/components/transition/LtTransitionFade.d.vue.ts +5 -3
- package/dist/runtime/components/transition/LtTransitionFade.vue.d.ts +5 -3
- package/dist/runtime/components/transition/LtTransitionFadeScale.d.vue.ts +5 -3
- package/dist/runtime/components/transition/LtTransitionFadeScale.vue.d.ts +5 -3
- package/dist/runtime/components/transition/LtTransitionSlide.d.vue.ts +5 -3
- package/dist/runtime/components/transition/LtTransitionSlide.vue.d.ts +5 -3
- package/dist/runtime/components/transition/LtTransitionSlideBottom.d.vue.ts +5 -3
- package/dist/runtime/components/transition/LtTransitionSlideBottom.vue.d.ts +5 -3
- package/dist/runtime/components/transition/LtTransitionSlideRevert.d.vue.ts +5 -3
- package/dist/runtime/components/transition/LtTransitionSlideRevert.vue.d.ts +5 -3
- package/dist/runtime/composables/auth/use-lt-auth.js +16 -1
- package/dist/runtime/composables/index.d.ts +1 -0
- package/dist/runtime/composables/index.js +1 -0
- package/dist/runtime/composables/use-lt-offline-sync.d.ts +21 -0
- package/dist/runtime/composables/use-lt-offline-sync.js +250 -0
- package/dist/runtime/lib/index.d.ts +2 -0
- package/dist/runtime/lib/index.js +2 -0
- package/dist/runtime/lib/sync-hints.d.ts +46 -0
- package/dist/runtime/lib/sync-hints.js +147 -0
- package/dist/runtime/lib/sync-store.d.ts +89 -0
- package/dist/runtime/lib/sync-store.js +149 -0
- package/dist/runtime/lib/sync.d.ts +89 -0
- package/dist/runtime/lib/sync.js +369 -0
- package/dist/runtime/plugins/form-label-association.client.d.ts +2 -1
- package/dist/runtime/plugins/pre-hydration-input.client.d.ts +2 -1
- package/dist/runtime/types/index.d.ts +1 -0
- package/dist/runtime/types/module.d.ts +7 -0
- package/dist/runtime/types/sync.d.ts +127 -0
- package/dist/runtime/types/sync.js +0 -0
- package/package.json +9 -8
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
export const LT_SYNC_DB_NAME = "lt-offline-sync";
|
|
2
|
+
export const LT_SYNC_CHANNEL_NAME = "lt-offline-sync";
|
|
3
|
+
const STORES = ["meta", "outbox", "records"];
|
|
4
|
+
const done = (request) => new Promise((resolve, reject) => {
|
|
5
|
+
request.addEventListener("success", () => resolve(request.result));
|
|
6
|
+
request.addEventListener("error", () => reject(request.error));
|
|
7
|
+
});
|
|
8
|
+
let connection;
|
|
9
|
+
let opening;
|
|
10
|
+
function openDatabase() {
|
|
11
|
+
if (connection) return Promise.resolve(connection);
|
|
12
|
+
opening ??= (async () => {
|
|
13
|
+
const request = indexedDB.open(LT_SYNC_DB_NAME, 1);
|
|
14
|
+
request.addEventListener("upgradeneeded", () => {
|
|
15
|
+
const db = request.result;
|
|
16
|
+
db.createObjectStore("records", { keyPath: ["model", "id"] }).createIndex("model", "model");
|
|
17
|
+
const outbox = db.createObjectStore("outbox", { autoIncrement: true, keyPath: "seq" });
|
|
18
|
+
outbox.createIndex("mutationId", "mutationId", { unique: true });
|
|
19
|
+
outbox.createIndex("model", "model");
|
|
20
|
+
db.createObjectStore("meta", { keyPath: "key" });
|
|
21
|
+
});
|
|
22
|
+
try {
|
|
23
|
+
const db = await done(request);
|
|
24
|
+
db.addEventListener("versionchange", () => {
|
|
25
|
+
db.close();
|
|
26
|
+
if (connection === db) connection = void 0;
|
|
27
|
+
});
|
|
28
|
+
connection = db;
|
|
29
|
+
return db;
|
|
30
|
+
} finally {
|
|
31
|
+
opening = void 0;
|
|
32
|
+
}
|
|
33
|
+
})();
|
|
34
|
+
return opening;
|
|
35
|
+
}
|
|
36
|
+
export class LtSyncOwnerChangedError extends Error {
|
|
37
|
+
constructor() {
|
|
38
|
+
super("the local sync data changed owner");
|
|
39
|
+
this.name = "LtSyncOwnerChangedError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function runLtSyncTx(model, fn, options = {}) {
|
|
43
|
+
const db = await openDatabase();
|
|
44
|
+
const transaction = db.transaction(STORES, "readwrite");
|
|
45
|
+
const finished = new Promise((resolve, reject) => {
|
|
46
|
+
transaction.addEventListener("complete", () => resolve());
|
|
47
|
+
transaction.addEventListener("abort", () => reject(transaction.error ?? new Error("transaction aborted")));
|
|
48
|
+
transaction.addEventListener("error", () => reject(transaction.error));
|
|
49
|
+
});
|
|
50
|
+
const store = (name) => transaction.objectStore(name);
|
|
51
|
+
const strip = (stored) => {
|
|
52
|
+
if (!stored) return void 0;
|
|
53
|
+
const { model: _model, ...record } = stored;
|
|
54
|
+
return record;
|
|
55
|
+
};
|
|
56
|
+
const tx = {
|
|
57
|
+
addOutbox: (item) => {
|
|
58
|
+
const { seq: _seq, ...rest } = item;
|
|
59
|
+
return done(store("outbox").add({ ...rest, model }));
|
|
60
|
+
},
|
|
61
|
+
clearRecords: async (keepIds) => {
|
|
62
|
+
const keys = await done(store("records").index("model").getAllKeys(model));
|
|
63
|
+
for (const key of keys) {
|
|
64
|
+
if (!keepIds.has(key[1])) await done(store("records").delete(key));
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
deleteMeta: (key) => done(store("meta").delete(key)),
|
|
68
|
+
deleteRecord: (id) => done(store("records").delete([model, id])),
|
|
69
|
+
getMeta: async (key) => (await done(store("meta").get(key)))?.value,
|
|
70
|
+
getRecord: async (id) => strip(await done(store("records").get([model, id]))),
|
|
71
|
+
listOutbox: async () => {
|
|
72
|
+
const items = await done(store("outbox").index("model").getAll(model));
|
|
73
|
+
return items.map(({ model: _model, ...item }) => item);
|
|
74
|
+
},
|
|
75
|
+
listRecords: async () => (await done(store("records").index("model").getAll(model))).map((record) => strip(record)),
|
|
76
|
+
putOutbox: (item) => done(store("outbox").put({ ...item, model })),
|
|
77
|
+
putRecord: (record) => done(store("records").put({ ...record, model })),
|
|
78
|
+
removeOutbox: async (mutationId) => {
|
|
79
|
+
const key = await done(store("outbox").index("mutationId").getKey(mutationId));
|
|
80
|
+
if (key !== void 0) await done(store("outbox").delete(key));
|
|
81
|
+
},
|
|
82
|
+
setMeta: (key, value) => done(store("meta").put({ key, value }))
|
|
83
|
+
};
|
|
84
|
+
let result;
|
|
85
|
+
try {
|
|
86
|
+
if (options.owner !== void 0 && await tx.getMeta("owner") !== options.owner) throw new LtSyncOwnerChangedError();
|
|
87
|
+
result = await fn(tx);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
transaction.abort();
|
|
90
|
+
await finished.catch(() => void 0);
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
await finished;
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
export async function listLtSyncPendingModels() {
|
|
97
|
+
const db = await openDatabase();
|
|
98
|
+
const transaction = db.transaction(["outbox"], "readonly");
|
|
99
|
+
const items = await done(transaction.objectStore("outbox").getAll());
|
|
100
|
+
return [...new Set(items.map((item) => item.model))];
|
|
101
|
+
}
|
|
102
|
+
export function getLtSyncOwner() {
|
|
103
|
+
return runLtSyncTx("", (tx) => tx.getMeta("owner"));
|
|
104
|
+
}
|
|
105
|
+
export async function claimLtSyncOwner(userId) {
|
|
106
|
+
const db = await openDatabase();
|
|
107
|
+
const transaction = db.transaction(STORES, "readwrite");
|
|
108
|
+
const finished = new Promise((resolve, reject) => {
|
|
109
|
+
transaction.addEventListener("complete", () => resolve());
|
|
110
|
+
transaction.addEventListener("abort", () => reject(transaction.error ?? new Error("transaction aborted")));
|
|
111
|
+
transaction.addEventListener("error", () => reject(transaction.error));
|
|
112
|
+
});
|
|
113
|
+
const meta = transaction.objectStore("meta");
|
|
114
|
+
const owner = (await done(meta.get("owner")))?.value;
|
|
115
|
+
let wiped = false;
|
|
116
|
+
if (owner !== userId) {
|
|
117
|
+
const held = await done(transaction.objectStore("records").count()) + await done(transaction.objectStore("outbox").count()) + await done(meta.count());
|
|
118
|
+
if (held > 0) {
|
|
119
|
+
for (const name of STORES) await done(transaction.objectStore(name).clear());
|
|
120
|
+
wiped = true;
|
|
121
|
+
}
|
|
122
|
+
await done(meta.put({ key: "owner", value: userId }));
|
|
123
|
+
}
|
|
124
|
+
await finished;
|
|
125
|
+
if (wiped) postLtSyncMessage({ type: "cleared" });
|
|
126
|
+
return wiped;
|
|
127
|
+
}
|
|
128
|
+
export async function deleteLtSyncDatabase() {
|
|
129
|
+
if (typeof indexedDB === "undefined") return;
|
|
130
|
+
await opening?.catch(() => void 0);
|
|
131
|
+
connection?.close();
|
|
132
|
+
connection = void 0;
|
|
133
|
+
await new Promise((resolve, reject) => {
|
|
134
|
+
const request = indexedDB.deleteDatabase(LT_SYNC_DB_NAME);
|
|
135
|
+
request.addEventListener("success", () => resolve());
|
|
136
|
+
request.addEventListener("error", () => reject(request.error));
|
|
137
|
+
request.addEventListener("blocked", () => void 0);
|
|
138
|
+
});
|
|
139
|
+
postLtSyncMessage({ type: "cleared" });
|
|
140
|
+
}
|
|
141
|
+
function postLtSyncMessage(message) {
|
|
142
|
+
if (typeof BroadcastChannel === "undefined") return;
|
|
143
|
+
const channel = new BroadcastChannel(LT_SYNC_CHANNEL_NAME);
|
|
144
|
+
try {
|
|
145
|
+
channel.postMessage(message);
|
|
146
|
+
} finally {
|
|
147
|
+
channel.close();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline-sync client of the nest-server sync module (>= 12.2.0).
|
|
3
|
+
*
|
|
4
|
+
* The protocol is `docs/offline-sync-protocol.md` in @lenne.tech/nest-server; section numbers (§n)
|
|
5
|
+
* below point into it. This file is the protocol engine without Vue: local changes into the outbox,
|
|
6
|
+
* push, pull, conflicts, resync. `useLtOfflineSync()` adds reactivity, triggers and tab
|
|
7
|
+
* coordination on top. Ported from the nest-server's reference test client
|
|
8
|
+
* (`tests/clients/sync-client.mjs`), which passes the protocol's browser and Node scenarios.
|
|
9
|
+
*
|
|
10
|
+
* A local record is `{ id, version, updatedAt, deletedAt, data }`; `version` 0 means "created
|
|
11
|
+
* here, not on the server yet". The outbox holds the unsent changes in the order they were made.
|
|
12
|
+
* An item a push has carried (`attempted`) is never changed again: the server may have recorded it
|
|
13
|
+
* under its mutation id, and a repeat must carry the same change (§6.2, §6.5). A later edit
|
|
14
|
+
* becomes an item of its own.
|
|
15
|
+
*/
|
|
16
|
+
import type { LtSyncConflict, LtSyncConflictResolution, LtSyncIdFormat, LtSyncRecord, LtSyncResult } from '../types/sync.js';
|
|
17
|
+
/** The request could not reach the server */
|
|
18
|
+
export declare class LtSyncOfflineError extends Error {
|
|
19
|
+
constructor(cause: unknown);
|
|
20
|
+
}
|
|
21
|
+
/** The server answered 401: the session ended. Nothing was applied, nothing is discarded (§4) */
|
|
22
|
+
export declare class LtSyncUnauthorizedError extends Error {
|
|
23
|
+
constructor();
|
|
24
|
+
}
|
|
25
|
+
/** The server asked to slow down: 429 with `Retry-After`, or 503 (§10) */
|
|
26
|
+
export declare class LtSyncBackoffError extends Error {
|
|
27
|
+
readonly status: number;
|
|
28
|
+
readonly retryAfterMs: number | undefined;
|
|
29
|
+
constructor(status: number, retryAfterMs: number | undefined);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* An ObjectId-hex id with the random tail §3 requires: 4 timestamp bytes plus 8 bytes from a secure
|
|
33
|
+
* source, fresh for every id. A driver's default ObjectId would make the next id predictable.
|
|
34
|
+
*/
|
|
35
|
+
export declare function ltSyncObjectId(): string;
|
|
36
|
+
/** A uuidv7: 48 bits of milliseconds, version 7, the variant, the rest random (`crypto.randomUUID()` is v4) */
|
|
37
|
+
export declare function ltSyncUuidV7(): string;
|
|
38
|
+
/** The sync base path from runtime config (default `/sync`) */
|
|
39
|
+
export declare function getLtSyncBasePath(): string;
|
|
40
|
+
/** A full URL for a sync path, e.g. `/note?limit=200` or `/hints?models=note` */
|
|
41
|
+
export declare function buildLtSyncUrl(path: string): string;
|
|
42
|
+
/** Options of the protocol engine */
|
|
43
|
+
export interface LtSyncEngineOptions<T> {
|
|
44
|
+
/** `fetch` for the requests (default: `ltAuthFetch`, cookie or JWT like every other request) */
|
|
45
|
+
fetch?: (input: string, init: RequestInit) => Promise<Response>;
|
|
46
|
+
idFormat?: LtSyncIdFormat;
|
|
47
|
+
/** The model's name under the sync base path */
|
|
48
|
+
model: string;
|
|
49
|
+
onConflict?: (conflict: LtSyncConflict<T>) => LtSyncConflictResolution<T> | Promise<LtSyncConflictResolution<T>>;
|
|
50
|
+
pageLimit?: number;
|
|
51
|
+
tenantId?: string;
|
|
52
|
+
}
|
|
53
|
+
/** The protocol engine of one model */
|
|
54
|
+
export interface LtSyncEngine<T> {
|
|
55
|
+
create: (data: T) => Promise<LtSyncRecord<T>>;
|
|
56
|
+
get: (id: string) => Promise<LtSyncRecord<T> | undefined>;
|
|
57
|
+
/** The live local records, sorted by id */
|
|
58
|
+
list: () => Promise<LtSyncRecord<T>[]>;
|
|
59
|
+
readonly model: string;
|
|
60
|
+
/** Where the engine keeps its data: the model, or the model and the tenant */
|
|
61
|
+
readonly scope: string;
|
|
62
|
+
pendingCount: () => Promise<number>;
|
|
63
|
+
/** Push the outbox only — the sign-out flush. Never throws */
|
|
64
|
+
pushPending: () => Promise<void>;
|
|
65
|
+
remove: (id: string) => Promise<void>;
|
|
66
|
+
/** Push the outbox, then pull until `hasMore` is false. Never throws for offline, 401 or a backoff */
|
|
67
|
+
sync: () => Promise<LtSyncResult>;
|
|
68
|
+
update: (id: string, data: Partial<T>) => Promise<LtSyncRecord<T>>;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Where a model's local data lives: per model, and per tenant when one is given. A change made
|
|
72
|
+
* under one tenant must never go out with another tenant's `X-Tenant-Id` — the server stamps a
|
|
73
|
+
* create into the tenant of the header — nor show in that tenant's list.
|
|
74
|
+
*/
|
|
75
|
+
export declare function ltSyncScope(model: string, tenantId?: string): string;
|
|
76
|
+
/** Create the protocol engine of one model and register it for the sign-out flush */
|
|
77
|
+
export declare function createLtSyncEngine<T extends Record<string, unknown> = Record<string, unknown>>(options: LtSyncEngineOptions<T>): LtSyncEngine<T>;
|
|
78
|
+
/**
|
|
79
|
+
* Before sign-out: send what is still unsent, for at most `timeoutMs`, never failing. Whatever
|
|
80
|
+
* did not go out is deleted with the rest afterwards — after signing out it could not be pushed
|
|
81
|
+
* under this account anyway (nest-server `SECURITY.md`).
|
|
82
|
+
*
|
|
83
|
+
* Only when the local data belongs to `userId`, the user signing out. After an expired session
|
|
84
|
+
* the outbox of the PREVIOUS user is still on the device; sent now, it would be created and
|
|
85
|
+
* changed under the current user's session and account. It is deleted unsent instead.
|
|
86
|
+
*/
|
|
87
|
+
export declare function flushLtSyncBeforeSignOut(userId: string | undefined, timeoutMs?: number): Promise<void>;
|
|
88
|
+
/** Forget the registered engines (tests) */
|
|
89
|
+
export declare function resetLtSyncEngines(): void;
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { useRuntimeConfig } from "#imports";
|
|
2
|
+
import { buildLtApiUrl, ltAuthFetch } from "./auth-state.js";
|
|
3
|
+
import { getLtSyncOwner, listLtSyncPendingModels, LtSyncOwnerChangedError, runLtSyncTx } from "./sync-store.js";
|
|
4
|
+
const PUSH_CHUNK = 100;
|
|
5
|
+
const MAX_PUSH_ROUNDS = 50;
|
|
6
|
+
export class LtSyncOfflineError extends Error {
|
|
7
|
+
constructor(cause) {
|
|
8
|
+
super(`offline: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
9
|
+
this.name = "LtSyncOfflineError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class LtSyncUnauthorizedError extends Error {
|
|
13
|
+
constructor() {
|
|
14
|
+
super("unauthorized");
|
|
15
|
+
this.name = "LtSyncUnauthorizedError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export class LtSyncBackoffError extends Error {
|
|
19
|
+
constructor(status, retryAfterMs2) {
|
|
20
|
+
super(`server answered ${status}`);
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.retryAfterMs = retryAfterMs2;
|
|
23
|
+
this.name = "LtSyncBackoffError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const hex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
27
|
+
export function ltSyncObjectId() {
|
|
28
|
+
const random = new Uint8Array(8);
|
|
29
|
+
globalThis.crypto.getRandomValues(random);
|
|
30
|
+
return Math.floor(Date.now() / 1e3).toString(16).padStart(8, "0") + hex(random);
|
|
31
|
+
}
|
|
32
|
+
export function ltSyncUuidV7() {
|
|
33
|
+
const bytes = new Uint8Array(16);
|
|
34
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
35
|
+
let time = Date.now();
|
|
36
|
+
for (let index = 5; index >= 0; index--) {
|
|
37
|
+
bytes[index] = time % 256;
|
|
38
|
+
time = Math.floor(time / 256);
|
|
39
|
+
}
|
|
40
|
+
bytes[6] = 112 | bytes[6] & 15;
|
|
41
|
+
bytes[8] = 128 | bytes[8] & 63;
|
|
42
|
+
const value = hex(bytes);
|
|
43
|
+
return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`;
|
|
44
|
+
}
|
|
45
|
+
export function getLtSyncBasePath() {
|
|
46
|
+
try {
|
|
47
|
+
const rc = useRuntimeConfig();
|
|
48
|
+
const ext = rc.public.ltExtensions;
|
|
49
|
+
return ext?.sync?.basePath || "/sync";
|
|
50
|
+
} catch {
|
|
51
|
+
return "/sync";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function buildLtSyncUrl(path) {
|
|
55
|
+
return buildLtApiUrl(`${getLtSyncBasePath()}${path}`);
|
|
56
|
+
}
|
|
57
|
+
function retryAfterMs(value) {
|
|
58
|
+
if (!value) return void 0;
|
|
59
|
+
const seconds = Number(value);
|
|
60
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
|
|
61
|
+
const date = Date.parse(value);
|
|
62
|
+
return Number.isNaN(date) ? void 0 : Math.max(0, date - Date.now());
|
|
63
|
+
}
|
|
64
|
+
const engines = /* @__PURE__ */ new Map();
|
|
65
|
+
export function ltSyncScope(model, tenantId) {
|
|
66
|
+
return tenantId ? `${model}#${tenantId}` : model;
|
|
67
|
+
}
|
|
68
|
+
function parseLtSyncScope(scope) {
|
|
69
|
+
const at = scope.indexOf("#");
|
|
70
|
+
return at === -1 ? { model: scope } : { model: scope.slice(0, at), tenantId: scope.slice(at + 1) };
|
|
71
|
+
}
|
|
72
|
+
function localOf(item) {
|
|
73
|
+
return {
|
|
74
|
+
data: item.deletedAt ? void 0 : item.data,
|
|
75
|
+
deletedAt: item.deletedAt ?? null,
|
|
76
|
+
id: item.id,
|
|
77
|
+
updatedAt: item.updatedAt ?? null,
|
|
78
|
+
version: item.version
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function lastPending(outbox, id) {
|
|
82
|
+
return outbox.filter((item) => item.id === id).at(-1);
|
|
83
|
+
}
|
|
84
|
+
function nextBase(record, pending) {
|
|
85
|
+
return pending ? (pending.baseVersion ?? 0) + 1 : record.version;
|
|
86
|
+
}
|
|
87
|
+
function pushItem(item) {
|
|
88
|
+
const { baseVersion, data, id, mutationId, op } = item;
|
|
89
|
+
return { ...baseVersion === void 0 ? {} : { baseVersion }, ...data === void 0 ? {} : { data }, id, mutationId, op };
|
|
90
|
+
}
|
|
91
|
+
function emptyResult() {
|
|
92
|
+
return { applied: 0, conflicts: [], forbidden: false, needsLogin: false, offline: false, rejected: [], resynced: false, retried: 0 };
|
|
93
|
+
}
|
|
94
|
+
export function createLtSyncEngine(options) {
|
|
95
|
+
const { idFormat = "objectId", model, onConflict, pageLimit = 200, tenantId } = options;
|
|
96
|
+
const fetchImpl = options.fetch ?? ((input, init) => ltAuthFetch(input, init));
|
|
97
|
+
const scope = ltSyncScope(model, tenantId);
|
|
98
|
+
const cursorKey = `cursor:${scope}`;
|
|
99
|
+
const run = (fn) => runLtSyncTx(scope, fn);
|
|
100
|
+
const runAs = (owner, fn) => runLtSyncTx(scope, fn, owner === void 0 ? {} : { owner });
|
|
101
|
+
const newId = () => idFormat === "uuidv7" ? ltSyncUuidV7() : ltSyncObjectId();
|
|
102
|
+
async function request(method, path, payload) {
|
|
103
|
+
const headers = { accept: "application/json" };
|
|
104
|
+
if (payload !== void 0) headers["content-type"] = "application/json";
|
|
105
|
+
if (tenantId) headers["x-tenant-id"] = tenantId;
|
|
106
|
+
let response;
|
|
107
|
+
try {
|
|
108
|
+
response = await fetchImpl(buildLtSyncUrl(path), { body: payload === void 0 ? void 0 : JSON.stringify(payload), headers, method });
|
|
109
|
+
} catch (error) {
|
|
110
|
+
throw new LtSyncOfflineError(error);
|
|
111
|
+
}
|
|
112
|
+
if (response.status === 401) throw new LtSyncUnauthorizedError();
|
|
113
|
+
if (response.status === 429 || response.status === 503) throw new LtSyncBackoffError(response.status, retryAfterMs(response.headers.get("retry-after")));
|
|
114
|
+
const text = await response.text();
|
|
115
|
+
let body;
|
|
116
|
+
try {
|
|
117
|
+
body = text ? JSON.parse(text) : void 0;
|
|
118
|
+
} catch {
|
|
119
|
+
body = void 0;
|
|
120
|
+
}
|
|
121
|
+
if (response.status === 410 || response.status === 403 && method === "GET") return { body, status: response.status };
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
const error = new Error(`${method} ${getLtSyncBasePath()}${path} answered ${response.status}: ${text.slice(0, 300)}`);
|
|
124
|
+
error.status = response.status;
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
return { body, status: response.status };
|
|
128
|
+
}
|
|
129
|
+
async function settle(tx, item, outcome, result, resolution) {
|
|
130
|
+
const status = outcome?.status;
|
|
131
|
+
if (status === "retry" || !status) {
|
|
132
|
+
result.retried++;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
await tx.removeOutbox(item.mutationId);
|
|
136
|
+
const record = await tx.getRecord(item.id);
|
|
137
|
+
if (status === "applied") {
|
|
138
|
+
result.applied++;
|
|
139
|
+
if (item.op === "delete") {
|
|
140
|
+
await tx.deleteRecord(item.id);
|
|
141
|
+
} else if (record && (record.version ?? 0) < (outcome.version ?? 0)) {
|
|
142
|
+
await tx.putRecord({ ...record, version: outcome.version });
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const later = (await tx.listOutbox()).filter((entry) => entry.id === item.id);
|
|
147
|
+
if (status === "merged" && outcome.record) {
|
|
148
|
+
result.applied++;
|
|
149
|
+
const pendingData = later.filter((entry) => entry.op === "upsert").reduce((data, entry) => ({ ...data, ...entry.data }), {});
|
|
150
|
+
const merged = localOf(outcome.record);
|
|
151
|
+
await tx.putRecord(merged.deletedAt ? merged : { ...merged, data: { ...merged.data, ...pendingData } });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (status === "conflict") {
|
|
155
|
+
result.conflicts.push(outcome);
|
|
156
|
+
for (const entry of later) await tx.removeOutbox(entry.mutationId);
|
|
157
|
+
if (!outcome.record || outcome.record.deletedAt) {
|
|
158
|
+
await tx.deleteRecord(item.id);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const server = localOf(outcome.record);
|
|
162
|
+
if (resolution === "server") {
|
|
163
|
+
await tx.putRecord(server);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
await tx.putRecord({ ...server, data: { ...server.data, ...resolution.data } });
|
|
167
|
+
await tx.addOutbox({ baseVersion: server.version, data: resolution.data, id: item.id, mutationId: ltSyncUuidV7(), op: "upsert" });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
for (const entry of later) await tx.removeOutbox(entry.mutationId);
|
|
171
|
+
if (status === "not_found") {
|
|
172
|
+
await tx.deleteRecord(item.id);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
result.rejected.push(outcome);
|
|
176
|
+
if (record && !record.version) {
|
|
177
|
+
await tx.deleteRecord(item.id);
|
|
178
|
+
} else if (record) {
|
|
179
|
+
await tx.deleteMeta(cursorKey);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async function resolveConflict(item, outcome) {
|
|
183
|
+
if (outcome?.status !== "conflict" || !onConflict || !outcome.record || outcome.record.deletedAt) return "server";
|
|
184
|
+
const local = await run((tx) => tx.getRecord(item.id));
|
|
185
|
+
const answer = await onConflict({ change: { data: item.data, op: item.op }, local, server: localOf(outcome.record) });
|
|
186
|
+
return answer === "server" || !answer?.data ? "server" : answer;
|
|
187
|
+
}
|
|
188
|
+
async function push(result, owner) {
|
|
189
|
+
const carried = /* @__PURE__ */ new Set();
|
|
190
|
+
for (let round = 0; round < MAX_PUSH_ROUNDS; round++) {
|
|
191
|
+
const chunk = await runAs(owner, async (tx) => {
|
|
192
|
+
const records = /* @__PURE__ */ new Set();
|
|
193
|
+
const items = [];
|
|
194
|
+
for (const item of await tx.listOutbox()) {
|
|
195
|
+
if (items.length === PUSH_CHUNK) break;
|
|
196
|
+
if (records.has(item.id)) continue;
|
|
197
|
+
records.add(item.id);
|
|
198
|
+
if (!carried.has(item.mutationId)) items.push(item);
|
|
199
|
+
}
|
|
200
|
+
for (const item of items) await tx.putOutbox({ ...item, attempted: true });
|
|
201
|
+
return items;
|
|
202
|
+
});
|
|
203
|
+
if (!chunk.length) return;
|
|
204
|
+
for (const item of chunk) carried.add(item.mutationId);
|
|
205
|
+
const response = await request("POST", `/${encodeURIComponent(model)}`, { items: chunk.map(pushItem) });
|
|
206
|
+
const results = response.body?.results ?? [];
|
|
207
|
+
for (let index = 0; index < chunk.length; index++) {
|
|
208
|
+
const item = chunk[index];
|
|
209
|
+
const resolution = await resolveConflict(item, results[index]);
|
|
210
|
+
await runAs(owner, (tx) => settle(tx, item, results[index], result, resolution));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async function pull(result, owner) {
|
|
215
|
+
let cursor = await run((tx) => tx.getMeta(cursorKey));
|
|
216
|
+
for (let page = 0; page < 1e4; page++) {
|
|
217
|
+
const query = new URLSearchParams({ limit: String(pageLimit) });
|
|
218
|
+
if (cursor) query.set("cursor", cursor);
|
|
219
|
+
const response = await request("GET", `/${encodeURIComponent(model)}?${query}`);
|
|
220
|
+
if (response.status === 403) {
|
|
221
|
+
result.forbidden = true;
|
|
222
|
+
await runAs(owner, async (tx) => {
|
|
223
|
+
await tx.clearRecords(new Set((await tx.listOutbox()).map((item) => item.id)));
|
|
224
|
+
await tx.deleteMeta(cursorKey);
|
|
225
|
+
});
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
if (response.status === 410) {
|
|
229
|
+
if (result.resynced) throw new Error(`resync loop: ${JSON.stringify(response.body)}`);
|
|
230
|
+
result.resynced = true;
|
|
231
|
+
await runAs(owner, async (tx) => {
|
|
232
|
+
await tx.clearRecords(new Set((await tx.listOutbox()).map((item) => item.id)));
|
|
233
|
+
await tx.deleteMeta(cursorKey);
|
|
234
|
+
});
|
|
235
|
+
cursor = void 0;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const body = response.body;
|
|
239
|
+
await runAs(owner, async (tx) => {
|
|
240
|
+
const pending = new Set((await tx.listOutbox()).map((item) => item.id));
|
|
241
|
+
for (const item of body.items ?? []) {
|
|
242
|
+
if (pending.has(item.id)) continue;
|
|
243
|
+
const local = await tx.getRecord(item.id);
|
|
244
|
+
if (local && local.version > item.version) continue;
|
|
245
|
+
if (item.deletedAt) await tx.deleteRecord(item.id);
|
|
246
|
+
else await tx.putRecord(localOf(item));
|
|
247
|
+
}
|
|
248
|
+
await tx.setMeta(cursorKey, body.cursor);
|
|
249
|
+
});
|
|
250
|
+
cursor = body.cursor;
|
|
251
|
+
if (!body.hasMore) return result.resynced;
|
|
252
|
+
}
|
|
253
|
+
throw new Error("pull did not end");
|
|
254
|
+
}
|
|
255
|
+
async function syncOnce() {
|
|
256
|
+
const result = emptyResult();
|
|
257
|
+
try {
|
|
258
|
+
const owner = await getLtSyncOwner();
|
|
259
|
+
await push(result, owner);
|
|
260
|
+
if (await pull(result, owner)) await push(result, owner);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (error instanceof LtSyncOwnerChangedError) return result;
|
|
263
|
+
if (error instanceof LtSyncUnauthorizedError) result.needsLogin = true;
|
|
264
|
+
else if (error instanceof LtSyncOfflineError) result.offline = true;
|
|
265
|
+
else if (error instanceof LtSyncBackoffError) result.retryAfterMs = error.retryAfterMs ?? 0;
|
|
266
|
+
else throw error;
|
|
267
|
+
}
|
|
268
|
+
return result;
|
|
269
|
+
}
|
|
270
|
+
async function syncLocked() {
|
|
271
|
+
const locks = typeof navigator === "undefined" ? void 0 : navigator.locks;
|
|
272
|
+
if (!locks) return syncOnce();
|
|
273
|
+
return await locks.request(`lt-offline-sync:${scope}`, syncOnce);
|
|
274
|
+
}
|
|
275
|
+
let current = null;
|
|
276
|
+
let next = null;
|
|
277
|
+
function sync() {
|
|
278
|
+
if (!current) {
|
|
279
|
+
current = syncLocked().finally(() => {
|
|
280
|
+
current = null;
|
|
281
|
+
});
|
|
282
|
+
return current;
|
|
283
|
+
}
|
|
284
|
+
next ??= current.catch(() => void 0).then(() => {
|
|
285
|
+
next = null;
|
|
286
|
+
return sync();
|
|
287
|
+
});
|
|
288
|
+
return next;
|
|
289
|
+
}
|
|
290
|
+
const engine = {
|
|
291
|
+
async create(data) {
|
|
292
|
+
const record = { data, deletedAt: null, id: newId(), updatedAt: null, version: 0 };
|
|
293
|
+
await run(async (tx) => {
|
|
294
|
+
await tx.putRecord(record);
|
|
295
|
+
await tx.addOutbox({ data, id: record.id, mutationId: ltSyncUuidV7(), op: "upsert" });
|
|
296
|
+
});
|
|
297
|
+
return record;
|
|
298
|
+
},
|
|
299
|
+
get: (id) => run((tx) => tx.getRecord(id)),
|
|
300
|
+
async list() {
|
|
301
|
+
const records = await run((tx) => tx.listRecords());
|
|
302
|
+
return records.filter((record) => !record.deletedAt).sort((a, b) => a.id < b.id ? -1 : 1);
|
|
303
|
+
},
|
|
304
|
+
model,
|
|
305
|
+
pendingCount: () => run(async (tx) => (await tx.listOutbox()).length),
|
|
306
|
+
async pushPending() {
|
|
307
|
+
try {
|
|
308
|
+
await push(emptyResult(), await getLtSyncOwner());
|
|
309
|
+
} catch {
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
scope,
|
|
313
|
+
async remove(id) {
|
|
314
|
+
await run(async (tx) => {
|
|
315
|
+
const record = await tx.getRecord(id);
|
|
316
|
+
if (!record || record.deletedAt) return;
|
|
317
|
+
const pending = lastPending(await tx.listOutbox(), id);
|
|
318
|
+
if (pending && !pending.attempted) {
|
|
319
|
+
await tx.removeOutbox(pending.mutationId);
|
|
320
|
+
if (pending.baseVersion === void 0) {
|
|
321
|
+
await tx.deleteRecord(id);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const baseVersion = pending && !pending.attempted ? pending.baseVersion : nextBase(record, pending);
|
|
326
|
+
await tx.addOutbox({ baseVersion, id, mutationId: ltSyncUuidV7(), op: "delete" });
|
|
327
|
+
await tx.putRecord({ ...record, data: void 0, deletedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
328
|
+
});
|
|
329
|
+
},
|
|
330
|
+
sync,
|
|
331
|
+
async update(id, data) {
|
|
332
|
+
return run(async (tx) => {
|
|
333
|
+
const record = await tx.getRecord(id);
|
|
334
|
+
if (!record || record.deletedAt) throw new Error(`no live local record ${id}`);
|
|
335
|
+
const pending = lastPending(await tx.listOutbox(), id);
|
|
336
|
+
if (pending && pending.op === "upsert" && !pending.attempted) {
|
|
337
|
+
await tx.putOutbox({ ...pending, data: { ...pending.data, ...data } });
|
|
338
|
+
} else {
|
|
339
|
+
await tx.addOutbox({ baseVersion: nextBase(record, pending), data, id, mutationId: ltSyncUuidV7(), op: "upsert" });
|
|
340
|
+
}
|
|
341
|
+
const changed = { ...record, data: { ...record.data, ...data } };
|
|
342
|
+
await tx.putRecord(changed);
|
|
343
|
+
return changed;
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
engines.set(scope, engine);
|
|
348
|
+
return engine;
|
|
349
|
+
}
|
|
350
|
+
export async function flushLtSyncBeforeSignOut(userId, timeoutMs = 5e3) {
|
|
351
|
+
if (typeof indexedDB === "undefined" || !userId) return;
|
|
352
|
+
let timer;
|
|
353
|
+
const timeout = new Promise((resolve) => {
|
|
354
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
355
|
+
});
|
|
356
|
+
const flush = (async () => {
|
|
357
|
+
if (await getLtSyncOwner().catch(() => void 0) !== userId) return;
|
|
358
|
+
const scopes = await listLtSyncPendingModels().catch(() => []);
|
|
359
|
+
await Promise.all(scopes.map((scope) => (engines.get(scope) ?? createLtSyncEngine(parseLtSyncScope(scope))).pushPending()));
|
|
360
|
+
})();
|
|
361
|
+
try {
|
|
362
|
+
await Promise.race([flush, timeout]);
|
|
363
|
+
} finally {
|
|
364
|
+
clearTimeout(timer);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
export function resetLtSyncEngines() {
|
|
368
|
+
engines.clear();
|
|
369
|
+
}
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
*
|
|
67
67
|
* Default on; opt out with `ltExtensions.formLabelAssociation.enabled: false`.
|
|
68
68
|
*/
|
|
69
|
+
import type { ObjectPlugin, Plugin } from '#app';
|
|
69
70
|
/**
|
|
70
71
|
* Re-point every dangling field label at the control in its own field.
|
|
71
72
|
*
|
|
@@ -123,5 +124,5 @@ export declare function repairFieldAssociations(root: Document | ParentNode): {
|
|
|
123
124
|
groups: number;
|
|
124
125
|
labels: number;
|
|
125
126
|
};
|
|
126
|
-
declare const _default:
|
|
127
|
+
declare const _default: Plugin & ObjectPlugin;
|
|
127
128
|
export default _default;
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
* browsers do not raise the on-screen keyboard for it. Preserving the entry avoids all of
|
|
52
52
|
* that, because the field is never anything other than a normal, editable field.
|
|
53
53
|
*/
|
|
54
|
+
import type { ObjectPlugin, Plugin } from '#app';
|
|
54
55
|
type EditableField = HTMLInputElement | HTMLTextAreaElement;
|
|
55
56
|
/**
|
|
56
57
|
* Collect fields the user edited before hydration.
|
|
@@ -67,5 +68,5 @@ export declare function collectEditedFields(root: ParentNode): Map<EditableField
|
|
|
67
68
|
* @returns `true` when a value was actually restored.
|
|
68
69
|
*/
|
|
69
70
|
export declare function restoreField(field: EditableField, value: string): boolean;
|
|
70
|
-
declare const _default:
|
|
71
|
+
declare const _default: Plugin & ObjectPlugin;
|
|
71
72
|
export default _default;
|
|
@@ -2,4 +2,5 @@ export type { LtAuthClientConfig, LtAuthMode, LtAuthResponse, LtAuthState, LtPas
|
|
|
2
2
|
export type { LtFileInfo, LtUploadItem, LtUploadOptions, LtUploadProgress, LtUploadStatus, UseLtFileReturn, UseLtTusUploadReturn } from './upload.js';
|
|
3
3
|
export type { LtAiModuleOptions, LtAuthCookieNamesOptions, LtAuthModuleOptions, LtErrorTranslationModuleOptions, LtExtensionsModuleOptions, LtExtensionsPublicRuntimeConfig, LtFormLabelAssociationOptions, LtI18nModuleOptions, LtPreHydrationInputOptions, LtSystemSetupModuleOptions, LtTusModuleOptions, } from './module.js';
|
|
4
4
|
export type { LtErrorTranslationResponse, LtParsedError, UseLtErrorTranslationReturn } from './error.js';
|
|
5
|
+
export type { LtSyncConflict, LtSyncConflictResolution, LtSyncIdFormat, LtSyncItemStatus, LtSyncModuleOptions, LtSyncPushResult, LtSyncRecord, LtSyncResult, UseLtOfflineSyncOptions, UseLtOfflineSyncReturn, } from './sync.js';
|
|
5
6
|
export type { LtAiAction, LtAiAvailableConnection, LtAiBudgetLimit, LtAiBudgetSummary, LtAiConnection, LtAiConnectionInput, LtAiConnectionPreference, LtAiEffectiveSlot, LtAiInteraction, LtAiMessage, LtAiMode, LtAiPlaceholder, LtAiPrompt, LtAiPromptHint, LtAiPromptHintInput, LtAiPromptInput, LtAiPromptRunInput, LtAiResponse, LtAiSlot, LtAiSlotInput, LtAiStreamEvent, LtAiStreamHandlers, LtAiUsage, LtAiUsageInfo, LtAiUsageScope, UseLtAiAdminReturn, UseLtAiChatOptions, UseLtAiChatReturn, UseLtAiConnectionsReturn, UseLtAiPlaceholdersReturn, UseLtAiPromptsReturn, UseLtAiReturn, UseLtAiUsageReturn, } from './ai.js';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { LtSyncModuleOptions } from './sync.js';
|
|
1
2
|
/**
|
|
2
3
|
* System setup module configuration options
|
|
3
4
|
*
|
|
@@ -147,6 +148,8 @@ export interface LtExtensionsModuleOptions {
|
|
|
147
148
|
i18n?: LtI18nModuleOptions;
|
|
148
149
|
/** Pre-hydration input preservation configuration */
|
|
149
150
|
preHydrationInput?: LtPreHydrationInputOptions;
|
|
151
|
+
/** Offline sync with the nest-server sync module (opt-in) */
|
|
152
|
+
sync?: LtSyncModuleOptions;
|
|
150
153
|
/** TUS upload module configuration */
|
|
151
154
|
tus?: LtTusModuleOptions;
|
|
152
155
|
}
|
|
@@ -312,6 +315,10 @@ export interface LtExtensionsPublicRuntimeConfig {
|
|
|
312
315
|
enabled: boolean;
|
|
313
316
|
maxRestoreMs: number;
|
|
314
317
|
};
|
|
318
|
+
sync: {
|
|
319
|
+
basePath: string;
|
|
320
|
+
enabled: boolean;
|
|
321
|
+
};
|
|
315
322
|
tus: {
|
|
316
323
|
defaultChunkSize: number;
|
|
317
324
|
defaultEndpoint: string;
|