@sveltebase/sync 1.0.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.
@@ -0,0 +1,326 @@
1
+ import Dexie, {} from "dexie";
2
+ import { parseSyncMessage } from "../protocol.js";
3
+ import { useLiveQuery } from "./live.svelte.js";
4
+ export { useLiveQuery };
5
+ export class SyncClient {
6
+ db;
7
+ wsUrl;
8
+ socket;
9
+ tableConfigs;
10
+ reconnectTimer;
11
+ pingInterval;
12
+ closedByClient = false;
13
+ activeChannels = new Set();
14
+ // Mutations waiting for ack/reject from server
15
+ pendingMutations = new Map();
16
+ // Mutations queued to be sent when connection is established
17
+ mutationQueue = [];
18
+ constructor(options) {
19
+ this.wsUrl = options.url;
20
+ this.tableConfigs = options.tables;
21
+ // Initialize Dexie database
22
+ this.db = new Dexie(options.name);
23
+ const schema = {};
24
+ for (const [tableName, config] of Object.entries(options.tables)) {
25
+ schema[tableName] = config.indexes;
26
+ }
27
+ this.db.version(1).stores(schema);
28
+ if (typeof window !== "undefined") {
29
+ this.connect();
30
+ }
31
+ }
32
+ connect() {
33
+ if (this.closedByClient)
34
+ return;
35
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
36
+ const host = window.location.host;
37
+ const fullUrl = this.wsUrl.startsWith("ws://") || this.wsUrl.startsWith("wss://")
38
+ ? this.wsUrl
39
+ : `${protocol}//${host}${this.wsUrl}`;
40
+ this.socket = new WebSocket(fullUrl);
41
+ this.socket.addEventListener("open", async () => {
42
+ console.log("SyncClient: WebSocket connected");
43
+ this.activeChannels.clear();
44
+ this.startHeartbeat();
45
+ // Re-subscribe to all tables (delta-sync aware)
46
+ for (const config of Object.values(this.tableConfigs)) {
47
+ await this.subscribeToChannel(config.channel);
48
+ }
49
+ // Re-send all pending unacknowledged mutations
50
+ for (const mut of this.pendingMutations.values()) {
51
+ this.socket?.send(JSON.stringify({
52
+ type: "mutate",
53
+ id: mut.id,
54
+ channel: mut.channel,
55
+ action: mut.action,
56
+ key: mut.key,
57
+ data: mut.data,
58
+ }));
59
+ }
60
+ // Flush queued mutations
61
+ this.flushMutationQueue();
62
+ });
63
+ this.socket.addEventListener("message", async (message) => {
64
+ if (typeof message.data !== "string")
65
+ return;
66
+ if (message.data === "pong")
67
+ return;
68
+ const msg = parseSyncMessage(message.data);
69
+ if (!msg)
70
+ return;
71
+ await this.handleServerMessage(msg);
72
+ });
73
+ this.socket.addEventListener("close", () => {
74
+ this.socket = undefined;
75
+ this.stopHeartbeat();
76
+ if (!this.closedByClient) {
77
+ this.reconnectTimer = setTimeout(() => this.connect(), 2000);
78
+ }
79
+ });
80
+ this.socket.addEventListener("error", (err) => {
81
+ console.error("SyncClient: WebSocket error", err);
82
+ });
83
+ }
84
+ startHeartbeat() {
85
+ this.stopHeartbeat();
86
+ this.pingInterval = setInterval(() => {
87
+ if (this.socket && this.socket.readyState === WebSocket.OPEN) {
88
+ this.socket.send(JSON.stringify({ type: "ping" }));
89
+ }
90
+ }, 55000); // 55 seconds
91
+ }
92
+ stopHeartbeat() {
93
+ if (this.pingInterval) {
94
+ clearInterval(this.pingInterval);
95
+ this.pingInterval = undefined;
96
+ }
97
+ }
98
+ async subscribeToChannel(channel) {
99
+ if (this.socket && this.socket.readyState === WebSocket.OPEN) {
100
+ const tableName = this.findTableByChannel(channel);
101
+ let since;
102
+ if (tableName) {
103
+ try {
104
+ const table = this.db.table(tableName);
105
+ const latestRow = await table.orderBy("updatedAt").last();
106
+ if (latestRow && latestRow.updatedAt) {
107
+ since = latestRow.updatedAt;
108
+ }
109
+ }
110
+ catch {
111
+ // Ignore if query fails or table is empty
112
+ }
113
+ }
114
+ this.socket.send(JSON.stringify({ type: "subscribe", channel, since }));
115
+ this.activeChannels.add(channel);
116
+ }
117
+ }
118
+ flushMutationQueue() {
119
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
120
+ return;
121
+ while (this.mutationQueue.length > 0) {
122
+ const mut = this.mutationQueue.shift();
123
+ this.socket.send(JSON.stringify({
124
+ type: "mutate",
125
+ id: mut.id,
126
+ channel: mut.channel,
127
+ action: mut.action,
128
+ key: mut.key,
129
+ data: mut.data,
130
+ }));
131
+ }
132
+ }
133
+ async safePutRow(tableName, data) {
134
+ const table = this.db.table(tableName);
135
+ if (!data || !data.id)
136
+ return;
137
+ const existing = await table.get(data.id);
138
+ if (existing && existing.updatedAt && data.updatedAt) {
139
+ const existingTime = new Date(existing.updatedAt).getTime();
140
+ const incomingTime = new Date(data.updatedAt).getTime();
141
+ if (incomingTime < existingTime) {
142
+ // Ignore older update (Last-Write-Wins)
143
+ return;
144
+ }
145
+ }
146
+ await table.put(data);
147
+ }
148
+ async safeDeleteRow(tableName, key, incomingTimeStr) {
149
+ const table = this.db.table(tableName);
150
+ if (incomingTimeStr) {
151
+ const existing = await table.get(key);
152
+ if (existing && existing.updatedAt) {
153
+ const existingTime = new Date(existing.updatedAt).getTime();
154
+ const incomingTime = new Date(incomingTimeStr).getTime();
155
+ if (incomingTime < existingTime) {
156
+ // Ignore older delete
157
+ return;
158
+ }
159
+ }
160
+ }
161
+ await table.delete(key);
162
+ }
163
+ async handleServerMessage(msg) {
164
+ switch (msg.type) {
165
+ case "snapshot": {
166
+ const tableName = this.findTableByChannel(msg.channel);
167
+ if (tableName) {
168
+ const table = this.db.table(tableName);
169
+ if (msg.isDelta) {
170
+ // Delta Sync: put changes using Last-Write-Wins
171
+ for (const row of msg.data) {
172
+ await this.safePutRow(tableName, row);
173
+ }
174
+ }
175
+ else {
176
+ // Full Snapshot: clear and replace
177
+ await this.db.transaction("rw", table, async () => {
178
+ await table.clear();
179
+ await table.bulkPut(msg.data);
180
+ });
181
+ }
182
+ }
183
+ break;
184
+ }
185
+ case "ack": {
186
+ const pending = this.pendingMutations.get(msg.id);
187
+ if (pending) {
188
+ // If server returned canonical data, update local Dexie (respecting LWW)
189
+ if (msg.data) {
190
+ const tableName = this.findTableByChannel(pending.channel);
191
+ if (tableName) {
192
+ await this.safePutRow(tableName, msg.data);
193
+ }
194
+ }
195
+ pending.resolve(msg.data);
196
+ this.pendingMutations.delete(msg.id);
197
+ }
198
+ break;
199
+ }
200
+ case "reject": {
201
+ const pending = this.pendingMutations.get(msg.id);
202
+ if (pending) {
203
+ console.warn(`Mutation ${msg.id} rejected by server: ${msg.error}`);
204
+ await pending.rollback();
205
+ pending.reject(new Error(msg.error));
206
+ this.pendingMutations.delete(msg.id);
207
+ }
208
+ break;
209
+ }
210
+ case "change": {
211
+ // Prevent sync loops: if we sent this mutation, ignore the echo change
212
+ if (msg.mutationId && this.pendingMutations.has(msg.mutationId)) {
213
+ break;
214
+ }
215
+ const tableName = this.findTableByChannel(msg.channel);
216
+ if (!tableName)
217
+ break;
218
+ if (msg.action === "create" || msg.action === "update") {
219
+ await this.safePutRow(tableName, msg.data);
220
+ }
221
+ else if (msg.action === "delete" && msg.key) {
222
+ const incomingTimeStr = msg.data?.updatedAt;
223
+ await this.safeDeleteRow(tableName, msg.key, incomingTimeStr);
224
+ }
225
+ break;
226
+ }
227
+ }
228
+ }
229
+ findTableByChannel(channel) {
230
+ for (const [tableName, config] of Object.entries(this.tableConfigs)) {
231
+ if (config.channel === channel)
232
+ return tableName;
233
+ }
234
+ return undefined;
235
+ }
236
+ table(tableName) {
237
+ const dexieTable = this.db.table(tableName);
238
+ const config = this.tableConfigs[tableName];
239
+ if (!config) {
240
+ throw new Error(`Table ${tableName} not defined in SyncClient config.`);
241
+ }
242
+ return {
243
+ liveQuery: (queryFn) => {
244
+ return useLiveQuery(() => queryFn(dexieTable));
245
+ },
246
+ add: async (row) => {
247
+ const rowData = row;
248
+ const id = rowData.id || crypto.randomUUID();
249
+ const fullRow = { ...rowData, id };
250
+ // Rollback function
251
+ const rollback = async () => {
252
+ await dexieTable.delete(id);
253
+ };
254
+ // Apply optimistic update
255
+ await dexieTable.put(fullRow);
256
+ return this.enqueueMutation(config.channel, "create", id, fullRow, rollback);
257
+ },
258
+ put: async (id, changes) => {
259
+ const existing = await dexieTable.get(id);
260
+ if (!existing) {
261
+ throw new Error(`Cannot update item ${id}: not found locally.`);
262
+ }
263
+ // Rollback function
264
+ const rollback = async () => {
265
+ await dexieTable.put(existing);
266
+ };
267
+ const updatedRow = { ...existing, ...changes };
268
+ // Apply optimistic update
269
+ await dexieTable.put(updatedRow);
270
+ return this.enqueueMutation(config.channel, "update", id, changes, rollback);
271
+ },
272
+ delete: async (id) => {
273
+ const existing = await dexieTable.get(id);
274
+ if (!existing)
275
+ return; // Already deleted
276
+ // Rollback function
277
+ const rollback = async () => {
278
+ await dexieTable.put(existing);
279
+ };
280
+ // Apply optimistic update
281
+ await dexieTable.delete(id);
282
+ return this.enqueueMutation(config.channel, "delete", id, undefined, rollback);
283
+ },
284
+ };
285
+ }
286
+ enqueueMutation(channel, action, key, data, rollback) {
287
+ const mutationId = crypto.randomUUID();
288
+ return new Promise((resolve, reject) => {
289
+ this.pendingMutations.set(mutationId, {
290
+ id: mutationId,
291
+ channel,
292
+ action,
293
+ key,
294
+ data,
295
+ rollback,
296
+ resolve,
297
+ reject,
298
+ });
299
+ const msg = {
300
+ id: mutationId,
301
+ channel,
302
+ action,
303
+ key,
304
+ data,
305
+ };
306
+ if (this.socket && this.socket.readyState === WebSocket.OPEN) {
307
+ this.socket.send(JSON.stringify({ type: "mutate", ...msg }));
308
+ }
309
+ else {
310
+ this.mutationQueue.push(msg);
311
+ }
312
+ });
313
+ }
314
+ disconnect() {
315
+ this.closedByClient = true;
316
+ this.stopHeartbeat();
317
+ if (this.reconnectTimer) {
318
+ clearTimeout(this.reconnectTimer);
319
+ this.reconnectTimer = undefined;
320
+ }
321
+ if (this.socket) {
322
+ this.socket.close();
323
+ this.socket = undefined;
324
+ }
325
+ }
326
+ }
@@ -0,0 +1,18 @@
1
+ export type LiveQueryResult<T> = {
2
+ status: "loading";
3
+ data: undefined;
4
+ error: undefined;
5
+ isLoading: true;
6
+ } | {
7
+ status: "success";
8
+ data: T;
9
+ error: undefined;
10
+ isLoading: false;
11
+ } | {
12
+ status: "error";
13
+ data: undefined;
14
+ error: any;
15
+ isLoading: false;
16
+ };
17
+ export declare function useLiveQuery<T>(queryFn: () => Promise<T> | T): LiveQueryResult<T>;
18
+ //# sourceMappingURL=live.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"live.svelte.d.ts","sourceRoot":"","sources":["../../src/client/live.svelte.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,eAAe,CAAC,CAAC,IACzB;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,SAAS,CAAC;IAAC,SAAS,EAAE,IAAI,CAAA;CAAE,GACzE;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,CAAC,CAAC;IAAC,KAAK,EAAE,SAAS,CAAC;IAAC,SAAS,EAAE,KAAK,CAAA;CAAE,GAClE;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,GAAG,CAAC;IAAC,SAAS,EAAE,KAAK,CAAA;CAAE,CAAC;AAEvE,wBAAgB,YAAY,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,eAAe,CAAC,CAAC,CAAC,CAuCjF"}
@@ -0,0 +1,40 @@
1
+ import { onDestroy } from "svelte";
2
+ import { liveQuery } from "dexie";
3
+ export function useLiveQuery(queryFn) {
4
+ let data = $state(undefined);
5
+ let error = $state(undefined);
6
+ let status = $state("loading");
7
+ if (typeof window !== "undefined") {
8
+ const observable = liveQuery(queryFn);
9
+ const subscription = observable.subscribe({
10
+ next: (val) => {
11
+ data = val;
12
+ error = undefined;
13
+ status = "success";
14
+ },
15
+ error: (err) => {
16
+ data = undefined;
17
+ error = err;
18
+ status = "error";
19
+ console.error("liveQuery error:", err);
20
+ },
21
+ });
22
+ onDestroy(() => {
23
+ subscription.unsubscribe();
24
+ });
25
+ }
26
+ return {
27
+ get data() {
28
+ return data;
29
+ },
30
+ get error() {
31
+ return error;
32
+ },
33
+ get status() {
34
+ return status;
35
+ },
36
+ get isLoading() {
37
+ return status === "loading";
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,25 @@
1
+ namespace App {
2
+ interface Platform {
3
+ env: {
4
+ SYNC_ENGINE: any;
5
+ [key: string]: any;
6
+ };
7
+ context: any;
8
+ caches: any;
9
+ }
10
+ }
11
+
12
+ interface Env {
13
+ SYNC_ENGINE: any;
14
+ [key: string]: any;
15
+ }
16
+
17
+ declare module "$app/environment" {
18
+ export const dev: boolean;
19
+ export const browser: boolean;
20
+ export const building: boolean;
21
+ }
22
+
23
+ declare module "$app/server" {
24
+ export function getRequestEvent(): any;
25
+ }
@@ -0,0 +1,6 @@
1
+ export { SyncClient, useLiveQuery } from "./client/index.js";
2
+ export { defineSync } from "./server/index.js";
3
+ export { handleUpgrade, publishEvent } from "./server/handler.js";
4
+ export type { SyncContext, SyncHandler } from "./server/index.js";
5
+ export type { SyncMessage } from "./protocol.js";
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAC7D,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAC/C,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAClE,YAAY,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAClE,YAAY,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { SyncClient, useLiveQuery } from "./client/index.js";
2
+ export { defineSync } from "./server/index.js";
3
+ export { handleUpgrade, publishEvent } from "./server/handler.js";
@@ -0,0 +1,41 @@
1
+ export type SyncMessage = {
2
+ type: "subscribe";
3
+ channel: string;
4
+ since?: string;
5
+ } | {
6
+ type: "unsubscribe";
7
+ channel: string;
8
+ } | {
9
+ type: "mutate";
10
+ id: string;
11
+ channel: string;
12
+ action: "create" | "update" | "delete";
13
+ key?: string;
14
+ data?: any;
15
+ } | {
16
+ type: "ping";
17
+ } | {
18
+ type: "pong";
19
+ } | {
20
+ type: "snapshot";
21
+ channel: string;
22
+ data: any[];
23
+ isDelta?: boolean;
24
+ } | {
25
+ type: "ack";
26
+ id: string;
27
+ data?: any;
28
+ } | {
29
+ type: "reject";
30
+ id: string;
31
+ error: string;
32
+ } | {
33
+ type: "change";
34
+ channel: string;
35
+ action: "create" | "update" | "delete";
36
+ key?: string;
37
+ data?: any;
38
+ mutationId?: string;
39
+ };
40
+ export declare function parseSyncMessage(data: string): SyncMessage | null;
41
+ //# sourceMappingURL=protocol.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GACnB;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GACtD;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACxC;IACE,IAAI,EAAE,QAAQ,CAAC;IACf,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;IACvC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,GAAG,CAAC;CACZ,GACD;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAChB;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAChB;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,GAAG,EAAE,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GACrE;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,GAAG,CAAA;CAAE,GACvC;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAC7C;IACE,IAAI,EAAE,QAAQ,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;IACvC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,GAAG,CAAC;IACX,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEN,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAcjE"}
@@ -0,0 +1,14 @@
1
+ export function parseSyncMessage(data) {
2
+ try {
3
+ const parsed = JSON.parse(data);
4
+ if (parsed &&
5
+ typeof parsed === "object" &&
6
+ typeof parsed.type === "string") {
7
+ return parsed;
8
+ }
9
+ }
10
+ catch {
11
+ // Ignore malformed JSON
12
+ }
13
+ return null;
14
+ }
@@ -0,0 +1,27 @@
1
+ import type { SyncHandler } from "./index.js";
2
+ export interface ISyncConnection {
3
+ send(data: string): void;
4
+ close(code?: number, reason?: string): void;
5
+ getAuth(): any;
6
+ setAuth(auth: any): void;
7
+ getSubscribedChannels(): Set<string>;
8
+ readonly headers: Headers;
9
+ readonly url: string;
10
+ }
11
+ export declare class SyncBroker {
12
+ private handlers;
13
+ private connections;
14
+ private authorizeConnection?;
15
+ constructor(handlers: SyncHandler[], authorizeConnection?: (request: Request, platform: App.Platform | undefined) => Promise<any>);
16
+ setHandlers(handlers: SyncHandler[]): void;
17
+ registerConnection(conn: ISyncConnection): void;
18
+ removeConnection(conn: ISyncConnection): void;
19
+ /**
20
+ * Resolves the appropriate handler for a channel name.
21
+ */
22
+ private findHandler;
23
+ handleMessage(conn: ISyncConnection, rawMessage: string, platform: App.Platform | undefined, request: Request): Promise<void>;
24
+ private broadcastChange;
25
+ handleExternalChange(channel: string, action: "create" | "update" | "delete", key: string | undefined, data: any): Promise<void>;
26
+ }
27
+ //# sourceMappingURL=broker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"broker.d.ts","sourceRoot":"","sources":["../../src/server/broker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAe,MAAM,YAAY,CAAC;AAG3D,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C,OAAO,IAAI,GAAG,CAAC;IACf,OAAO,CAAC,IAAI,EAAE,GAAG,GAAG,IAAI,CAAC;IACzB,qBAAqB,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC;IACrC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAA2B;IAC3C,OAAO,CAAC,WAAW,CAAmC;IACtD,OAAO,CAAC,mBAAmB,CAAC,CAGV;gBAGhB,QAAQ,EAAE,WAAW,EAAE,EACvB,mBAAmB,CAAC,EAAE,CACpB,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,GAAG,CAAC,QAAQ,GAAG,SAAS,KAC/B,OAAO,CAAC,GAAG,CAAC;IAOZ,WAAW,CAAC,QAAQ,EAAE,WAAW,EAAE;IAanC,kBAAkB,CAAC,IAAI,EAAE,eAAe;IAIxC,gBAAgB,CAAC,IAAI,EAAE,eAAe;IAI7C;;OAEG;IACH,OAAO,CAAC,WAAW;IAqCN,aAAa,CACxB,IAAI,EAAE,eAAe,EACrB,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,GAAG,CAAC,QAAQ,GAAG,SAAS,EAClC,OAAO,EAAE,OAAO;YA0IJ,eAAe;IAoDhB,oBAAoB,CAC/B,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,QAAQ,GAAG,QAAQ,GAAG,QAAQ,EACtC,GAAG,EAAE,MAAM,GAAG,SAAS,EACvB,IAAI,EAAE,GAAG;CAoBZ"}