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,341 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // HyperChess Core — hyperchess-store
3
+ // File: packages/store/src/adapters/postgres.ts
4
+ // Version: 1.0.0
5
+ // Copyright (c) 2026 HyperChess Developer Team
6
+ import { GameNotFoundError } from '../types/game-record';
7
+ /**
8
+ * PostgreSQL adapter for production game storage
9
+ * Requires: npm install pg
10
+ *
11
+ * Features:
12
+ * - Connection pooling
13
+ * - LISTEN/NOTIFY for real-time updates
14
+ * - Full-text search ready
15
+ * - Automatic timestamp management
16
+ *
17
+ * Schema assumption: a `games` table must already exist — unlike the SQLite
18
+ * adapter this one never runs DDL, so migrations are the deployment's job. Its
19
+ * columns are `id` (primary key), `hpgn`, `hfen`, `players`, `metadata`,
20
+ * `result`, `user_id`, `synced`, `created_at`, `updated_at`. `players` and
21
+ * `metadata` are written as JSON strings and read back with `JSON.parse`, so
22
+ * they must be `text`/`json` rather than `jsonb` (a `jsonb` column would be
23
+ * returned pre-parsed by `pg` and fail to parse again). The timestamp columns
24
+ * are read as `Date` objects and converted to ISO strings.
25
+ *
26
+ * Real-time updates rely on a database-side trigger publishing
27
+ * `NOTIFY game_updates` with a `{ id, game }` JSON payload; without that trigger
28
+ * `watch()` still fires locally for writes made through this instance.
29
+ */
30
+ export class PostgresGameStore {
31
+ /**
32
+ * Open a connection pool and begin listening for `game_updates` notifications.
33
+ *
34
+ * The pool is capped at 20 connections with a 2s connect timeout and 30s idle
35
+ * timeout. One extra connection is checked out and held for the lifetime of
36
+ * the store to service `LISTEN`; call {@link PostgresGameStore.close} to give
37
+ * it back.
38
+ *
39
+ * @param connectionString - Standard `postgres://` DSN passed to `pg.Pool`.
40
+ * @throws Error if the optional `pg` package is not installed.
41
+ */
42
+ constructor(connectionString) {
43
+ this.listeners = new Map();
44
+ // Dynamic import to make pg optional
45
+ try {
46
+ const { Pool } = require('pg');
47
+ this.pool = new Pool({
48
+ connectionString,
49
+ max: 20,
50
+ idleTimeoutMillis: 30000,
51
+ connectionTimeoutMillis: 2000,
52
+ });
53
+ // Setup LISTEN for notifications
54
+ this.setupNotifications();
55
+ }
56
+ catch (error) {
57
+ throw new Error('PostgreSQL adapter requires "pg" package: npm install pg');
58
+ }
59
+ }
60
+ /**
61
+ * Dedicate a pooled client to `LISTEN game_updates` and fan payloads out to
62
+ * local watchers. Failures are logged rather than thrown so a database without
63
+ * the notification trigger still yields a usable store.
64
+ */
65
+ async setupNotifications() {
66
+ try {
67
+ this.client = await this.pool.connect();
68
+ await this.client.query('LISTEN game_updates');
69
+ this.client.on('notification', (msg) => {
70
+ try {
71
+ const payload = JSON.parse(msg.payload);
72
+ const callbacks = this.listeners.get(payload.id);
73
+ if (callbacks) {
74
+ callbacks.forEach((cb) => cb(payload.game));
75
+ }
76
+ }
77
+ catch (error) {
78
+ console.warn('Failed to parse notification:', error);
79
+ }
80
+ });
81
+ }
82
+ catch (error) {
83
+ console.warn('Failed to setup notifications:', error);
84
+ }
85
+ }
86
+ /**
87
+ * Upsert a game via `INSERT ... ON CONFLICT (id) DO UPDATE`.
88
+ *
89
+ * `updated_at` is always set to now. `created_at` is only meaningful on the
90
+ * insert path — the conflict branch deliberately leaves it untouched so the
91
+ * original creation time survives edits. `synced` defaults to `true` here,
92
+ * the opposite of the SQLite adapter, because a successful write to the
93
+ * central database *is* the synced state.
94
+ *
95
+ * @param game - Record to persist; a missing `id` is generated locally.
96
+ * @returns The id reported back by the `RETURNING` clause.
97
+ */
98
+ async saveGame(game) {
99
+ const id = game.id || this.generateId();
100
+ const now = new Date().toISOString();
101
+ const query = `
102
+ INSERT INTO games (
103
+ id, hpgn, hfen, players, metadata, result, user_id, synced, created_at, updated_at
104
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
105
+ ON CONFLICT (id) DO UPDATE SET
106
+ hpgn = $2, hfen = $3, players = $4, metadata = $5,
107
+ result = $6, synced = $8, updated_at = $10
108
+ RETURNING id
109
+ `;
110
+ const result = await this.pool.query(query, [
111
+ id,
112
+ game.hpgn,
113
+ game.hfen,
114
+ game.players ? JSON.stringify(game.players) : null,
115
+ game.metadata ? JSON.stringify(game.metadata) : null,
116
+ game.result || null,
117
+ game.userId || null,
118
+ game.synced ?? true,
119
+ game.createdAt || now,
120
+ now,
121
+ ]);
122
+ // Notify listeners
123
+ if (this.listeners.has(id)) {
124
+ const updated = await this.loadGame(id);
125
+ this.listeners.get(id)?.forEach((cb) => cb(updated));
126
+ }
127
+ return result.rows[0].id;
128
+ }
129
+ /**
130
+ * Fetch one row by primary key.
131
+ *
132
+ * @param id - Game id.
133
+ * @returns The row mapped back to camelCase {@link GameRecord} shape.
134
+ * @throws {@link GameNotFoundError} if the query returns no rows.
135
+ */
136
+ async loadGame(id) {
137
+ const result = await this.pool.query('SELECT * FROM games WHERE id = $1', [id]);
138
+ if (result.rows.length === 0) {
139
+ throw new GameNotFoundError(id);
140
+ }
141
+ return this.rowToRecord(result.rows[0]);
142
+ }
143
+ /**
144
+ * Delete one row and forget any watchers registered for it.
145
+ *
146
+ * @param id - Game id.
147
+ * @throws {@link GameNotFoundError} if the `DELETE` affected no rows.
148
+ */
149
+ async deleteGame(id) {
150
+ const result = await this.pool.query('DELETE FROM games WHERE id = $1', [id]);
151
+ if (result.rowCount === 0) {
152
+ throw new GameNotFoundError(id);
153
+ }
154
+ this.listeners.delete(id);
155
+ }
156
+ /**
157
+ * List games with SQL-side filtering, ordering and pagination.
158
+ *
159
+ * Filters are appended as parameterised predicates, never string-interpolated.
160
+ * Ordering is by `created_at` and defaults to descending — the reverse of the
161
+ * memory adapter's default.
162
+ *
163
+ * @param options - `userId`/`result` filters, `sort` direction, `limit` and
164
+ * `offset`. An `offset` without a `limit` is passed through to Postgres,
165
+ * which permits it.
166
+ * @returns Matching rows in the requested order.
167
+ */
168
+ async listGames(options) {
169
+ let query = 'SELECT * FROM games WHERE 1=1';
170
+ const params = [];
171
+ let paramCount = 1;
172
+ if (options?.userId) {
173
+ query += ` AND user_id = $${paramCount}`;
174
+ params.push(options.userId);
175
+ paramCount++;
176
+ }
177
+ if (options?.result) {
178
+ query += ` AND result = $${paramCount}`;
179
+ params.push(options.result);
180
+ paramCount++;
181
+ }
182
+ query += ` ORDER BY created_at ${options?.sort === 'asc' ? 'ASC' : 'DESC'}`;
183
+ if (options?.limit) {
184
+ query += ` LIMIT $${paramCount}`;
185
+ params.push(options.limit);
186
+ paramCount++;
187
+ }
188
+ if (options?.offset) {
189
+ query += ` OFFSET $${paramCount}`;
190
+ params.push(options.offset);
191
+ }
192
+ const result = await this.pool.query(query, params);
193
+ return result.rows.map((row) => this.rowToRecord(row));
194
+ }
195
+ /**
196
+ * Count matching rows with `COUNT(*)`, ignoring `limit`/`offset`.
197
+ *
198
+ * Postgres returns `count` as a bigint string, so the result is parsed to a
199
+ * number before being handed back.
200
+ *
201
+ * @param options - Only `userId` and `result` are honoured.
202
+ * @returns Total number of matching rows.
203
+ */
204
+ async countGames(options) {
205
+ let query = 'SELECT COUNT(*) as count FROM games WHERE 1=1';
206
+ const params = [];
207
+ if (options?.userId) {
208
+ query += ` AND user_id = $1`;
209
+ params.push(options.userId);
210
+ }
211
+ if (options?.result) {
212
+ query += ` AND result = $${params.length + 1}`;
213
+ params.push(options.result);
214
+ }
215
+ const result = await this.pool.query(query, params);
216
+ return parseInt(result.rows[0].count, 10);
217
+ }
218
+ /**
219
+ * Subscribe to changes for one game.
220
+ *
221
+ * Two paths feed the callback: writes made through this instance notify
222
+ * directly, and writes made by other processes arrive over the `game_updates`
223
+ * `LISTEN` channel — the latter only if the database publishes them. Watching
224
+ * is purely local bookkeeping and issues no query, so it is cheap.
225
+ *
226
+ * @param id - Game id to observe.
227
+ * @param callback - Receives the updated record.
228
+ * @returns Unsubscribe handle.
229
+ */
230
+ watch(id, callback) {
231
+ if (!this.listeners.has(id)) {
232
+ this.listeners.set(id, new Set());
233
+ }
234
+ this.listeners.get(id).add(callback);
235
+ return () => {
236
+ const callbacks = this.listeners.get(id);
237
+ if (callbacks) {
238
+ callbacks.delete(callback);
239
+ if (callbacks.size === 0) {
240
+ this.listeners.delete(id);
241
+ }
242
+ }
243
+ };
244
+ }
245
+ /**
246
+ * Probe the pool with `SELECT 1`.
247
+ *
248
+ * Confirms connectivity only — it does not verify that the `games` table
249
+ * exists or is readable.
250
+ *
251
+ * @returns `false` instead of throwing if the query fails.
252
+ */
253
+ async isHealthy() {
254
+ try {
255
+ await this.pool.query('SELECT 1');
256
+ return true;
257
+ }
258
+ catch {
259
+ return false;
260
+ }
261
+ }
262
+ /**
263
+ * Read every row, newest first, in a single unpaginated query.
264
+ *
265
+ * Materialises the whole table in memory — fine for backups, unsuitable for
266
+ * very large datasets.
267
+ *
268
+ * @returns All stored games.
269
+ */
270
+ async exportAll() {
271
+ const result = await this.pool.query('SELECT * FROM games ORDER BY created_at DESC');
272
+ return result.rows.map((row) => this.rowToRecord(row));
273
+ }
274
+ /**
275
+ * Upsert a batch of games one statement at a time.
276
+ *
277
+ * Not wrapped in a transaction: a row that fails is logged and skipped, so a
278
+ * partial import is a possible outcome.
279
+ *
280
+ * @param games - Records to import.
281
+ * @returns How many were saved successfully.
282
+ */
283
+ async importGames(games) {
284
+ let count = 0;
285
+ for (const game of games) {
286
+ try {
287
+ await this.saveGame(game);
288
+ count++;
289
+ }
290
+ catch (error) {
291
+ console.warn(`Failed to import game ${game.id}:`, error);
292
+ }
293
+ }
294
+ return count;
295
+ }
296
+ /**
297
+ * Delete every row in the `games` table and drop all local watchers.
298
+ *
299
+ * @returns Number of rows deleted.
300
+ */
301
+ async clear() {
302
+ const result = await this.pool.query('DELETE FROM games');
303
+ this.listeners.clear();
304
+ return result.rowCount;
305
+ }
306
+ /**
307
+ * Map a snake_case database row to a {@link GameRecord}, parsing the JSON
308
+ * columns and rendering `timestamptz` values as ISO 8601 strings.
309
+ */
310
+ rowToRecord(row) {
311
+ return {
312
+ id: row.id,
313
+ hpgn: row.hpgn,
314
+ hfen: row.hfen,
315
+ players: row.players ? JSON.parse(row.players) : undefined,
316
+ metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
317
+ result: row.result,
318
+ userId: row.user_id,
319
+ synced: row.synced,
320
+ createdAt: row.created_at?.toISOString(),
321
+ updatedAt: row.updated_at?.toISOString(),
322
+ };
323
+ }
324
+ generateId() {
325
+ return `game_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
326
+ }
327
+ /**
328
+ * Release the `LISTEN` client and drain the pool.
329
+ *
330
+ * Must be called for the process to exit cleanly — the held notification
331
+ * client keeps an open socket that would otherwise pin the event loop. The
332
+ * store is unusable afterwards.
333
+ */
334
+ async close() {
335
+ if (this.client) {
336
+ await this.client.release();
337
+ }
338
+ await this.pool.end();
339
+ }
340
+ }
341
+ //# sourceMappingURL=postgres.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres.js","sourceRoot":"","sources":["../../src/adapters/postgres.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,186 @@
1
+ import { GameStore } from '../types/game-store';
2
+ import { GameRecord, GameQueryOptions, Unsubscribe } from '../types/game-record';
3
+ /**
4
+ * SQLite adapter for mobile and offline-first apps
5
+ * Requires: npm install better-sqlite3
6
+ *
7
+ * Features:
8
+ * - WAL mode for concurrent access
9
+ * - Sync queue for offline-first architecture
10
+ * - Automatic timestamp management
11
+ * - Efficient local queries
12
+ *
13
+ * Schema is self-managing: the constructor runs `CREATE TABLE IF NOT EXISTS` for
14
+ * a `games` table plus indexes on `user_id`, `created_at` and `synced`, so no
15
+ * external migration step is needed. `players` and `metadata` are stored as JSON
16
+ * text; timestamps are stored as ISO 8601 strings (not SQLite date types), which
17
+ * is why lexicographic `ORDER BY created_at` sorts chronologically.
18
+ *
19
+ * Unlike the server-backed adapters this one treats `synced` as false by
20
+ * default: rows written locally are pending until something drains
21
+ * {@link SqliteGameStore.getSyncQueue} and calls
22
+ * {@link SqliteGameStore.markSynced}.
23
+ *
24
+ * `better-sqlite3` is synchronous; the async signatures exist purely to satisfy
25
+ * the {@link GameStore} contract and never actually yield.
26
+ */
27
+ export declare class SqliteGameStore implements GameStore {
28
+ private db;
29
+ private watchers;
30
+ private pollIntervals;
31
+ /**
32
+ * Open (or create) the database file and ensure the schema exists.
33
+ *
34
+ * Sets `journal_mode = WAL` so readers don't block the writer, and
35
+ * `synchronous = NORMAL`, which trades a small durability window on OS crash
36
+ * for markedly faster writes — an acceptable bargain for a local cache whose
37
+ * source of truth lives upstream.
38
+ *
39
+ * @param dbPath - Filesystem path to the database; `:memory:` is accepted for
40
+ * an ephemeral database.
41
+ * @throws Error if the optional `better-sqlite3` package is not installed.
42
+ */
43
+ constructor(dbPath: string);
44
+ /**
45
+ * Create the `games` table and its lookup indexes if they are missing.
46
+ * Idempotent, so it runs unconditionally on every open.
47
+ */
48
+ private initSchema;
49
+ /**
50
+ * Write a game with `INSERT OR REPLACE`.
51
+ *
52
+ * This is a whole-row replace, not a merge: columns absent from `game` are
53
+ * reset rather than preserved. `updated_at` is stamped with the current time,
54
+ * and `synced` defaults to 0 so the record enters the offline sync queue.
55
+ *
56
+ * @param game - Record to persist; a missing `id` is generated locally.
57
+ * @returns The id the row was written under.
58
+ */
59
+ saveGame(game: GameRecord): Promise<string>;
60
+ /**
61
+ * Fetch one row by primary key.
62
+ *
63
+ * @param id - Game id.
64
+ * @returns The row mapped to {@link GameRecord} shape, with `synced` coerced
65
+ * from SQLite's integer boolean.
66
+ * @throws {@link GameNotFoundError} if no row matches.
67
+ */
68
+ loadGame(id: string): Promise<GameRecord>;
69
+ /**
70
+ * Delete a row and tear down its watcher, including the backing poll timer.
71
+ *
72
+ * @param id - Game id.
73
+ * @throws {@link GameNotFoundError} if the statement changed no rows.
74
+ */
75
+ deleteGame(id: string): Promise<void>;
76
+ /**
77
+ * List games, filtering and paginating in SQL.
78
+ *
79
+ * Ordering is by `created_at` and defaults to descending. Filter values are
80
+ * bound as parameters, never interpolated into the SQL text.
81
+ *
82
+ * @param options - `userId`/`result` filters, `sort` direction, `limit` and
83
+ * `offset`.
84
+ * @returns Matching rows in the requested order.
85
+ */
86
+ listGames(options?: GameQueryOptions): Promise<GameRecord[]>;
87
+ /**
88
+ * Count matching rows with `COUNT(*)`, ignoring `limit`/`offset`.
89
+ *
90
+ * @param options - Only `userId` and `result` are honoured.
91
+ * @returns Total number of matching rows.
92
+ */
93
+ countGames(options?: GameQueryOptions): Promise<number>;
94
+ /**
95
+ * Observe a game by polling.
96
+ *
97
+ * SQLite has no change-notification channel, so the first watcher for an id
98
+ * starts a 1s interval that re-reads the row and pushes it to every callback.
99
+ * Consequences worth knowing: updates arrive with up to a second of latency,
100
+ * the callback fires on every tick whether or not the row actually changed,
101
+ * and if the row disappears the poll stops and all watchers for that id are
102
+ * dropped without a final event.
103
+ *
104
+ * @param id - Game id to observe.
105
+ * @param callback - Receives the current record on each poll.
106
+ * @returns Unsubscribe handle; the interval is cleared once the last callback
107
+ * for the id is removed.
108
+ */
109
+ watch(id: string, callback: (game: GameRecord) => void): Unsubscribe;
110
+ /**
111
+ * Clear the poll interval for an id and forget its callbacks. Safe to call
112
+ * for an id that is not being watched.
113
+ */
114
+ private stopWatching;
115
+ /**
116
+ * Probe the open database handle with `SELECT 1`.
117
+ *
118
+ * @returns `false` instead of throwing if the handle is closed or the file is
119
+ * unreadable.
120
+ */
121
+ isHealthy(): Promise<boolean>;
122
+ /**
123
+ * Return the games still pending upload — every row with `synced = 0`.
124
+ *
125
+ * Intended to be drained on reconnect and pushed to a remote store, marking
126
+ * each one with {@link SqliteGameStore.markSynced} as it lands.
127
+ *
128
+ * @returns Unsynced records, unordered.
129
+ */
130
+ getSyncQueue(): Promise<GameRecord[]>;
131
+ /**
132
+ * Flag a single game as uploaded, refreshing its `updated_at`.
133
+ *
134
+ * A no-op if the id is unknown — this reports nothing and throws nothing, so
135
+ * callers cannot use it to detect a missing row.
136
+ *
137
+ * @param id - Game id to mark.
138
+ */
139
+ markSynced(id: string): Promise<void>;
140
+ /**
141
+ * Mark every pending game as synced in one statement, without uploading
142
+ * anything. Use only when the queue is known to be reconciled — it discards
143
+ * the record of what still needs pushing. `updated_at` is left alone here.
144
+ */
145
+ clearSyncQueue(): Promise<void>;
146
+ /**
147
+ * Read every row, newest first, in one unpaginated query.
148
+ *
149
+ * @returns All stored games.
150
+ */
151
+ exportAll(): Promise<GameRecord[]>;
152
+ /**
153
+ * Write a batch of games one statement at a time.
154
+ *
155
+ * Not wrapped in a transaction, so a failure part-way leaves earlier rows
156
+ * committed. Because it routes through `saveGame()`, imported rows land
157
+ * unsynced unless the source record says otherwise.
158
+ *
159
+ * @param games - Records to import.
160
+ * @returns How many were written successfully.
161
+ */
162
+ importGames(games: GameRecord[]): Promise<number>;
163
+ /**
164
+ * Delete every row and stop all polling watchers.
165
+ *
166
+ * The table and indexes survive; only the data is removed.
167
+ *
168
+ * @returns Number of rows deleted.
169
+ */
170
+ clear(): Promise<number>;
171
+ /**
172
+ * Map a snake_case row to a {@link GameRecord}, parsing the JSON text columns
173
+ * and coercing SQLite's integer `synced` flag to a boolean. Timestamps pass
174
+ * through unchanged because they are already stored as ISO 8601 strings.
175
+ */
176
+ private rowToRecord;
177
+ private generateId;
178
+ /**
179
+ * Stop every poll timer and close the database handle.
180
+ *
181
+ * Required for the process to exit cleanly, since active `watch()` intervals
182
+ * would otherwise keep the event loop alive. The store is unusable afterwards.
183
+ */
184
+ close(): void;
185
+ }
186
+ //# sourceMappingURL=sqlite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlite.d.ts","sourceRoot":"","sources":["../../src/adapters/sqlite.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,gBAAgB,EAAqB,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAEpG;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,qBAAa,eAAgB,YAAW,SAAS;IAC/C,OAAO,CAAC,EAAE,CAAM;IAChB,OAAO,CAAC,QAAQ,CAA2D;IAC3E,OAAO,CAAC,aAAa,CAA+B;IAEpD;;;;;;;;;;;OAWG;gBACS,MAAM,EAAE,MAAM;IAe1B;;;OAGG;IACH,OAAO,CAAC,UAAU;IAqBlB;;;;;;;;;OASG;IACG,QAAQ,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC;IA8BjD;;;;;;;OAOG;IACG,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAW/C;;;;;OAKG;IACG,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAW3C;;;;;;;;;OASG;IACG,SAAS,CAAC,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC;IAgClE;;;;;OAKG;IACG,UAAU,CAAC,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,MAAM,CAAC;IAoB7D;;;;;;;;;;;;;;OAcG;IACH,KAAK,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,IAAI,GAAG,WAAW;IAgCpE;;;OAGG;IACH,OAAO,CAAC,YAAY;IASpB;;;;;OAKG;IACG,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC;IAWnC;;;;;;;OAOG;IACG,YAAY,IAAI,OAAO,CAAC,UAAU,EAAE,CAAC;IAK3C;;;;;;;OAOG;IACG,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAK3C;;;;OAIG;IACG,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;IAKrC;;;;OAIG;IACG,SAAS,IAAI,OAAO,CAAC,UAAU,EAAE,CAAC;IAKxC;;;;;;;;;OASG;IACG,WAAW,CAAC,KAAK,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC;IAevD;;;;;;OAMG;IACG,KAAK,IAAI,OAAO,CAAC,MAAM,CAAC;IAS9B;;;;OAIG;IACH,OAAO,CAAC,WAAW;IAenB,OAAO,CAAC,UAAU;IAIlB;;;;;OAKG;IACH,KAAK,IAAI,IAAI;CAMd"}