@owlmeans/bot-postgres 0.0.0-stage → 0.1.18-rc.1

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/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
@@ -0,0 +1,3 @@
1
+ export type * from './types.js'
2
+ export * from './resource.js'
3
+ export * from './helper.js'
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ import type { PgIndexSpec } from '@owlmeans/postgres-resource'
2
+
3
+ /** The indexes a maker owns, by name suffix — the full name is `<table>_<suffix>`. */
4
+ export type IndexList = ReadonlyArray<readonly [suffix: string, spec: PgIndexSpec]>
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
+ })
@@ -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
+ })
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": [
3
+ "@owlmeans/dep-config/tsconfig.base.json",
4
+ "@owlmeans/dep-config/tsconfig.node.json"
5
+ ],
6
+ "compilerOptions": {
7
+ "types": ["bun"],
8
+ "rootDir": "../",
9
+ "noEmit": true
10
+ },
11
+ "include": ["./**/*", "../src/**/*"]
12
+ }
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
+ }