hyperchess-store 0.1.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,379 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // HyperChess Core — hyperchess-store
3
+ // File: packages/store/src/adapters/sqlite.ts
4
+ // Version: 1.0.0
5
+ // Copyright (c) 2026 HyperChess Developer Team
6
+ import { GameNotFoundError } from '../types/game-record';
7
+ /**
8
+ * SQLite adapter for mobile and offline-first apps
9
+ * Requires: npm install better-sqlite3
10
+ *
11
+ * Features:
12
+ * - WAL mode for concurrent access
13
+ * - Sync queue for offline-first architecture
14
+ * - Automatic timestamp management
15
+ * - Efficient local queries
16
+ *
17
+ * Schema is self-managing: the constructor runs `CREATE TABLE IF NOT EXISTS` for
18
+ * a `games` table plus indexes on `user_id`, `created_at` and `synced`, so no
19
+ * external migration step is needed. `players` and `metadata` are stored as JSON
20
+ * text; timestamps are stored as ISO 8601 strings (not SQLite date types), which
21
+ * is why lexicographic `ORDER BY created_at` sorts chronologically.
22
+ *
23
+ * Unlike the server-backed adapters this one treats `synced` as false by
24
+ * default: rows written locally are pending until something drains
25
+ * {@link SqliteGameStore.getSyncQueue} and calls
26
+ * {@link SqliteGameStore.markSynced}.
27
+ *
28
+ * `better-sqlite3` is synchronous; the async signatures exist purely to satisfy
29
+ * the {@link GameStore} contract and never actually yield.
30
+ */
31
+ export class SqliteGameStore {
32
+ /**
33
+ * Open (or create) the database file and ensure the schema exists.
34
+ *
35
+ * Sets `journal_mode = WAL` so readers don't block the writer, and
36
+ * `synchronous = NORMAL`, which trades a small durability window on OS crash
37
+ * for markedly faster writes — an acceptable bargain for a local cache whose
38
+ * source of truth lives upstream.
39
+ *
40
+ * @param dbPath - Filesystem path to the database; `:memory:` is accepted for
41
+ * an ephemeral database.
42
+ * @throws Error if the optional `better-sqlite3` package is not installed.
43
+ */
44
+ constructor(dbPath) {
45
+ this.watchers = new Map();
46
+ this.pollIntervals = new Map();
47
+ try {
48
+ const Database = require('better-sqlite3');
49
+ this.db = new Database(dbPath);
50
+ // Enable WAL mode for better concurrent access
51
+ this.db.pragma('journal_mode = WAL');
52
+ this.db.pragma('synchronous = NORMAL');
53
+ this.initSchema();
54
+ }
55
+ catch (error) {
56
+ throw new Error('SQLite adapter requires "better-sqlite3" package: npm install better-sqlite3');
57
+ }
58
+ }
59
+ /**
60
+ * Create the `games` table and its lookup indexes if they are missing.
61
+ * Idempotent, so it runs unconditionally on every open.
62
+ */
63
+ initSchema() {
64
+ this.db.exec(`
65
+ CREATE TABLE IF NOT EXISTS games (
66
+ id TEXT PRIMARY KEY,
67
+ hpgn TEXT NOT NULL,
68
+ hfen TEXT NOT NULL,
69
+ players TEXT,
70
+ metadata TEXT,
71
+ result TEXT,
72
+ user_id TEXT,
73
+ synced BOOLEAN DEFAULT 0,
74
+ created_at TEXT,
75
+ updated_at TEXT
76
+ );
77
+
78
+ CREATE INDEX IF NOT EXISTS idx_user_id ON games(user_id);
79
+ CREATE INDEX IF NOT EXISTS idx_created_at ON games(created_at DESC);
80
+ CREATE INDEX IF NOT EXISTS idx_synced ON games(synced);
81
+ `);
82
+ }
83
+ /**
84
+ * Write a game with `INSERT OR REPLACE`.
85
+ *
86
+ * This is a whole-row replace, not a merge: columns absent from `game` are
87
+ * reset rather than preserved. `updated_at` is stamped with the current time,
88
+ * and `synced` defaults to 0 so the record enters the offline sync queue.
89
+ *
90
+ * @param game - Record to persist; a missing `id` is generated locally.
91
+ * @returns The id the row was written under.
92
+ */
93
+ async saveGame(game) {
94
+ const id = game.id || this.generateId();
95
+ const now = new Date().toISOString();
96
+ const stmt = this.db.prepare(`
97
+ INSERT OR REPLACE INTO games (
98
+ id, hpgn, hfen, players, metadata, result, user_id, synced, created_at, updated_at
99
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
100
+ `);
101
+ stmt.run(id, game.hpgn, game.hfen, game.players ? JSON.stringify(game.players) : null, game.metadata ? JSON.stringify(game.metadata) : null, game.result || null, game.userId || null, game.synced ?? 0, // Mark as unsynced by default
102
+ game.createdAt || now, now);
103
+ // Notify watchers
104
+ const updated = await this.loadGame(id);
105
+ this.watchers.get(id)?.forEach((cb) => cb(updated));
106
+ return id;
107
+ }
108
+ /**
109
+ * Fetch one row by primary key.
110
+ *
111
+ * @param id - Game id.
112
+ * @returns The row mapped to {@link GameRecord} shape, with `synced` coerced
113
+ * from SQLite's integer boolean.
114
+ * @throws {@link GameNotFoundError} if no row matches.
115
+ */
116
+ async loadGame(id) {
117
+ const stmt = this.db.prepare('SELECT * FROM games WHERE id = ?');
118
+ const row = stmt.get(id);
119
+ if (!row) {
120
+ throw new GameNotFoundError(id);
121
+ }
122
+ return this.rowToRecord(row);
123
+ }
124
+ /**
125
+ * Delete a row and tear down its watcher, including the backing poll timer.
126
+ *
127
+ * @param id - Game id.
128
+ * @throws {@link GameNotFoundError} if the statement changed no rows.
129
+ */
130
+ async deleteGame(id) {
131
+ const stmt = this.db.prepare('DELETE FROM games WHERE id = ?');
132
+ const result = stmt.run(id);
133
+ if (result.changes === 0) {
134
+ throw new GameNotFoundError(id);
135
+ }
136
+ this.stopWatching(id);
137
+ }
138
+ /**
139
+ * List games, filtering and paginating in SQL.
140
+ *
141
+ * Ordering is by `created_at` and defaults to descending. Filter values are
142
+ * bound as parameters, never interpolated into the SQL text.
143
+ *
144
+ * @param options - `userId`/`result` filters, `sort` direction, `limit` and
145
+ * `offset`.
146
+ * @returns Matching rows in the requested order.
147
+ */
148
+ async listGames(options) {
149
+ let query = 'SELECT * FROM games WHERE 1=1';
150
+ const params = [];
151
+ if (options?.userId) {
152
+ query += ' AND user_id = ?';
153
+ params.push(options.userId);
154
+ }
155
+ if (options?.result) {
156
+ query += ' AND result = ?';
157
+ params.push(options.result);
158
+ }
159
+ query += ` ORDER BY created_at ${options?.sort === 'asc' ? 'ASC' : 'DESC'}`;
160
+ if (options?.limit) {
161
+ query += ' LIMIT ?';
162
+ params.push(options.limit);
163
+ }
164
+ if (options?.offset) {
165
+ query += ' OFFSET ?';
166
+ params.push(options.offset);
167
+ }
168
+ const stmt = this.db.prepare(query);
169
+ const rows = stmt.all(...params);
170
+ return rows.map((row) => this.rowToRecord(row));
171
+ }
172
+ /**
173
+ * Count matching rows with `COUNT(*)`, ignoring `limit`/`offset`.
174
+ *
175
+ * @param options - Only `userId` and `result` are honoured.
176
+ * @returns Total number of matching rows.
177
+ */
178
+ async countGames(options) {
179
+ let query = 'SELECT COUNT(*) as count FROM games WHERE 1=1';
180
+ const params = [];
181
+ if (options?.userId) {
182
+ query += ' AND user_id = ?';
183
+ params.push(options.userId);
184
+ }
185
+ if (options?.result) {
186
+ query += ' AND result = ?';
187
+ params.push(options.result);
188
+ }
189
+ const stmt = this.db.prepare(query);
190
+ const result = stmt.get(...params);
191
+ return result.count;
192
+ }
193
+ /**
194
+ * Observe a game by polling.
195
+ *
196
+ * SQLite has no change-notification channel, so the first watcher for an id
197
+ * starts a 1s interval that re-reads the row and pushes it to every callback.
198
+ * Consequences worth knowing: updates arrive with up to a second of latency,
199
+ * the callback fires on every tick whether or not the row actually changed,
200
+ * and if the row disappears the poll stops and all watchers for that id are
201
+ * dropped without a final event.
202
+ *
203
+ * @param id - Game id to observe.
204
+ * @param callback - Receives the current record on each poll.
205
+ * @returns Unsubscribe handle; the interval is cleared once the last callback
206
+ * for the id is removed.
207
+ */
208
+ watch(id, callback) {
209
+ if (!this.watchers.has(id)) {
210
+ this.watchers.set(id, new Set());
211
+ // Start polling
212
+ const interval = setInterval(async () => {
213
+ try {
214
+ const game = await this.loadGame(id);
215
+ const callbacks = this.watchers.get(id);
216
+ callbacks?.forEach((cb) => cb(game));
217
+ }
218
+ catch {
219
+ // Game deleted or doesn't exist
220
+ this.stopWatching(id);
221
+ }
222
+ }, 1000); // Poll every 1 second
223
+ this.pollIntervals.set(id, interval);
224
+ }
225
+ this.watchers.get(id).add(callback);
226
+ return () => {
227
+ const callbacks = this.watchers.get(id);
228
+ if (callbacks) {
229
+ callbacks.delete(callback);
230
+ if (callbacks.size === 0) {
231
+ this.stopWatching(id);
232
+ }
233
+ }
234
+ };
235
+ }
236
+ /**
237
+ * Clear the poll interval for an id and forget its callbacks. Safe to call
238
+ * for an id that is not being watched.
239
+ */
240
+ stopWatching(id) {
241
+ const interval = this.pollIntervals.get(id);
242
+ if (interval) {
243
+ clearInterval(interval);
244
+ this.pollIntervals.delete(id);
245
+ }
246
+ this.watchers.delete(id);
247
+ }
248
+ /**
249
+ * Probe the open database handle with `SELECT 1`.
250
+ *
251
+ * @returns `false` instead of throwing if the handle is closed or the file is
252
+ * unreadable.
253
+ */
254
+ async isHealthy() {
255
+ try {
256
+ this.db.prepare('SELECT 1').get();
257
+ return true;
258
+ }
259
+ catch {
260
+ return false;
261
+ }
262
+ }
263
+ // Offline-first sync management
264
+ /**
265
+ * Return the games still pending upload — every row with `synced = 0`.
266
+ *
267
+ * Intended to be drained on reconnect and pushed to a remote store, marking
268
+ * each one with {@link SqliteGameStore.markSynced} as it lands.
269
+ *
270
+ * @returns Unsynced records, unordered.
271
+ */
272
+ async getSyncQueue() {
273
+ const stmt = this.db.prepare('SELECT * FROM games WHERE synced = 0');
274
+ return stmt.all().map((row) => this.rowToRecord(row));
275
+ }
276
+ /**
277
+ * Flag a single game as uploaded, refreshing its `updated_at`.
278
+ *
279
+ * A no-op if the id is unknown — this reports nothing and throws nothing, so
280
+ * callers cannot use it to detect a missing row.
281
+ *
282
+ * @param id - Game id to mark.
283
+ */
284
+ async markSynced(id) {
285
+ const stmt = this.db.prepare('UPDATE games SET synced = 1, updated_at = ? WHERE id = ?');
286
+ stmt.run(new Date().toISOString(), id);
287
+ }
288
+ /**
289
+ * Mark every pending game as synced in one statement, without uploading
290
+ * anything. Use only when the queue is known to be reconciled — it discards
291
+ * the record of what still needs pushing. `updated_at` is left alone here.
292
+ */
293
+ async clearSyncQueue() {
294
+ const stmt = this.db.prepare('UPDATE games SET synced = 1 WHERE synced = 0');
295
+ stmt.run();
296
+ }
297
+ /**
298
+ * Read every row, newest first, in one unpaginated query.
299
+ *
300
+ * @returns All stored games.
301
+ */
302
+ async exportAll() {
303
+ const stmt = this.db.prepare('SELECT * FROM games ORDER BY created_at DESC');
304
+ return stmt.all().map((row) => this.rowToRecord(row));
305
+ }
306
+ /**
307
+ * Write a batch of games one statement at a time.
308
+ *
309
+ * Not wrapped in a transaction, so a failure part-way leaves earlier rows
310
+ * committed. Because it routes through `saveGame()`, imported rows land
311
+ * unsynced unless the source record says otherwise.
312
+ *
313
+ * @param games - Records to import.
314
+ * @returns How many were written successfully.
315
+ */
316
+ async importGames(games) {
317
+ let count = 0;
318
+ for (const game of games) {
319
+ try {
320
+ await this.saveGame(game);
321
+ count++;
322
+ }
323
+ catch (error) {
324
+ console.warn(`Failed to import game ${game.id}:`, error);
325
+ }
326
+ }
327
+ return count;
328
+ }
329
+ /**
330
+ * Delete every row and stop all polling watchers.
331
+ *
332
+ * The table and indexes survive; only the data is removed.
333
+ *
334
+ * @returns Number of rows deleted.
335
+ */
336
+ async clear() {
337
+ const stmt = this.db.prepare('DELETE FROM games');
338
+ const result = stmt.run();
339
+ this.watchers.clear();
340
+ this.pollIntervals.forEach((interval) => clearInterval(interval));
341
+ this.pollIntervals.clear();
342
+ return result.changes;
343
+ }
344
+ /**
345
+ * Map a snake_case row to a {@link GameRecord}, parsing the JSON text columns
346
+ * and coercing SQLite's integer `synced` flag to a boolean. Timestamps pass
347
+ * through unchanged because they are already stored as ISO 8601 strings.
348
+ */
349
+ rowToRecord(row) {
350
+ return {
351
+ id: row.id,
352
+ hpgn: row.hpgn,
353
+ hfen: row.hfen,
354
+ players: row.players ? JSON.parse(row.players) : undefined,
355
+ metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
356
+ result: row.result,
357
+ userId: row.user_id,
358
+ synced: !!row.synced,
359
+ createdAt: row.created_at,
360
+ updatedAt: row.updated_at,
361
+ };
362
+ }
363
+ generateId() {
364
+ return `game_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
365
+ }
366
+ /**
367
+ * Stop every poll timer and close the database handle.
368
+ *
369
+ * Required for the process to exit cleanly, since active `watch()` intervals
370
+ * would otherwise keep the event loop alive. The store is unusable afterwards.
371
+ */
372
+ close() {
373
+ this.pollIntervals.forEach((interval) => clearInterval(interval));
374
+ this.pollIntervals.clear();
375
+ this.watchers.clear();
376
+ this.db.close();
377
+ }
378
+ }
379
+ //# sourceMappingURL=sqlite.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlite.js","sourceRoot":"","sources":["../../src/adapters/sqlite.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,180 @@
1
+ import { GameStore } from '../types/game-store';
2
+ import { GameRecord, GameQueryOptions, Unsubscribe } from '../types/game-record';
3
+ /**
4
+ * Supabase adapter - PostgreSQL + Realtime + Auth
5
+ * Requires: npm install @supabase/supabase-js
6
+ *
7
+ * Features:
8
+ * - Realtime subscriptions via postgres_changes
9
+ * - Row Level Security aware (games scoped to authenticated user)
10
+ * - Range-based pagination
11
+ *
12
+ * Schema assumption: a `public.games` table exposed through PostgREST with
13
+ * columns `id` (primary key), `hpgn`, `hfen`, `players`, `metadata`, `result`,
14
+ * `user_id`, `synced`, `created_at`, `updated_at`. `players` and `metadata` are
15
+ * sent as objects and read back as objects, so those columns must be `json`/
16
+ * `jsonb` — the opposite of the raw Postgres adapter, which round-trips them as
17
+ * JSON strings.
18
+ *
19
+ * Because every call goes through PostgREST, Row Level Security applies: what a
20
+ * query returns depends on the key the client was built with. Under RLS a
21
+ * "missing" row and a forbidden one are indistinguishable, so
22
+ * {@link GameNotFoundError} can also mean "not visible to you". Realtime
23
+ * subscriptions likewise require the `games` table to be in the realtime
24
+ * publication.
25
+ *
26
+ * Errors from the client are surfaced as plain `Error`s carrying the PostgREST
27
+ * message rather than being swallowed — only `isHealthy()` degrades quietly.
28
+ */
29
+ export declare class SupabaseGameStore implements GameStore {
30
+ private client;
31
+ private channels;
32
+ /**
33
+ * Create the underlying Supabase client.
34
+ *
35
+ * The key determines the store's effective permissions: an anon key leaves RLS
36
+ * in force (games scoped to the signed-in user), a service-role key bypasses
37
+ * it entirely and must never reach a browser.
38
+ *
39
+ * @param supabaseUrl - Project URL, e.g. `https://<ref>.supabase.co`.
40
+ * @param supabaseKey - Anon or service-role API key.
41
+ * @throws Error if the optional `@supabase/supabase-js` package is not
42
+ * installed.
43
+ */
44
+ constructor(supabaseUrl: string, supabaseKey: string);
45
+ /**
46
+ * Upsert a game, conflicting on `id`, and read the stored row back.
47
+ *
48
+ * `updated_at` is always stamped with the current time; `created_at` falls
49
+ * back to now only when the record carries none, which means re-saving a
50
+ * record whose `createdAt` was dropped will rewrite the creation time.
51
+ *
52
+ * @param game - Record to persist; a missing `id` is generated locally.
53
+ * @returns The id reported by the returned row.
54
+ * @throws Error carrying the PostgREST message if the upsert is rejected —
55
+ * including RLS denials.
56
+ */
57
+ saveGame(game: GameRecord): Promise<string>;
58
+ /**
59
+ * Fetch one row by id.
60
+ *
61
+ * Uses `maybeSingle()` so an absent row comes back as `null` data rather than
62
+ * a PostgREST error, letting the missing case be reported as a typed
63
+ * {@link GameNotFoundError}.
64
+ *
65
+ * @param id - Game id.
66
+ * @returns The row in {@link GameRecord} shape.
67
+ * @throws {@link GameNotFoundError} if no row is visible under `id`.
68
+ * @throws Error carrying the PostgREST message on a query failure.
69
+ */
70
+ loadGame(id: string): Promise<GameRecord>;
71
+ /**
72
+ * Delete a row, using the returned representation to prove it existed.
73
+ *
74
+ * The trailing `.select()` is what makes the not-found case detectable: a
75
+ * `DELETE` matching nothing is not an error to PostgREST, it simply returns an
76
+ * empty set.
77
+ *
78
+ * @param id - Game id.
79
+ * @throws {@link GameNotFoundError} if no row was deleted.
80
+ * @throws Error carrying the PostgREST message on a query failure.
81
+ */
82
+ deleteGame(id: string): Promise<void>;
83
+ /**
84
+ * List games using PostgREST filters and range-based pagination.
85
+ *
86
+ * `range()` bounds are inclusive, so a page is expressed as
87
+ * `[offset, offset + limit - 1]`. An `offset` given without a `limit` falls
88
+ * back to a fixed 1000-row window, since PostgREST needs an upper bound.
89
+ *
90
+ * @param options - `userId`/`result` equality filters, `sort` direction
91
+ * (default descending by `created_at`), `limit` and `offset`.
92
+ * @returns Matching rows in the requested order.
93
+ * @throws Error carrying the PostgREST message on a query failure.
94
+ */
95
+ listGames(options?: GameQueryOptions): Promise<GameRecord[]>;
96
+ /**
97
+ * Count matching rows without transferring them.
98
+ *
99
+ * `head: true` issues a `HEAD` request so only the count header comes back;
100
+ * `count: 'exact'` is accurate but does a full scan on large tables.
101
+ *
102
+ * @param options - Only `userId` and `result` are honoured.
103
+ * @returns Total number of matching rows, or 0 if the count header is absent.
104
+ * @throws Error carrying the PostgREST message on a query failure.
105
+ */
106
+ countGames(options?: GameQueryOptions): Promise<number>;
107
+ /**
108
+ * Subscribe to a game over a Realtime `postgres_changes` channel.
109
+ *
110
+ * Listens for `UPDATE` events only — inserts and deletes are not delivered, so
111
+ * watching an id before it exists yields nothing until the first update after
112
+ * creation. Each id gets its own channel, tracked so `clear()` can tear them
113
+ * all down.
114
+ *
115
+ * @param id - Game id to observe.
116
+ * @param callback - Receives the new row from the change payload.
117
+ * @returns Unsubscribe handle that removes the channel.
118
+ */
119
+ watch(id: string, callback: (game: GameRecord) => void): Unsubscribe;
120
+ /**
121
+ * Probe with a bounded head request against the `games` table.
122
+ *
123
+ * Verifies both reachability and that the table is readable under the current
124
+ * key and RLS policy.
125
+ *
126
+ * @returns `false` instead of throwing on any error.
127
+ */
128
+ isHealthy(): Promise<boolean>;
129
+ /**
130
+ * Read every visible row, newest first.
131
+ *
132
+ * No range is applied, so the result is still subject to the project's
133
+ * PostgREST `max-rows` setting — on a large table this may silently return a
134
+ * truncated backup.
135
+ *
136
+ * @returns All games visible to the current key.
137
+ * @throws Error carrying the PostgREST message on a query failure.
138
+ */
139
+ exportAll(): Promise<GameRecord[]>;
140
+ /**
141
+ * Upsert a batch of games with one request per record.
142
+ *
143
+ * Sequential and non-transactional; a record rejected by RLS or a constraint
144
+ * is logged and skipped, so a partial import is possible.
145
+ *
146
+ * @param games - Records to import.
147
+ * @returns How many were saved successfully.
148
+ */
149
+ importGames(games: GameRecord[]): Promise<number>;
150
+ /**
151
+ * Delete every visible row and close all Realtime channels.
152
+ *
153
+ * The `neq('id', '')` predicate exists because PostgREST refuses an unfiltered
154
+ * `DELETE` as a footgun guard; matching "id is not the empty string" selects
155
+ * everything while satisfying that requirement. RLS still applies, so this
156
+ * clears only what the current key can see.
157
+ *
158
+ * @returns Number of rows deleted.
159
+ * @throws Error carrying the PostgREST message on a query failure.
160
+ */
161
+ clear(): Promise<number>;
162
+ /**
163
+ * Map a snake_case PostgREST row to a {@link GameRecord}. The JSON columns
164
+ * arrive already deserialised, so only the key renaming and `null`-to-
165
+ * `undefined` normalisation is needed.
166
+ */
167
+ private rowToRecord;
168
+ private generateId;
169
+ /**
170
+ * Resolve the id of the currently signed-in Supabase user.
171
+ *
172
+ * Useful for stamping `GameRecord.userId` so saved games line up with the
173
+ * `user_id` an RLS policy filters on.
174
+ *
175
+ * @returns The auth user id, or `null` when nobody is signed in or the lookup
176
+ * fails — the two cases are not distinguished.
177
+ */
178
+ getAuthenticatedUser(): Promise<string | null>;
179
+ }
180
+ //# sourceMappingURL=supabase.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"supabase.d.ts","sourceRoot":"","sources":["../../src/adapters/supabase.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,gBAAgB,EAAqB,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAEpG;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,qBAAa,iBAAkB,YAAW,SAAS;IACjD,OAAO,CAAC,MAAM,CAAM;IACpB,OAAO,CAAC,QAAQ,CAA+B;IAE/C;;;;;;;;;;;OAWG;gBACS,WAAW,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM;IAWpD;;;;;;;;;;;OAWG;IACG,QAAQ,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC;IA+BjD;;;;;;;;;;;OAWG;IACG,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAa/C;;;;;;;;;;OAUG;IACG,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAW3C;;;;;;;;;;;OAWG;IACG,SAAS,CAAC,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC;IAwBlE;;;;;;;;;OASG;IACG,UAAU,CAAC,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,MAAM,CAAC;IAe7D;;;;;;;;;;;OAWG;IACH,KAAK,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,IAAI,GAAG,WAAW;IAmBpE;;;;;;;OAOG;IACG,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC;IASnC;;;;;;;;;OASG;IACG,SAAS,IAAI,OAAO,CAAC,UAAU,EAAE,CAAC;IAUxC;;;;;;;;OAQG;IACG,WAAW,CAAC,KAAK,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC;IAevD;;;;;;;;;;OAUG;IACG,KAAK,IAAI,OAAO,CAAC,MAAM,CAAC;IAa9B;;;;OAIG;IACH,OAAO,CAAC,WAAW;IAenB,OAAO,CAAC,UAAU;IAMlB;;;;;;;;OAQG;IACG,oBAAoB,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;CASrD"}