@chatcops/store-postgres 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +113 -2
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +8 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +47 -0
- package/dist/cli.js.map +1 -0
- package/dist/conversations.d.ts +38 -0
- package/dist/conversations.d.ts.map +1 -0
- package/dist/conversations.js +230 -0
- package/dist/conversations.js.map +1 -0
- package/dist/counters.d.ts +37 -0
- package/dist/counters.d.ts.map +1 -0
- package/dist/counters.js +98 -0
- package/dist/counters.js.map +1 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/queryable.d.ts +26 -0
- package/dist/queryable.d.ts.map +1 -0
- package/dist/queryable.js +25 -0
- package/dist/queryable.js.map +1 -0
- package/dist/row-hook.d.ts +26 -0
- package/dist/row-hook.d.ts.map +1 -0
- package/dist/row-hook.js +38 -0
- package/dist/row-hook.js.map +1 -0
- package/dist/schema.d.ts +44 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +126 -0
- package/dist/schema.js.map +1 -0
- package/dist/sql.d.ts +9 -0
- package/dist/sql.d.ts.map +1 -0
- package/dist/sql.js +9 -0
- package/dist/sql.js.map +1 -0
- package/dist/vectors.d.ts +15 -0
- package/dist/vectors.d.ts.map +1 -0
- package/dist/vectors.js +147 -0
- package/dist/vectors.js.map +1 -0
- package/package.json +64 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CoderCops
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,114 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @chatcops/store-postgres
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Postgres storage for [ChatCops](https://chat.codercops.com): conversations, rate limit counters, turn locks and analytics events, so they survive restarts and are shared by every server instance.
|
|
4
|
+
|
|
5
|
+
It works with any driver. Every write is a single SQL statement, so nothing needs a transaction or a dedicated connection, and serverless drivers such as Neon's work as well as `pg`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @chatcops/store-postgres pg
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`pg` is only needed for the `migrate` command and if you use it as your driver.
|
|
14
|
+
|
|
15
|
+
## Create the tables
|
|
16
|
+
|
|
17
|
+
Once per database, and again after upgrading this package:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
DATABASE_URL=postgres://... npx chatcops-postgres migrate
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or from code, for example at startup:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { migrate } from '@chatcops/store-postgres';
|
|
27
|
+
await migrate(pool);
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Migrations already applied are skipped, so running it on every deploy is fine.
|
|
31
|
+
|
|
32
|
+
## Use it
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import pg from 'pg';
|
|
36
|
+
import { postgresStorage } from '@chatcops/store-postgres';
|
|
37
|
+
|
|
38
|
+
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
|
|
39
|
+
const storage = postgresStorage({ db: pool });
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
With Neon on Vercel or Cloudflare:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { neon } from '@neondatabase/serverless';
|
|
46
|
+
import { fromNeon, postgresStorage } from '@chatcops/store-postgres';
|
|
47
|
+
|
|
48
|
+
const storage = postgresStorage({ db: fromNeon(neon(process.env.DATABASE_URL!)) });
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
With postgres.js, wrap the client the same way: `fromPostgresJs(postgres(url))`.
|
|
52
|
+
|
|
53
|
+
Pass the complete storage object to the server:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
chatcopsMiddleware({
|
|
57
|
+
provider: { type: 'claude', apiKey: process.env.ANTHROPIC_API_KEY! },
|
|
58
|
+
systemPrompt: 'You are a helpful assistant.',
|
|
59
|
+
storage,
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Options
|
|
64
|
+
|
|
65
|
+
| Option | Default | What it does |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `db` | required | A `pg` `Pool`, or a client wrapped with `fromNeon` or `fromPostgresJs`. Anything with `query(text, params)` that returns `{ rows }` works. |
|
|
68
|
+
| `tablePrefix` | `'chatcops_'` | Table name prefix, so several apps can share a database. Use the same one with `migrate`. |
|
|
69
|
+
|
|
70
|
+
## Your own columns
|
|
71
|
+
|
|
72
|
+
To stamp your own columns on every new conversation and message row (a tenant id, a site id), add them with your own migration, declare them, and say how to fill them:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// ALTER TABLE chatcops_conversations ADD COLUMN workspace_id uuid;
|
|
76
|
+
// ALTER TABLE chatcops_messages ADD COLUMN workspace_id uuid;
|
|
77
|
+
const storage = postgresStorage({
|
|
78
|
+
db: pool,
|
|
79
|
+
extraColumns: { workspace_id: 'uuid' },
|
|
80
|
+
rowHook: ({ table, metadata }) => ({ workspace_id: metadata?.workspaceId }),
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The hook runs when a row is created, never on updates. It gets the table, the conversation id, the metadata passed with the write, and the message for message rows. Returning a key that isn't in `extraColumns` throws, and values are sent as query parameters, never put into the SQL. Reads aren't filtered by these columns; that's up to your own queries or row-level security.
|
|
85
|
+
|
|
86
|
+
## Tables
|
|
87
|
+
|
|
88
|
+
| Table | Holds |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `chatcops_conversations` | One row per conversation: metadata, version, timestamps |
|
|
91
|
+
| `chatcops_messages` | One row per message, with parts, status and usage as `jsonb` |
|
|
92
|
+
| `chatcops_counters` | Rate limit and budget counters with their expiry |
|
|
93
|
+
| `chatcops_kv` | Short-lived keys, such as the lock that keeps two replies to one conversation from overlapping |
|
|
94
|
+
| `chatcops_events` | Analytics events |
|
|
95
|
+
|
|
96
|
+
Writing to a conversation is one `INSERT ... ON CONFLICT` that locks its row, so two server instances answering at once never lose a message, and a stale `expectedVersion` fails with a `ConflictError`.
|
|
97
|
+
|
|
98
|
+
## Retention
|
|
99
|
+
|
|
100
|
+
Nothing is deleted on its own. Call `purge(before)` on a schedule to delete conversations last updated before a time, with their messages. It also clears expired counters and keys:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
|
104
|
+
await storage.conversations.purge?.(thirtyDaysAgo);
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Testing
|
|
108
|
+
|
|
109
|
+
The package runs the shared ChatCops storage conformance suite against a real Postgres when `CHATCOPS_TEST_POSTGRES_URL` is set:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:17-alpine
|
|
113
|
+
CHATCOPS_TEST_POSTGRES_URL=postgres://postgres:postgres@localhost:5432/postgres pnpm --filter @chatcops/store-postgres test
|
|
114
|
+
```
|
package/dist/bin.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bin.d.ts","sourceRoot":"","sources":["../src/bin.ts"],"names":[],"mappings":""}
|
package/dist/bin.js
ADDED
package/dist/bin.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bin.js","sourceRoot":"","sources":["../src/bin.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,IAAI,EAAE,MAAM,UAAU,CAAC;AAEhC,OAAO,CAAC,QAAQ,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE;IACnD,GAAG,EAAE,OAAO,CAAC,GAAG;IAChB,GAAG,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC;IACtC,KAAK,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC;CAC3C,CAAC,CAAC"}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Queryable } from './queryable.js';
|
|
2
|
+
export declare const USAGE = "Usage: chatcops-postgres migrate [--prefix chatcops_]\n\nCreates or updates the ChatCops tables in the database at DATABASE_URL.\nNeeds the pg package installed.";
|
|
3
|
+
export interface CliDeps {
|
|
4
|
+
env: Record<string, string | undefined>;
|
|
5
|
+
log(message: string): void;
|
|
6
|
+
error(message: string): void;
|
|
7
|
+
/** Opens a pool for the URL. Defaults to `pg`. */
|
|
8
|
+
connect?(url: string): Promise<Queryable & {
|
|
9
|
+
end(): Promise<void>;
|
|
10
|
+
}>;
|
|
11
|
+
}
|
|
12
|
+
/** The `chatcops-postgres` command. Resolves the exit code. */
|
|
13
|
+
export declare function main(args: string[], deps: CliDeps): Promise<number>;
|
|
14
|
+
//# sourceMappingURL=cli.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAGhD,eAAO,MAAM,KAAK,sKAGc,CAAC;AAEjC,MAAM,WAAW,OAAO;IACtB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IACxC,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,kDAAkD;IAClD,OAAO,CAAC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG;QAAE,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;KAAE,CAAC,CAAC;CACtE;AAQD,+DAA+D;AAC/D,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,CAiCzE"}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { migrate } from './schema.js';
|
|
2
|
+
export const USAGE = `Usage: chatcops-postgres migrate [--prefix chatcops_]
|
|
3
|
+
|
|
4
|
+
Creates or updates the ChatCops tables in the database at DATABASE_URL.
|
|
5
|
+
Needs the pg package installed.`;
|
|
6
|
+
async function connectWithPg(url) {
|
|
7
|
+
const pg = await import('pg').catch(() => null);
|
|
8
|
+
if (!pg)
|
|
9
|
+
throw new Error('The migrate command needs the pg package: npm install pg');
|
|
10
|
+
return new pg.default.Pool({ connectionString: url });
|
|
11
|
+
}
|
|
12
|
+
/** The `chatcops-postgres` command. Resolves the exit code. */
|
|
13
|
+
export async function main(args, deps) {
|
|
14
|
+
if (args[0] !== 'migrate') {
|
|
15
|
+
deps.log(USAGE);
|
|
16
|
+
return args[0] === '--help' || args[0] === '-h' ? 0 : 1;
|
|
17
|
+
}
|
|
18
|
+
const prefixIndex = args.indexOf('--prefix');
|
|
19
|
+
const tablePrefix = prefixIndex === -1 ? undefined : args[prefixIndex + 1];
|
|
20
|
+
const url = deps.env.DATABASE_URL;
|
|
21
|
+
if (!url) {
|
|
22
|
+
deps.error('Set DATABASE_URL to the database to migrate.');
|
|
23
|
+
return 1;
|
|
24
|
+
}
|
|
25
|
+
let pool;
|
|
26
|
+
try {
|
|
27
|
+
pool = await (deps.connect ?? connectWithPg)(url);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
deps.error(error instanceof Error ? error.message : String(error));
|
|
31
|
+
return 1;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const { applied } = await migrate(pool, { tablePrefix });
|
|
35
|
+
deps.log(applied.length ? `Applied migrations: ${applied.join(', ')}` : 'Already up to date.');
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
// Only the message: driver errors can include the connection string.
|
|
40
|
+
deps.error(`Migration failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
await pool.end();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAEtC,MAAM,CAAC,MAAM,KAAK,GAAG;;;gCAGW,CAAC;AAUjC,KAAK,UAAU,aAAa,CAAC,GAAW;IACtC,MAAM,EAAE,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;IAChD,IAAI,CAAC,EAAE;QAAE,MAAM,IAAI,KAAK,CAAC,0DAA0D,CAAC,CAAC;IACrF,OAAO,IAAI,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,gBAAgB,EAAE,GAAG,EAAE,CAAC,CAAC;AACxD,CAAC;AAED,+DAA+D;AAC/D,MAAM,CAAC,KAAK,UAAU,IAAI,CAAC,IAAc,EAAE,IAAa;IACtD,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,SAAS,EAAE,CAAC;QAC1B,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QAChB,OAAO,IAAI,CAAC,CAAC,CAAC,KAAK,QAAQ,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC1D,CAAC;IAED,MAAM,WAAW,GAAG,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC;IAC7C,MAAM,WAAW,GAAG,WAAW,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,GAAG,CAAC,CAAC,CAAC;IAC3E,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,YAAY,CAAC;IAClC,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,IAAI,CAAC,KAAK,CAAC,8CAA8C,CAAC,CAAC;QAC3D,OAAO,CAAC,CAAC;IACX,CAAC;IAED,IAAI,IAA0C,CAAC;IAC/C,IAAI,CAAC;QACH,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,IAAI,aAAa,CAAC,CAAC,GAAG,CAAC,CAAC;IACpD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,CAAC,KAAK,CAAC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;QACnE,OAAO,CAAC,CAAC;IACX,CAAC;IAED,IAAI,CAAC;QACH,MAAM,EAAE,OAAO,EAAE,GAAG,MAAM,OAAO,CAAC,IAAI,EAAE,EAAE,WAAW,EAAE,CAAC,CAAC;QACzD,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,uBAAuB,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,qBAAqB,CAAC,CAAC;QAC/F,OAAO,CAAC,CAAC;IACX,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,qEAAqE;QACrE,IAAI,CAAC,KAAK,CAAC,qBAAqB,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QAC1F,OAAO,CAAC,CAAC;IACX,CAAC;YAAS,CAAC;QACT,MAAM,IAAI,CAAC,GAAG,EAAE,CAAC;IACnB,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type AppendOptions, type ChatMessage, type Conversation, type ConversationStore, type ConversationSummary, type ListQuery } from '@chatcops/core';
|
|
2
|
+
import type { Queryable } from './queryable.js';
|
|
3
|
+
import type { RowHook } from './row-hook.js';
|
|
4
|
+
import type { TableNames } from './schema.js';
|
|
5
|
+
export declare class PostgresConversationStore implements ConversationStore {
|
|
6
|
+
private readonly db;
|
|
7
|
+
private readonly t;
|
|
8
|
+
private readonly now;
|
|
9
|
+
private readonly hook?;
|
|
10
|
+
constructor(db: Queryable, t: TableNames, now: () => number, hook?: RowHook | undefined);
|
|
11
|
+
/** The hook's extra columns for one new row, as SQL and bound params. */
|
|
12
|
+
private extraFor;
|
|
13
|
+
/**
|
|
14
|
+
* Inserts `messages` after the conversation's last sequence number. `conv`
|
|
15
|
+
* is a CTE returning `next_seq` after the upsert, which already counts
|
|
16
|
+
* these messages, so the first one gets `next_seq - count + 1`.
|
|
17
|
+
*/
|
|
18
|
+
private insertMessages;
|
|
19
|
+
get(id: string): Promise<Conversation | null>;
|
|
20
|
+
/** Replaces the whole conversation, as 0.4 stores did. */
|
|
21
|
+
save(conversation: Conversation): Promise<void>;
|
|
22
|
+
delete(id: string): Promise<void>;
|
|
23
|
+
append(id: string, messages: ChatMessage[], options?: AppendOptions): Promise<{
|
|
24
|
+
version: number;
|
|
25
|
+
}>;
|
|
26
|
+
updateMessage(id: string, message: ChatMessage): Promise<boolean>;
|
|
27
|
+
removeMessage(id: string, messageId: string): Promise<boolean>;
|
|
28
|
+
list(query: ListQuery): Promise<{
|
|
29
|
+
items: ConversationSummary[];
|
|
30
|
+
cursor?: string;
|
|
31
|
+
}>;
|
|
32
|
+
/**
|
|
33
|
+
* Deletes conversations last updated before `before`, with their messages.
|
|
34
|
+
* Also clears expired counters and keys, which nothing else removes.
|
|
35
|
+
*/
|
|
36
|
+
purge(before: number): Promise<number>;
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=conversations.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"conversations.d.ts","sourceRoot":"","sources":["../src/conversations.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,aAAa,EAClB,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,iBAAiB,EACtB,KAAK,mBAAmB,EACxB,KAAK,SAAS,EACf,MAAM,gBAAgB,CAAC;AACxB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAC7C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AA6D9C,qBAAa,yBAA0B,YAAW,iBAAiB;IAE/D,OAAO,CAAC,QAAQ,CAAC,EAAE;IACnB,OAAO,CAAC,QAAQ,CAAC,CAAC;IAClB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC;gBAHL,EAAE,EAAE,SAAS,EACb,CAAC,EAAE,UAAU,EACb,GAAG,EAAE,MAAM,MAAM,EACjB,IAAI,CAAC,EAAE,OAAO,YAAA;IAGjC,yEAAyE;IACzE,OAAO,CAAC,QAAQ;IAchB;;;;OAIG;IACH,OAAO,CAAC,cAAc;IA4BhB,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC;IAsBnD,0DAA0D;IACpD,IAAI,CAAC,YAAY,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IA2B/C,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIjC,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,WAAW,EAAE,EAAE,OAAO,GAAE,aAAkB,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IAyCtG,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC;IAoBjE,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAe9D,IAAI,CAAC,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,mBAAmB,EAAE,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAiCxF;;;OAGG;IACG,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CAY7C"}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { ConflictError, } from '@chatcops/core';
|
|
2
|
+
import { json, ms, param, toTime } from './sql.js';
|
|
3
|
+
const KNOWN_FIELDS = new Set(['id', 'role', 'content', 'parts', 'status', 'usage', 'aiGenerated', 'timestamp', 'metadata']);
|
|
4
|
+
/** The columns of a message, in insert order. */
|
|
5
|
+
const MESSAGE_COLUMNS = 'id, role, content, parts, status, usage, ai_generated, metadata, extra, sent_at';
|
|
6
|
+
function messageValues(message) {
|
|
7
|
+
const extra = Object.fromEntries(Object.entries(message).filter(([key]) => !KNOWN_FIELDS.has(key)));
|
|
8
|
+
return [
|
|
9
|
+
message.id,
|
|
10
|
+
message.role,
|
|
11
|
+
message.content,
|
|
12
|
+
json(message.parts),
|
|
13
|
+
param(message.status),
|
|
14
|
+
json(message.usage),
|
|
15
|
+
param(message.aiGenerated),
|
|
16
|
+
json(message.metadata),
|
|
17
|
+
Object.keys(extra).length ? JSON.stringify(extra) : null,
|
|
18
|
+
message.timestamp,
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
function toMessage(row) {
|
|
22
|
+
return {
|
|
23
|
+
...(row.extra ?? {}),
|
|
24
|
+
id: row.id,
|
|
25
|
+
role: row.role,
|
|
26
|
+
content: row.content,
|
|
27
|
+
...(row.parts ? { parts: row.parts } : {}),
|
|
28
|
+
...(row.status ? { status: row.status } : {}),
|
|
29
|
+
...(row.usage ? { usage: row.usage } : {}),
|
|
30
|
+
...(row.ai_generated !== null ? { aiGenerated: row.ai_generated } : {}),
|
|
31
|
+
timestamp: Number(row.sent_ms),
|
|
32
|
+
...(row.metadata ? { metadata: row.metadata } : {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export class PostgresConversationStore {
|
|
36
|
+
db;
|
|
37
|
+
t;
|
|
38
|
+
now;
|
|
39
|
+
hook;
|
|
40
|
+
constructor(db, t, now, hook) {
|
|
41
|
+
this.db = db;
|
|
42
|
+
this.t = t;
|
|
43
|
+
this.now = now;
|
|
44
|
+
this.hook = hook;
|
|
45
|
+
}
|
|
46
|
+
/** The hook's extra columns for one new row, as SQL and bound params. */
|
|
47
|
+
extraFor(table, context, params) {
|
|
48
|
+
if (!this.hook)
|
|
49
|
+
return { columns: '', values: '' };
|
|
50
|
+
const row = this.hook.values({ table, ...context });
|
|
51
|
+
const values = this.hook.columns.map(([name, type]) => {
|
|
52
|
+
params.push(row[name] ?? null);
|
|
53
|
+
return `$${params.length}::${type}`;
|
|
54
|
+
});
|
|
55
|
+
return { columns: this.hook.columnList, values: values.map((v) => `, ${v}`).join('') };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Inserts `messages` after the conversation's last sequence number. `conv`
|
|
59
|
+
* is a CTE returning `next_seq` after the upsert, which already counts
|
|
60
|
+
* these messages, so the first one gets `next_seq - count + 1`.
|
|
61
|
+
*/
|
|
62
|
+
insertMessages(conversationId, metadata, messages, params, idParam) {
|
|
63
|
+
let hookColumns = '';
|
|
64
|
+
const rows = messages.map((message, i) => {
|
|
65
|
+
const placeholders = messageValues(message).map((value) => {
|
|
66
|
+
params.push(value);
|
|
67
|
+
return `$${params.length}`;
|
|
68
|
+
});
|
|
69
|
+
const [id, role, content, parts, status, usage, ai, meta, extra, sent] = placeholders;
|
|
70
|
+
const hooked = this.extraFor('messages', { conversationId, metadata, message }, params);
|
|
71
|
+
hookColumns = hooked.columns;
|
|
72
|
+
return `(${i + 1}, ${id}::text, ${role}::text, ${content}::text, ${parts}::text::jsonb, ${status}::text, ${usage}::text::jsonb, ${ai}::boolean, ${meta}::text::jsonb, ${extra}::text::jsonb, ${toTime(sent)}${hooked.values})`;
|
|
73
|
+
});
|
|
74
|
+
params.push(messages.length);
|
|
75
|
+
const count = `$${params.length}::int`;
|
|
76
|
+
const hookSelect = (this.hook?.columns ?? []).map(([name]) => `, v.${name}`).join('');
|
|
77
|
+
return `ins AS (
|
|
78
|
+
INSERT INTO ${this.t.messages} (conversation_id, seq, ${MESSAGE_COLUMNS}${hookColumns})
|
|
79
|
+
SELECT ${idParam}, conv.next_seq - ${count} + v.ord, v.id, v.role, v.content, v.parts, v.status, v.usage, v.ai_generated, v.metadata, v.extra, v.sent_at${hookSelect}
|
|
80
|
+
FROM conv, (VALUES ${rows.join(', ')}) AS v (ord, ${MESSAGE_COLUMNS}${hookColumns})
|
|
81
|
+
)`;
|
|
82
|
+
}
|
|
83
|
+
async get(id) {
|
|
84
|
+
const { rows } = await this.db.query(`SELECT c.id, c.metadata, c.version, ${ms('c.created_at')} AS created_ms, ${ms('c.updated_at')} AS updated_ms,
|
|
85
|
+
coalesce((
|
|
86
|
+
SELECT jsonb_agg(to_jsonb(m) - 'conversation_id' || jsonb_build_object('sent_ms', ${ms('m.sent_at')}) ORDER BY m.seq)
|
|
87
|
+
FROM ${this.t.messages} m WHERE m.conversation_id = c.id
|
|
88
|
+
), '[]'::jsonb) AS messages
|
|
89
|
+
FROM ${this.t.conversations} c WHERE c.id = $1`, [id]);
|
|
90
|
+
const row = rows[0];
|
|
91
|
+
if (!row)
|
|
92
|
+
return null;
|
|
93
|
+
return {
|
|
94
|
+
id: row.id,
|
|
95
|
+
messages: row.messages.map(toMessage),
|
|
96
|
+
...(row.metadata ? { metadata: row.metadata } : {}),
|
|
97
|
+
version: Number(row.version),
|
|
98
|
+
createdAt: Number(row.created_ms),
|
|
99
|
+
updatedAt: Number(row.updated_ms),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** Replaces the whole conversation, as 0.4 stores did. */
|
|
103
|
+
async save(conversation) {
|
|
104
|
+
const params = [conversation.id, json(conversation.metadata), conversation.messages.length, conversation.createdAt, this.now()];
|
|
105
|
+
const hooked = this.extraFor('conversations', { conversationId: conversation.id, metadata: conversation.metadata }, params);
|
|
106
|
+
const insert = conversation.messages.length
|
|
107
|
+
? `, ${this.insertMessages(conversation.id, conversation.metadata, conversation.messages, params, '$1')}`
|
|
108
|
+
: '';
|
|
109
|
+
await this.db.query(`WITH conv AS (
|
|
110
|
+
INSERT INTO ${this.t.conversations} AS c (id, metadata, version, next_seq, created_at, updated_at${hooked.columns})
|
|
111
|
+
VALUES ($1, $2::text::jsonb, 1, $3::int, ${toTime('$4')}, ${toTime('$5')}${hooked.values})
|
|
112
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
113
|
+
metadata = EXCLUDED.metadata,
|
|
114
|
+
version = c.version + 1,
|
|
115
|
+
next_seq = c.next_seq + EXCLUDED.next_seq,
|
|
116
|
+
created_at = EXCLUDED.created_at,
|
|
117
|
+
updated_at = EXCLUDED.updated_at
|
|
118
|
+
RETURNING c.version, c.next_seq
|
|
119
|
+
),
|
|
120
|
+
del AS (
|
|
121
|
+
DELETE FROM ${this.t.messages}
|
|
122
|
+
WHERE conversation_id = $1 AND seq <= (SELECT next_seq FROM conv) - $3::int
|
|
123
|
+
)${insert}
|
|
124
|
+
SELECT version FROM conv`, params);
|
|
125
|
+
}
|
|
126
|
+
async delete(id) {
|
|
127
|
+
await this.db.query(`DELETE FROM ${this.t.conversations} WHERE id = $1`, [id]);
|
|
128
|
+
}
|
|
129
|
+
async append(id, messages, options = {}) {
|
|
130
|
+
const expected = options.expectedVersion ?? null;
|
|
131
|
+
const params = [id, json(options.metadata), messages.length, this.now(), expected];
|
|
132
|
+
const hooked = this.extraFor('conversations', { conversationId: id, metadata: options.metadata }, params);
|
|
133
|
+
const insert = messages.length ? `, ${this.insertMessages(id, options.metadata, messages, params, '$1')}` : '';
|
|
134
|
+
// One statement, so it's atomic without a transaction. The upsert locks
|
|
135
|
+
// the conversation row, and a concurrent append waits for it and then
|
|
136
|
+
// sees the latest version and sequence number. A new conversation is only
|
|
137
|
+
// created when no version, or version 0, was expected.
|
|
138
|
+
const { rows } = await this.db.query(`WITH conv AS (
|
|
139
|
+
INSERT INTO ${this.t.conversations} AS c (id, metadata, version, next_seq, created_at, updated_at${hooked.columns})
|
|
140
|
+
SELECT $1, $2::text::jsonb, 1, $3::int, ${toTime('$4')}, ${toTime('$4')}${hooked.values}
|
|
141
|
+
WHERE $5::int IS NULL OR $5::int = 0
|
|
142
|
+
OR EXISTS (SELECT 1 FROM ${this.t.conversations} WHERE id = $1)
|
|
143
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
144
|
+
version = c.version + 1,
|
|
145
|
+
next_seq = c.next_seq + EXCLUDED.next_seq,
|
|
146
|
+
updated_at = EXCLUDED.updated_at,
|
|
147
|
+
metadata = CASE
|
|
148
|
+
WHEN EXCLUDED.metadata IS NULL THEN c.metadata
|
|
149
|
+
ELSE coalesce(c.metadata, '{}'::jsonb) || EXCLUDED.metadata
|
|
150
|
+
END
|
|
151
|
+
WHERE $5::int IS NULL OR c.version = $5::int
|
|
152
|
+
RETURNING c.version, c.next_seq
|
|
153
|
+
)${insert}
|
|
154
|
+
SELECT version FROM conv`, params);
|
|
155
|
+
const row = rows[0];
|
|
156
|
+
if (row)
|
|
157
|
+
return { version: Number(row.version) };
|
|
158
|
+
const current = await this.db.query(`SELECT version FROM ${this.t.conversations} WHERE id = $1`, [id]);
|
|
159
|
+
throw new ConflictError(id, expected ?? 0, Number(current.rows[0]?.version ?? 0));
|
|
160
|
+
}
|
|
161
|
+
async updateMessage(id, message) {
|
|
162
|
+
const values = messageValues(message);
|
|
163
|
+
const { rows } = await this.db.query(`WITH m AS (
|
|
164
|
+
UPDATE ${this.t.messages} SET
|
|
165
|
+
role = $3::text, content = $4::text, parts = $5::text::jsonb, status = $6::text, usage = $7::text::jsonb,
|
|
166
|
+
ai_generated = $8::boolean, metadata = $9::text::jsonb, extra = $10::text::jsonb, sent_at = ${toTime('$11')}
|
|
167
|
+
WHERE conversation_id = $1 AND id = $2
|
|
168
|
+
RETURNING 1
|
|
169
|
+
),
|
|
170
|
+
bump AS (
|
|
171
|
+
UPDATE ${this.t.conversations} SET version = version + 1, updated_at = ${toTime('$12')}
|
|
172
|
+
WHERE id = $1 AND EXISTS (SELECT 1 FROM m)
|
|
173
|
+
)
|
|
174
|
+
SELECT count(*)::int AS updated FROM m`, [id, ...values, this.now()]);
|
|
175
|
+
return Number(rows[0]?.updated ?? 0) > 0;
|
|
176
|
+
}
|
|
177
|
+
async removeMessage(id, messageId) {
|
|
178
|
+
const { rows } = await this.db.query(`WITH m AS (
|
|
179
|
+
DELETE FROM ${this.t.messages} WHERE conversation_id = $1 AND id = $2 RETURNING 1
|
|
180
|
+
),
|
|
181
|
+
bump AS (
|
|
182
|
+
UPDATE ${this.t.conversations} SET version = version + 1, updated_at = ${toTime('$3')}
|
|
183
|
+
WHERE id = $1 AND EXISTS (SELECT 1 FROM m)
|
|
184
|
+
)
|
|
185
|
+
SELECT count(*)::int AS removed FROM m`, [id, messageId, this.now()]);
|
|
186
|
+
return Number(rows[0]?.removed ?? 0) > 0;
|
|
187
|
+
}
|
|
188
|
+
async list(query) {
|
|
189
|
+
let cursorTime = null;
|
|
190
|
+
let cursorId = null;
|
|
191
|
+
if (query.cursor) {
|
|
192
|
+
const split = query.cursor.indexOf(':');
|
|
193
|
+
cursorTime = Number(query.cursor.slice(0, split));
|
|
194
|
+
cursorId = query.cursor.slice(split + 1);
|
|
195
|
+
if (split === -1 || !Number.isFinite(cursorTime))
|
|
196
|
+
throw new Error(`Not a list cursor: "${query.cursor}".`);
|
|
197
|
+
}
|
|
198
|
+
const { rows } = await this.db.query(`SELECT c.id, c.metadata, ${ms('c.created_at')} AS created_ms, ${ms('c.updated_at')} AS updated_ms,
|
|
199
|
+
(SELECT count(*)::int FROM ${this.t.messages} m WHERE m.conversation_id = c.id) AS message_count
|
|
200
|
+
FROM ${this.t.conversations} c
|
|
201
|
+
WHERE ($1::float8 IS NULL OR c.updated_at > ${toTime('$1')})
|
|
202
|
+
AND ($2::float8 IS NULL OR c.updated_at < ${toTime('$2')} OR (c.updated_at = ${toTime('$2')} AND c.id > $3::text))
|
|
203
|
+
ORDER BY c.updated_at DESC, c.id ASC
|
|
204
|
+
LIMIT $4::int`, [param(query.updatedAfter), cursorTime, cursorId, query.limit + 1]);
|
|
205
|
+
const page = rows.slice(0, query.limit);
|
|
206
|
+
const items = page.map((row) => ({
|
|
207
|
+
id: row.id,
|
|
208
|
+
createdAt: Number(row.created_ms),
|
|
209
|
+
updatedAt: Number(row.updated_ms),
|
|
210
|
+
messageCount: Number(row.message_count),
|
|
211
|
+
...(row.metadata ? { metadata: row.metadata } : {}),
|
|
212
|
+
}));
|
|
213
|
+
const last = items.at(-1);
|
|
214
|
+
return rows.length > query.limit && last ? { items, cursor: `${last.updatedAt}:${last.id}` } : { items };
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Deletes conversations last updated before `before`, with their messages.
|
|
218
|
+
* Also clears expired counters and keys, which nothing else removes.
|
|
219
|
+
*/
|
|
220
|
+
async purge(before) {
|
|
221
|
+
const { rows } = await this.db.query(`WITH gone AS (
|
|
222
|
+
DELETE FROM ${this.t.conversations} WHERE updated_at < ${toTime('$1')} RETURNING 1
|
|
223
|
+
),
|
|
224
|
+
counters AS (DELETE FROM ${this.t.counters} WHERE expires_at <= ${toTime('$2')}),
|
|
225
|
+
keys AS (DELETE FROM ${this.t.kv} WHERE expires_at <= ${toTime('$2')})
|
|
226
|
+
SELECT count(*)::int AS purged FROM gone`, [before, this.now()]);
|
|
227
|
+
return Number(rows[0]?.purged ?? 0);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
//# sourceMappingURL=conversations.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"conversations.js","sourceRoot":"","sources":["../src/conversations.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AnalyticsEvent, CounterStore, CounterValue, EventCount, EventQuery, EventSink, KeyValueStore } from '@chatcops/core';
|
|
2
|
+
import type { Queryable } from './queryable.js';
|
|
3
|
+
import type { TableNames } from './schema.js';
|
|
4
|
+
/** One upsert per increment, so concurrent increments never lose a count. */
|
|
5
|
+
export declare class PostgresCounterStore implements CounterStore {
|
|
6
|
+
private readonly db;
|
|
7
|
+
private readonly t;
|
|
8
|
+
private readonly now;
|
|
9
|
+
constructor(db: Queryable, t: TableNames, now: () => number);
|
|
10
|
+
increment(key: string, by: number, ttlMs: number): Promise<CounterValue>;
|
|
11
|
+
get(key: string): Promise<CounterValue | null>;
|
|
12
|
+
}
|
|
13
|
+
/** JSON values with an optional expiry. Expired rows are ignored, and cleared by `purge()`. */
|
|
14
|
+
export declare class PostgresKeyValueStore implements KeyValueStore {
|
|
15
|
+
private readonly db;
|
|
16
|
+
private readonly t;
|
|
17
|
+
private readonly now;
|
|
18
|
+
constructor(db: Queryable, t: TableNames, now: () => number);
|
|
19
|
+
private expiry;
|
|
20
|
+
get<T = unknown>(key: string): Promise<T | null>;
|
|
21
|
+
set(key: string, value: unknown, options?: {
|
|
22
|
+
ttlMs?: number;
|
|
23
|
+
}): Promise<void>;
|
|
24
|
+
setIfAbsent(key: string, value: unknown, options?: {
|
|
25
|
+
ttlMs?: number;
|
|
26
|
+
}): Promise<boolean>;
|
|
27
|
+
delete(key: string): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
/** Events as rows, counted with SQL. Writes go in batches of up to 500 rows. */
|
|
30
|
+
export declare class PostgresEventSink implements EventSink {
|
|
31
|
+
private readonly db;
|
|
32
|
+
private readonly t;
|
|
33
|
+
constructor(db: Queryable, t: TableNames);
|
|
34
|
+
write(events: AnalyticsEvent[]): Promise<void>;
|
|
35
|
+
query(query: EventQuery): Promise<EventCount[]>;
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=counters.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"counters.d.ts","sourceRoot":"","sources":["../src/counters.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,YAAY,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACnI,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAM9C,6EAA6E;AAC7E,qBAAa,oBAAqB,YAAW,YAAY;IAErD,OAAO,CAAC,QAAQ,CAAC,EAAE;IACnB,OAAO,CAAC,QAAQ,CAAC,CAAC;IAClB,OAAO,CAAC,QAAQ,CAAC,GAAG;gBAFH,EAAE,EAAE,SAAS,EACb,CAAC,EAAE,UAAU,EACb,GAAG,EAAE,MAAM,MAAM;IAG9B,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;IAcxE,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC;CAQrD;AAED,+FAA+F;AAC/F,qBAAa,qBAAsB,YAAW,aAAa;IAEvD,OAAO,CAAC,QAAQ,CAAC,EAAE;IACnB,OAAO,CAAC,QAAQ,CAAC,CAAC;IAClB,OAAO,CAAC,QAAQ,CAAC,GAAG;gBAFH,EAAE,EAAE,SAAS,EACb,CAAC,EAAE,UAAU,EACb,GAAG,EAAE,MAAM,MAAM;IAGpC,OAAO,CAAC,MAAM;IAIR,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;IAShD,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,GAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IASjF,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,GAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IAa5F,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAGzC;AAED,gFAAgF;AAChF,qBAAa,iBAAkB,YAAW,SAAS;IAE/C,OAAO,CAAC,QAAQ,CAAC,EAAE;IACnB,OAAO,CAAC,QAAQ,CAAC,CAAC;gBADD,EAAE,EAAE,SAAS,EACb,CAAC,EAAE,UAAU;IAG1B,KAAK,CAAC,MAAM,EAAE,cAAc,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAiB9C,KAAK,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC;CAatD"}
|