@owlmeans/bot-postgres 0.0.0-stage → 0.1.18-rc.2
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/README.md +143 -2
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/bot-postgres/SKILL.md +181 -0
- package/build/helper.d.ts +9 -0
- package/build/helper.d.ts.map +1 -0
- package/build/helper.js +27 -0
- package/build/helper.js.map +1 -0
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +3 -0
- package/build/index.js.map +1 -0
- package/build/resource.d.ts +23 -0
- package/build/resource.d.ts.map +1 -0
- package/build/resource.js +75 -0
- package/build/resource.js.map +1 -0
- package/build/types.d.ts +26 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/types.local.d.ts +4 -0
- package/build/types.local.d.ts.map +1 -0
- package/build/types.local.js +2 -0
- package/build/types.local.js.map +1 -0
- package/package.json +52 -4
- package/src/helper.ts +33 -0
- package/src/index.ts +3 -0
- package/src/resource.ts +100 -0
- package/src/types.local.ts +4 -0
- package/src/types.ts +28 -0
- package/tests/conformance.spec.ts +41 -0
- package/tests/context.ts +103 -0
- package/tests/schema.spec.ts +187 -0
- package/tests/tables.spec.ts +89 -0
- package/tests/tsconfig.json +12 -0
- package/tsconfig.json +11 -0
package/src/helper.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { BasicContext } from '@owlmeans/context'
|
|
2
|
+
import { RES_BOT_ACTIVITY, RES_BOT_CONNECTION, RES_BOT_RULE, RES_BOT_UPDATE } from '@owlmeans/server-bot'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
makeBotActivityResource, makeBotConnectionResource, makeBotRuleResource, makeBotUpdateResource,
|
|
6
|
+
} from './resource.js'
|
|
7
|
+
import type { BotPostgresOptions } from './types.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Registers the four bot resources, each only while its alias is free — a second call (a context
|
|
11
|
+
* composed in two places, a hot reload) leaves the registered ones alone. Registration order against
|
|
12
|
+
* `appendBot` is free: the service resolves its resources by alias on every call.
|
|
13
|
+
*/
|
|
14
|
+
export const appendPostgresBot = (ctx: BasicContext<any>, opts: BotPostgresOptions = {}): void => {
|
|
15
|
+
const { dbAlias, serviceAlias, resources = {} } = opts
|
|
16
|
+
const connection = resources.connection ?? RES_BOT_CONNECTION
|
|
17
|
+
const rule = resources.rule ?? RES_BOT_RULE
|
|
18
|
+
const update = resources.update ?? RES_BOT_UPDATE
|
|
19
|
+
const activity = resources.activity ?? RES_BOT_ACTIVITY
|
|
20
|
+
|
|
21
|
+
if (!ctx.hasResource(connection)) {
|
|
22
|
+
ctx.registerResource(makeBotConnectionResource(dbAlias, connection, serviceAlias))
|
|
23
|
+
}
|
|
24
|
+
if (!ctx.hasResource(rule)) {
|
|
25
|
+
ctx.registerResource(makeBotRuleResource(dbAlias, rule, serviceAlias))
|
|
26
|
+
}
|
|
27
|
+
if (!ctx.hasResource(update)) {
|
|
28
|
+
ctx.registerResource(makeBotUpdateResource(dbAlias, update, serviceAlias))
|
|
29
|
+
}
|
|
30
|
+
if (!ctx.hasResource(activity)) {
|
|
31
|
+
ctx.registerResource(makeBotActivityResource(dbAlias, activity, serviceAlias))
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/index.ts
ADDED
package/src/resource.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { makePostgresResource, pgDeclarationHelper, pgNameHelper } from '@owlmeans/postgres-resource'
|
|
2
|
+
import type { PostgresResource } from '@owlmeans/postgres-resource'
|
|
3
|
+
import type { ResourceRecord } from '@owlmeans/resource'
|
|
4
|
+
import {
|
|
5
|
+
BotActivityRecordSchema, BotConnectionRecordSchema, BotRuleRecordSchema, BotUpdateRecordSchema,
|
|
6
|
+
RES_BOT_ACTIVITY, RES_BOT_CONNECTION, RES_BOT_RULE, RES_BOT_UPDATE,
|
|
7
|
+
} from '@owlmeans/server-bot'
|
|
8
|
+
import type { BotActivityRecord, BotConnectionRecord, BotRuleRecord, BotUpdateRecord } from '@owlmeans/server-bot'
|
|
9
|
+
|
|
10
|
+
import type { BotActivityResource, BotConnectionResource, BotRuleResource, BotUpdateResource } from './types.js'
|
|
11
|
+
import type { IndexList } from './types.local.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Declare the indexes a maker owns, each once. Declarations are kept per alias at module scope, so a
|
|
15
|
+
* maker that runs twice (a target's file wrapping it, a spec) would otherwise declare every index
|
|
16
|
+
* again and the compiler would warn about the duplicate on every boot. The name carries the table,
|
|
17
|
+
* so a second set of bot tables under other aliases in the same schema never collides with the first.
|
|
18
|
+
*/
|
|
19
|
+
const declareIndexes = <T extends ResourceRecord>(resource: PostgresResource<T>, alias: string, indexes: IndexList): void => {
|
|
20
|
+
const table = pgNameHelper.pgIdentifier(alias)
|
|
21
|
+
const declared = pgDeclarationHelper.getDeclaration(alias).indexes
|
|
22
|
+
for (const [suffix, spec] of indexes) {
|
|
23
|
+
const name = `${table}_${suffix}`
|
|
24
|
+
if (!declared.some(index => index.name === name)) {
|
|
25
|
+
resource.index(name, spec)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `bot-connection` — the connections of every scope. The schema is `server-bot`'s own, assigned, never
|
|
32
|
+
* copied: `identity` compiles to one `jsonb` column, `setup` and `credentialsSet` to `text[]`, the
|
|
33
|
+
* sealed credentials to one `text` column. Indexes follow the reads: a scope's list by creation, and
|
|
34
|
+
* the worker's reconciliation (enabled, connected, polling or on the gateway).
|
|
35
|
+
*/
|
|
36
|
+
export const makeBotConnectionResource = (
|
|
37
|
+
dbAlias?: string, alias: string = RES_BOT_CONNECTION, serviceAlias?: string
|
|
38
|
+
): BotConnectionResource => {
|
|
39
|
+
const resource = makePostgresResource<BotConnectionRecord, BotConnectionResource>(alias, dbAlias, serviceAlias)
|
|
40
|
+
resource.schema = BotConnectionRecordSchema
|
|
41
|
+
declareIndexes(resource, alias, [
|
|
42
|
+
['scope_created', { columns: ['scope', 'createdAt'] }],
|
|
43
|
+
['receive', { columns: ['enabled', 'status', 'receive'] }],
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
return resource
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `bot-rule` — keyword rules, scope-wide (`connection` null) or bound to one connection; `keywords` is
|
|
51
|
+
* `text[]`, `order` a `double precision` column (quoted wherever the store names it).
|
|
52
|
+
*/
|
|
53
|
+
export const makeBotRuleResource = (
|
|
54
|
+
dbAlias?: string, alias: string = RES_BOT_RULE, serviceAlias?: string
|
|
55
|
+
): BotRuleResource => {
|
|
56
|
+
const resource = makePostgresResource<BotRuleRecord, BotRuleResource>(alias, dbAlias, serviceAlias)
|
|
57
|
+
resource.schema = BotRuleRecordSchema
|
|
58
|
+
declareIndexes(resource, alias, [
|
|
59
|
+
['scope', { columns: ['scope'] }],
|
|
60
|
+
['scope_connection', { columns: ['scope', 'connection'] }],
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
return resource
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `bot-update` — the inbox. `dedupe` is UNIQUE: two concurrent deliveries of one platform update
|
|
68
|
+
* collide here (`RecordExists`) and the service reads the stored row back. The normalized `update`
|
|
69
|
+
* is one `jsonb` column, `handled` a `text[]`. Indexes follow the dispatcher (pending rows by age,
|
|
70
|
+
* expired leases), a connection's newest update, and the retention purge.
|
|
71
|
+
*/
|
|
72
|
+
export const makeBotUpdateResource = (
|
|
73
|
+
dbAlias?: string, alias: string = RES_BOT_UPDATE, serviceAlias?: string
|
|
74
|
+
): BotUpdateResource => {
|
|
75
|
+
const resource = makePostgresResource<BotUpdateRecord, BotUpdateResource>(alias, dbAlias, serviceAlias)
|
|
76
|
+
resource.schema = BotUpdateRecordSchema
|
|
77
|
+
declareIndexes(resource, alias, [
|
|
78
|
+
['dedupe', { columns: ['dedupe'], unique: true }],
|
|
79
|
+
['inbox', { columns: ['status', 'receivedAt'] }],
|
|
80
|
+
['lease', { columns: ['status', 'claimedAt'] }],
|
|
81
|
+
['connection_received', { columns: ['connection', 'receivedAt'] }],
|
|
82
|
+
['retention', { columns: ['status', 'processedAt'] }],
|
|
83
|
+
])
|
|
84
|
+
|
|
85
|
+
return resource
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `bot-activity` — what every action did; `target` is one `jsonb` column. Read newest first per scope. */
|
|
89
|
+
export const makeBotActivityResource = (
|
|
90
|
+
dbAlias?: string, alias: string = RES_BOT_ACTIVITY, serviceAlias?: string
|
|
91
|
+
): BotActivityResource => {
|
|
92
|
+
const resource = makePostgresResource<BotActivityRecord, BotActivityResource>(alias, dbAlias, serviceAlias)
|
|
93
|
+
resource.schema = BotActivityRecordSchema
|
|
94
|
+
declareIndexes(resource, alias, [
|
|
95
|
+
['scope_at', { columns: ['scope', 'at'] }],
|
|
96
|
+
['scope_connection_at', { columns: ['scope', 'connection', 'at'] }],
|
|
97
|
+
])
|
|
98
|
+
|
|
99
|
+
return resource
|
|
100
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { PostgresResource } from '@owlmeans/postgres-resource'
|
|
2
|
+
import type {
|
|
3
|
+
BotActivityRecord, BotConnectionRecord, BotResourceAliases, BotRuleRecord, BotUpdateRecord,
|
|
4
|
+
} from '@owlmeans/server-bot'
|
|
5
|
+
|
|
6
|
+
/** `bot-connection` — one row per connected bot, its credentials sealed in one text column. */
|
|
7
|
+
export interface BotConnectionResource extends PostgresResource<BotConnectionRecord> {}
|
|
8
|
+
|
|
9
|
+
/** `bot-rule` — the keyword rules of a scope, scope-wide or bound to one connection. */
|
|
10
|
+
export interface BotRuleResource extends PostgresResource<BotRuleRecord> {}
|
|
11
|
+
|
|
12
|
+
/** `bot-update` — the inbox: one row per inbound platform update, unique by its `dedupe` key. */
|
|
13
|
+
export interface BotUpdateResource extends PostgresResource<BotUpdateRecord> {}
|
|
14
|
+
|
|
15
|
+
/** `bot-activity` — one row per action a bot took (or refused), newest read first. */
|
|
16
|
+
export interface BotActivityResource extends PostgresResource<BotActivityRecord> {}
|
|
17
|
+
|
|
18
|
+
export interface BotPostgresOptions {
|
|
19
|
+
/** The `cfg.dbs` entry the four tables live in — default `postgres`. */
|
|
20
|
+
dbAlias?: string
|
|
21
|
+
/** The Postgres connection service — default `postgres`. */
|
|
22
|
+
serviceAlias?: string
|
|
23
|
+
/**
|
|
24
|
+
* The resources' aliases — default `bot-connection`, `bot-rule`, `bot-update`, `bot-activity`.
|
|
25
|
+
* The bot service must be given the same ones (`appendBot(ctx, { resources })`).
|
|
26
|
+
*/
|
|
27
|
+
resources?: BotResourceAliases
|
|
28
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { botConformance, botConformanceCasesFor, makeBotConformanceSubject } from '@owlmeans/server-bot/conformance'
|
|
3
|
+
import type { BotConformanceSubject } from '@owlmeans/server-bot/conformance'
|
|
4
|
+
|
|
5
|
+
import { gate, makeSuite } from './context.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The store conformance suite of `@owlmeans/server-bot`, against a real database: every case,
|
|
9
|
+
* including the one only a store with a unique dedupe index qualifies for. Each case works in a scope
|
|
10
|
+
* of its own, so one booted context serves them all.
|
|
11
|
+
*/
|
|
12
|
+
const suite = makeSuite('conformance')
|
|
13
|
+
const cases = botConformanceCasesFor(['unique'])
|
|
14
|
+
|
|
15
|
+
describe('@owlmeans/bot-postgres — store conformance', () => {
|
|
16
|
+
test('a store with the unique dedupe index qualifies for every case', () => {
|
|
17
|
+
expect(cases.length).toBe(botConformance.length)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
if (gate.skip) {
|
|
21
|
+
test.skip(gate.reason ?? 'postgres gate closed', () => {})
|
|
22
|
+
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let subject: BotConformanceSubject
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
await suite.boot({
|
|
30
|
+
prepare: context => { subject = makeBotConformanceSubject(context, { capabilities: ['unique'] }) },
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
afterAll(async () => {
|
|
35
|
+
await suite.teardown()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
for (const entry of cases) {
|
|
39
|
+
test(entry.name, async () => { await entry.run(subject) }, 20_000)
|
|
40
|
+
}
|
|
41
|
+
})
|
package/tests/context.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { randomNamespace, gateHelper } from '@owlmeans/test-integration'
|
|
2
|
+
import type { IntegrationGate, PostgresEnv } from '@owlmeans/test-integration'
|
|
3
|
+
import { configureLog, logger } from '@owlmeans/log'
|
|
4
|
+
import { PgAutoSync } from '@owlmeans/postgres-resource'
|
|
5
|
+
import { config, makeServerContext } from '@owlmeans/server-context'
|
|
6
|
+
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
7
|
+
import { Pool } from 'pg'
|
|
8
|
+
|
|
9
|
+
import { appendPostgres } from '@owlmeans/postgres'
|
|
10
|
+
import type { PostgresService } from '@owlmeans/postgres'
|
|
11
|
+
|
|
12
|
+
import { appendPostgresBot } from '../src/index.js'
|
|
13
|
+
import type { BotPostgresOptions } from '../src/index.js'
|
|
14
|
+
|
|
15
|
+
// Handler failures and refused actions are expected in the conformance cases; `LOG_LEVEL=debug` shows them.
|
|
16
|
+
configureLog({ level: process.env.LOG_LEVEL ?? 'error' })
|
|
17
|
+
|
|
18
|
+
const log = logger('bot-postgres:tests')
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Env-gated integration harness, the shape `@owlmeans/marketing-consent-postgres` and
|
|
22
|
+
* `@owlmeans/planning-postgres` use: a real `ServerContext` per boot against a throwaway schema of
|
|
23
|
+
* this suite's own, dropped in `teardown()`. Specs skip cleanly when `POSTGRES_URL` is unset or
|
|
24
|
+
* nothing answers at it.
|
|
25
|
+
*/
|
|
26
|
+
export const gate: IntegrationGate<PostgresEnv> = gateHelper.postgresGate()
|
|
27
|
+
|
|
28
|
+
const url = (): string => gate.env.POSTGRES_URL as string
|
|
29
|
+
|
|
30
|
+
export const raw = async <R>(fn: (pool: Pool) => Promise<R>): Promise<R> => {
|
|
31
|
+
const pool = new Pool({ connectionString: url(), max: 1 })
|
|
32
|
+
try {
|
|
33
|
+
return await fn(pool)
|
|
34
|
+
} finally {
|
|
35
|
+
await pool.end().catch(() => undefined)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface BootOptions {
|
|
40
|
+
/** Passed to `appendPostgresBot`. */
|
|
41
|
+
bot?: BotPostgresOptions
|
|
42
|
+
/** Runs after the resources are registered and before `init()` — where a service is appended. */
|
|
43
|
+
prepare?: (context: ServerContext<ServerConfig>) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Booted {
|
|
47
|
+
context: ServerContext<ServerConfig>
|
|
48
|
+
pg: PostgresService
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface Suite {
|
|
52
|
+
schema: string
|
|
53
|
+
boot: (opts?: BootOptions) => Promise<Booted>
|
|
54
|
+
teardown: () => Promise<void>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const makeSuite = (label: string): Suite => {
|
|
58
|
+
const prefix = process.env.POSTGRES_TEST_DB_PREFIX ?? 'omt'
|
|
59
|
+
const schema = randomNamespace(`${prefix}_bot_${label}`)
|
|
60
|
+
const pools: Pool[] = []
|
|
61
|
+
|
|
62
|
+
const boot = async (opts: BootOptions = {}): Promise<Booted> => {
|
|
63
|
+
const cfg: ServerConfig = config('bot-pg-test', {
|
|
64
|
+
dbs: [{
|
|
65
|
+
service: 'postgres', alias: 'postgres', host: '127.0.0.1', schema,
|
|
66
|
+
meta: { url: url(), max: 4, retries: 3, retryDelayMillis: 200, autoSync: PgAutoSync.Full },
|
|
67
|
+
}],
|
|
68
|
+
} as Partial<ServerConfig>)
|
|
69
|
+
|
|
70
|
+
const context = makeServerContext(cfg) as ServerContext<ServerConfig>
|
|
71
|
+
appendPostgres(context)
|
|
72
|
+
appendPostgresBot(context, opts.bot)
|
|
73
|
+
opts.prepare?.(context)
|
|
74
|
+
|
|
75
|
+
context.configure()
|
|
76
|
+
const pg = context.service<PostgresService>('postgres')
|
|
77
|
+
try {
|
|
78
|
+
await context.init()
|
|
79
|
+
} finally {
|
|
80
|
+
for (const pool of Object.values(pg.clients ?? {})) {
|
|
81
|
+
if (!pools.includes(pool)) {
|
|
82
|
+
pools.push(pool)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { context, pg }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const teardown = async (): Promise<void> => {
|
|
91
|
+
if (gate.skip) {
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
await raw(async pool => { await pool.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`) })
|
|
95
|
+
.catch(error => log.error('Test schema not dropped', { schema, error }))
|
|
96
|
+
/** After the drop — a pool still holding the schema open makes CASCADE wait. */
|
|
97
|
+
while (pools.length > 0) {
|
|
98
|
+
await pools.pop()?.end().catch(() => undefined)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { schema, boot, teardown }
|
|
103
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { AppType, makeBasicContext, type BasicConfig } from '@owlmeans/context'
|
|
3
|
+
import { addLogPlugin, memoryPlugin } from '@owlmeans/log'
|
|
4
|
+
import { pgDeclarationHelper, pgDiffHelper, pgSchemaHelper } from '@owlmeans/postgres-resource'
|
|
5
|
+
import type { TableSpec } from '@owlmeans/postgres-resource'
|
|
6
|
+
import {
|
|
7
|
+
BotActivityRecordSchema, BotConnectionRecordSchema, BotRuleRecordSchema, BotUpdateRecordSchema,
|
|
8
|
+
RES_BOT_ACTIVITY, RES_BOT_CONNECTION, RES_BOT_RULE, RES_BOT_UPDATE,
|
|
9
|
+
} from '@owlmeans/server-bot'
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
appendPostgresBot, makeBotActivityResource, makeBotConnectionResource, makeBotRuleResource, makeBotUpdateResource,
|
|
13
|
+
} from '../src/index.js'
|
|
14
|
+
import type { BotUpdateResource } from '../src/index.js'
|
|
15
|
+
|
|
16
|
+
const ALIASES = [RES_BOT_CONNECTION, RES_BOT_RULE, RES_BOT_UPDATE, RES_BOT_ACTIVITY]
|
|
17
|
+
|
|
18
|
+
const SCHEMAS: Record<string, object> = {
|
|
19
|
+
[RES_BOT_CONNECTION]: BotConnectionRecordSchema,
|
|
20
|
+
[RES_BOT_RULE]: BotRuleRecordSchema,
|
|
21
|
+
[RES_BOT_UPDATE]: BotUpdateRecordSchema,
|
|
22
|
+
[RES_BOT_ACTIVITY]: BotActivityRecordSchema,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const reset = (): void => {
|
|
26
|
+
for (const alias of ALIASES) {
|
|
27
|
+
pgDeclarationHelper.resetDeclarations(alias)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const makeAll = (): void => {
|
|
32
|
+
makeBotConnectionResource()
|
|
33
|
+
makeBotRuleResource()
|
|
34
|
+
makeBotUpdateResource()
|
|
35
|
+
makeBotActivityResource()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What `init()` compiles for an alias: the shared schema plus the maker's declared indexes. */
|
|
39
|
+
const specOf = (alias: string): TableSpec => pgSchemaHelper.schemaToTableSpec(
|
|
40
|
+
alias, SCHEMAS[alias] as never, 'app', alias.replace('-', '_'), true, pgDeclarationHelper.getDeclaration(alias).indexes
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
const typeOf = (spec: TableSpec, property: string) => {
|
|
44
|
+
const column = spec.byProperty[property]
|
|
45
|
+
|
|
46
|
+
return { sqlType: column.sqlType, jsonb: column.jsonb, array: column.array, notNull: column.notNull }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The DDL a fresh table is created with — what `init()` runs against an empty schema. */
|
|
50
|
+
const ddlOf = (spec: TableSpec): string[] =>
|
|
51
|
+
pgDiffHelper.planSync(spec, { exists: false, columns: [], indexes: [], constraints: [] }).statements.map(entry => entry.sql)
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* No database: `schemaToTableSpec` is the compiler `init()` runs, so what each table becomes is
|
|
55
|
+
* pinned here — nested objects are one `jsonb` column, string lists native `text[]`, timestamps
|
|
56
|
+
* `text` (never `timestamptz`, which would marshal through `Date`), and the reserved `order` column
|
|
57
|
+
* is quoted wherever it is named.
|
|
58
|
+
*/
|
|
59
|
+
describe('@owlmeans/bot-postgres — table shapes', () => {
|
|
60
|
+
test('each maker assigns server-bot\'s schema, not a copy, under the default alias', () => {
|
|
61
|
+
reset()
|
|
62
|
+
const made = [makeBotConnectionResource(), makeBotRuleResource(), makeBotUpdateResource(), makeBotActivityResource()]
|
|
63
|
+
|
|
64
|
+
expect(made.map(resource => resource.alias)).toEqual(ALIASES)
|
|
65
|
+
expect(made.map(resource => resource.schema)).toEqual(ALIASES.map(alias => SCHEMAS[alias]))
|
|
66
|
+
made.forEach((resource, at) => expect(resource.schema).toBe(SCHEMAS[ALIASES[at]] as never))
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('every table has exactly its schema\'s properties as columns, timestamps as text', () => {
|
|
70
|
+
reset()
|
|
71
|
+
makeAll()
|
|
72
|
+
for (const alias of ALIASES) {
|
|
73
|
+
const spec = specOf(alias)
|
|
74
|
+
const properties = Object.keys((SCHEMAS[alias] as { properties: object }).properties).sort()
|
|
75
|
+
expect([alias, spec.columns.map(column => column.property).sort()]).toEqual([alias, properties])
|
|
76
|
+
expect([alias, spec.primaryKey]).toEqual([alias, ['id']])
|
|
77
|
+
for (const column of spec.columns.filter(entry => /At$/.test(entry.property))) {
|
|
78
|
+
expect([alias, column.property, column.sqlType, column.jsonType]).toEqual([alias, column.property, 'text', 'string'])
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('nested objects are one jsonb column each; string lists are native text[]', () => {
|
|
84
|
+
reset()
|
|
85
|
+
makeAll()
|
|
86
|
+
const connection = specOf(RES_BOT_CONNECTION)
|
|
87
|
+
const rule = specOf(RES_BOT_RULE)
|
|
88
|
+
const update = specOf(RES_BOT_UPDATE)
|
|
89
|
+
const activity = specOf(RES_BOT_ACTIVITY)
|
|
90
|
+
|
|
91
|
+
const jsonb = { sqlType: 'jsonb', jsonb: true, array: false }
|
|
92
|
+
expect(typeOf(connection, 'identity')).toEqual({ ...jsonb, notNull: false })
|
|
93
|
+
expect(typeOf(update, 'update')).toEqual({ ...jsonb, notNull: true })
|
|
94
|
+
expect(typeOf(activity, 'target')).toEqual({ ...jsonb, notNull: false })
|
|
95
|
+
|
|
96
|
+
const strings = { sqlType: 'text[]', jsonb: false, array: true }
|
|
97
|
+
expect(typeOf(connection, 'setup')).toEqual({ ...strings, notNull: false })
|
|
98
|
+
expect(typeOf(connection, 'credentialsSet')).toEqual({ ...strings, notNull: true })
|
|
99
|
+
expect(typeOf(rule, 'keywords')).toEqual({ ...strings, notNull: true })
|
|
100
|
+
expect(typeOf(update, 'handled')).toEqual({ ...strings, notNull: true })
|
|
101
|
+
|
|
102
|
+
/** The sealed credentials are ONE opaque text column, never split per credential key. */
|
|
103
|
+
expect(typeOf(connection, 'sealed')).toEqual({ sqlType: 'text', jsonb: false, array: false, notNull: false })
|
|
104
|
+
expect(typeOf(update, 'attempts').sqlType).toBe('integer')
|
|
105
|
+
expect(typeOf(connection, 'enabled').sqlType).toBe('boolean')
|
|
106
|
+
expect(connection.checks.map(check => check.name).sort()).toEqual([
|
|
107
|
+
'bot_connection_platform_enum', 'bot_connection_receive_enum', 'bot_connection_status_enum', 'bot_connection_transport_enum',
|
|
108
|
+
])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('the rule\'s order is a double precision column, quoted in the DDL', () => {
|
|
112
|
+
reset()
|
|
113
|
+
makeAll()
|
|
114
|
+
const rule = specOf(RES_BOT_RULE)
|
|
115
|
+
|
|
116
|
+
expect([rule.byProperty.order.column, rule.byProperty.order.sqlType, rule.byProperty.order.notNull])
|
|
117
|
+
.toEqual(['order', 'double precision', true])
|
|
118
|
+
expect(typeOf(rule, 'connection')).toEqual({ sqlType: 'text', jsonb: false, array: false, notNull: false })
|
|
119
|
+
const [create] = ddlOf(rule)
|
|
120
|
+
expect(create).toContain('"order" double precision NOT NULL')
|
|
121
|
+
expect(create).not.toMatch(/[^"]order[^"]/)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('the inbox dedupe key is a unique index, and every index the reads need is declared', () => {
|
|
125
|
+
reset()
|
|
126
|
+
makeAll()
|
|
127
|
+
const names = (alias: string) => pgDeclarationHelper.getDeclaration(alias).indexes
|
|
128
|
+
.map(index => [index.name, index.columns, index.unique === true])
|
|
129
|
+
|
|
130
|
+
expect(names(RES_BOT_CONNECTION)).toEqual([
|
|
131
|
+
['bot_connection_scope_created', ['scope', 'createdAt'], false],
|
|
132
|
+
['bot_connection_receive', ['enabled', 'status', 'receive'], false],
|
|
133
|
+
])
|
|
134
|
+
expect(names(RES_BOT_RULE)).toEqual([
|
|
135
|
+
['bot_rule_scope', ['scope'], false],
|
|
136
|
+
['bot_rule_scope_connection', ['scope', 'connection'], false],
|
|
137
|
+
])
|
|
138
|
+
expect(names(RES_BOT_UPDATE)).toEqual([
|
|
139
|
+
['bot_update_dedupe', ['dedupe'], true],
|
|
140
|
+
['bot_update_inbox', ['status', 'receivedAt'], false],
|
|
141
|
+
['bot_update_lease', ['status', 'claimedAt'], false],
|
|
142
|
+
['bot_update_connection_received', ['connection', 'receivedAt'], false],
|
|
143
|
+
['bot_update_retention', ['status', 'processedAt'], false],
|
|
144
|
+
])
|
|
145
|
+
expect(names(RES_BOT_ACTIVITY)).toEqual([
|
|
146
|
+
['bot_activity_scope_at', ['scope', 'at'], false],
|
|
147
|
+
['bot_activity_scope_connection_at', ['scope', 'connection', 'at'], false],
|
|
148
|
+
])
|
|
149
|
+
expect(ddlOf(specOf(RES_BOT_UPDATE))).toContain(
|
|
150
|
+
'CREATE UNIQUE INDEX "bot_update_dedupe" ON "app"."bot_update" USING btree ("dedupe")'
|
|
151
|
+
)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('a maker run twice declares its indexes once, and nothing warns at compile time', () => {
|
|
155
|
+
reset()
|
|
156
|
+
const memory = memoryPlugin('bot-postgres-schema')
|
|
157
|
+
const remove = addLogPlugin(memory)
|
|
158
|
+
try {
|
|
159
|
+
makeAll()
|
|
160
|
+
makeAll()
|
|
161
|
+
for (const alias of ALIASES) {
|
|
162
|
+
const names = pgDeclarationHelper.getDeclaration(alias).indexes.map(index => index.name)
|
|
163
|
+
expect([alias, names.length]).toEqual([alias, new Set(names).size])
|
|
164
|
+
specOf(alias)
|
|
165
|
+
}
|
|
166
|
+
expect(memory.records.filter(record => record.level === 'warn' || record.level === 'error')).toEqual([])
|
|
167
|
+
} finally {
|
|
168
|
+
remove()
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test('appendPostgresBot registers the four once, and custom aliases name their own tables and indexes', () => {
|
|
173
|
+
const context = makeBasicContext<BasicConfig>({ ready: false, service: 'bot-pg-schema', type: AppType.Backend, services: {} })
|
|
174
|
+
appendPostgresBot(context)
|
|
175
|
+
const first = ALIASES.map(alias => context.resource(alias))
|
|
176
|
+
appendPostgresBot(context)
|
|
177
|
+
expect(ALIASES.map(alias => context.resource(alias))).toEqual(first)
|
|
178
|
+
first.forEach((resource, at) => expect(context.resource(ALIASES[at])).toBe(resource))
|
|
179
|
+
|
|
180
|
+
const custom = { connection: 'chat-connection', rule: 'chat-rule', update: 'chat-update', activity: 'chat-activity' }
|
|
181
|
+
appendPostgresBot(context, { resources: custom })
|
|
182
|
+
expect(Object.values(custom).map(alias => context.resource<BotUpdateResource>(alias).schema))
|
|
183
|
+
.toEqual(ALIASES.map(alias => SCHEMAS[alias]))
|
|
184
|
+
expect(pgDeclarationHelper.getDeclaration('chat-update').indexes.map(index => index.name))
|
|
185
|
+
.toContain('chat_update_dedupe')
|
|
186
|
+
})
|
|
187
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { makePgIntrospectHelper, pgDiffHelper } from '@owlmeans/postgres-resource'
|
|
3
|
+
import type { PostgresResource } from '@owlmeans/postgres-resource'
|
|
4
|
+
import { BotChatKind, BotMatchMode, BotPlatform, BotRuleAction, BotUpdateKind } from '@owlmeans/bot'
|
|
5
|
+
import { RecordExists } from '@owlmeans/resource'
|
|
6
|
+
import { BotInboxStatus, RES_BOT_ACTIVITY, RES_BOT_CONNECTION, RES_BOT_RULE, RES_BOT_UPDATE } from '@owlmeans/server-bot'
|
|
7
|
+
import type { BotUpdateRecord } from '@owlmeans/server-bot'
|
|
8
|
+
|
|
9
|
+
import { appendPostgresBot } from '../src/index.js'
|
|
10
|
+
import type { BotRuleResource, BotUpdateResource } from '../src/index.js'
|
|
11
|
+
import { gate, makeSuite } from './context.js'
|
|
12
|
+
|
|
13
|
+
const ALIASES = [RES_BOT_CONNECTION, RES_BOT_RULE, RES_BOT_UPDATE, RES_BOT_ACTIVITY]
|
|
14
|
+
|
|
15
|
+
const suite = makeSuite('tables')
|
|
16
|
+
const it = gate.skip ? test.skip : test
|
|
17
|
+
|
|
18
|
+
/** An inbox row as the service writes it — no id: Postgres mints it. */
|
|
19
|
+
const inboxRow = (dedupe: string): Omit<BotUpdateRecord, 'id'> => ({
|
|
20
|
+
scope: 'tables', connection: 'c-1', platform: BotPlatform.Telegram, dedupe,
|
|
21
|
+
status: BotInboxStatus.Pending, attempts: 0, handled: [], receivedAt: '2026-10-26T10:00:00.000Z',
|
|
22
|
+
update: {
|
|
23
|
+
connection: 'c-1', scope: 'tables', platform: BotPlatform.Telegram, kind: BotUpdateKind.Message,
|
|
24
|
+
chat: { id: '-100', kind: BotChatKind.Group }, addressed: false, receivedAt: '2026-10-26T10:00:00.000Z',
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('@owlmeans/bot-postgres — tables against a real database', () => {
|
|
29
|
+
if (gate.skip) {
|
|
30
|
+
test.skip(gate.reason ?? 'postgres gate closed', () => {})
|
|
31
|
+
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
afterAll(async () => {
|
|
36
|
+
await suite.teardown()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('a second boot reconciles nothing on any of the four tables', async () => {
|
|
40
|
+
await suite.boot()
|
|
41
|
+
const again = await suite.boot()
|
|
42
|
+
const { pool } = await again.context.resource<PostgresResource<never>>(RES_BOT_CONNECTION).db()
|
|
43
|
+
const client = await pool.connect()
|
|
44
|
+
try {
|
|
45
|
+
for (const alias of ALIASES) {
|
|
46
|
+
const table = again.context.resource<PostgresResource<never>>(alias).table
|
|
47
|
+
expect(table.qualified).toBe(`"${suite.schema}"."${alias.replace('-', '_')}"`)
|
|
48
|
+
const live = await makePgIntrospectHelper(client).introspectTable(table.schema, table.table, table.qualified)
|
|
49
|
+
expect([alias, pgDiffHelper.planSync(table, live).statements.map(statement => statement.sql)]).toEqual([alias, []])
|
|
50
|
+
}
|
|
51
|
+
} finally {
|
|
52
|
+
client.release()
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('round-trips nested values, string lists and the order column; the dedupe key is unique', async () => {
|
|
57
|
+
const { context } = await suite.boot()
|
|
58
|
+
const updates = context.resource<BotUpdateResource>(RES_BOT_UPDATE)
|
|
59
|
+
const rules = context.resource<BotRuleResource>(RES_BOT_RULE)
|
|
60
|
+
|
|
61
|
+
const row = await updates.create(inboxRow('c-1:telegram:1'))
|
|
62
|
+
expect(typeof row.id).toBe('string')
|
|
63
|
+
const stored = await updates.get(row.id!)
|
|
64
|
+
expect([stored.handled, stored.update.chat, stored.claimedAt]).toEqual([[], { id: '-100', kind: BotChatKind.Group }, null])
|
|
65
|
+
await expect(updates.create(inboxRow('c-1:telegram:1'))).rejects.toThrow(RecordExists)
|
|
66
|
+
|
|
67
|
+
const rule = await rules.create({
|
|
68
|
+
scope: 'tables', name: 'Spam', keywords: ['casino', 'free money'], mode: BotMatchMode.Word, action: BotRuleAction.Delete,
|
|
69
|
+
enabled: true, order: 2.5, createdAt: '2026-10-26T10:00:00.000Z', updatedAt: '2026-10-26T10:00:00.000Z',
|
|
70
|
+
})
|
|
71
|
+
const sorted = await rules.list({ scope: 'tables' }, { sort: [{ field: 'order', order: 'desc' }] })
|
|
72
|
+
expect(sorted.items.map(item => [item.id, item.order, item.keywords, item.connection])).toEqual([
|
|
73
|
+
[rule.id, 2.5, ['casino', 'free money'], null],
|
|
74
|
+
])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('custom aliases get tables and index names of their own', async () => {
|
|
78
|
+
const custom = { connection: 'chat-connection', rule: 'chat-rule', update: 'chat-update', activity: 'chat-activity' }
|
|
79
|
+
const { context } = await suite.boot({ bot: { resources: custom } })
|
|
80
|
+
const registered = context.resource(custom.update)
|
|
81
|
+
appendPostgresBot(context, { resources: custom })
|
|
82
|
+
expect(context.resource(custom.update)).toBe(registered)
|
|
83
|
+
|
|
84
|
+
const updates = context.resource<BotUpdateResource>(custom.update)
|
|
85
|
+
expect(updates.table.table).toBe('chat_update')
|
|
86
|
+
await updates.create(inboxRow('c-1:telegram:2'))
|
|
87
|
+
await expect(updates.create(inboxRow('c-1:telegram:2'))).rejects.toThrow(RecordExists)
|
|
88
|
+
})
|
|
89
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": [
|
|
3
|
+
"@owlmeans/dep-config/tsconfig.base.json",
|
|
4
|
+
"@owlmeans/dep-config/tsconfig.node.json"
|
|
5
|
+
],
|
|
6
|
+
"compilerOptions": {
|
|
7
|
+
"rootDir": "./src/",
|
|
8
|
+
"outDir": "./build/"
|
|
9
|
+
},
|
|
10
|
+
"exclude": ["./dist/**/*", "./build/**/*", "./tests/**/*", "./*.ts"]
|
|
11
|
+
}
|