@owlmeans/planning-postgres 0.1.18-rc.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/README.md +88 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/planning-postgres/SKILL.md +292 -0
- package/build/consts.d.ts +45 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +45 -0
- package/build/consts.js.map +1 -0
- package/build/errors.d.ts +10 -0
- package/build/errors.d.ts.map +1 -0
- package/build/errors.js +13 -0
- package/build/errors.js.map +1 -0
- package/build/index.d.ts +9 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +8 -0
- package/build/index.js.map +1 -0
- package/build/resource.d.ts +32 -0
- package/build/resource.d.ts.map +1 -0
- package/build/resource.js +94 -0
- package/build/resource.js.map +1 -0
- package/build/schemas.d.ts +20 -0
- package/build/schemas.d.ts.map +1 -0
- package/build/schemas.js +64 -0
- package/build/schemas.js.map +1 -0
- package/build/service.d.ts +16 -0
- package/build/service.d.ts.map +1 -0
- package/build/service.js +34 -0
- package/build/service.js.map +1 -0
- package/build/sql.d.ts +61 -0
- package/build/sql.d.ts.map +1 -0
- package/build/sql.js +90 -0
- package/build/sql.js.map +1 -0
- package/build/store/bus.d.ts +52 -0
- package/build/store/bus.d.ts.map +1 -0
- package/build/store/bus.js +167 -0
- package/build/store/bus.js.map +1 -0
- package/build/store/cards.d.ts +35 -0
- package/build/store/cards.d.ts.map +1 -0
- package/build/store/cards.js +112 -0
- package/build/store/cards.js.map +1 -0
- package/build/store/fold.d.ts +56 -0
- package/build/store/fold.d.ts.map +1 -0
- package/build/store/fold.js +453 -0
- package/build/store/fold.js.map +1 -0
- package/build/store/index.d.ts +22 -0
- package/build/store/index.d.ts.map +1 -0
- package/build/store/index.js +206 -0
- package/build/store/index.js.map +1 -0
- package/build/store/links.d.ts +18 -0
- package/build/store/links.d.ts.map +1 -0
- package/build/store/links.js +54 -0
- package/build/store/links.js.map +1 -0
- package/build/store/schemas.d.ts +32 -0
- package/build/store/schemas.d.ts.map +1 -0
- package/build/store/schemas.js +143 -0
- package/build/store/schemas.js.map +1 -0
- package/build/store/specs.d.ts +10 -0
- package/build/store/specs.d.ts.map +1 -0
- package/build/store/specs.js +47 -0
- package/build/store/specs.js.map +1 -0
- package/build/store/transitions.d.ts +44 -0
- package/build/store/transitions.d.ts.map +1 -0
- package/build/store/transitions.js +164 -0
- package/build/store/transitions.js.map +1 -0
- package/build/types.d.ts +72 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/package.json +56 -0
- package/src/consts.ts +54 -0
- package/src/errors.ts +15 -0
- package/src/index.ts +9 -0
- package/src/resource.ts +133 -0
- package/src/schemas.ts +84 -0
- package/src/service.ts +45 -0
- package/src/sql.ts +135 -0
- package/src/store/bus.ts +217 -0
- package/src/store/cards.ts +153 -0
- package/src/store/fold.ts +560 -0
- package/src/store/index.ts +237 -0
- package/src/store/links.ts +77 -0
- package/src/store/schemas.ts +174 -0
- package/src/store/specs.ts +72 -0
- package/src/store/transitions.ts +221 -0
- package/src/types.ts +73 -0
- package/tests/bus.spec.ts +142 -0
- package/tests/conformance.spec.ts +37 -0
- package/tests/context.ts +143 -0
- package/tests/fold.spec.ts +213 -0
- package/tests/schema.spec.ts +89 -0
- package/tests/sql.spec.ts +50 -0
- package/tests/sync.spec.ts +61 -0
- package/tests/tsconfig.json +17 -0
- package/tsconfig.json +11 -0
package/src/service.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
2
|
+
import type { WithPlanningService } from '@owlmeans/planning'
|
|
3
|
+
import { DEFAULT_ALIAS, makePlanningService } from '@owlmeans/server-planning'
|
|
4
|
+
import type { PlanningHostService } from '@owlmeans/server-planning'
|
|
5
|
+
import { makePlanningPostgresResources } from './resource.js'
|
|
6
|
+
import { makePostgresPlanningStore, planningPostgresAliases } from './store/index.js'
|
|
7
|
+
import type { PostgresPlanningServiceOptions } from './types.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The planning service over a Postgres store — what a target's own `services/planning.ts`
|
|
11
|
+
* exports as its `makeService()`. The store resolves its four resources on the service's context
|
|
12
|
+
* at the first call, so the resources are registered separately (`resources/planning/*.ts`).
|
|
13
|
+
*/
|
|
14
|
+
export const makePostgresPlanningService = (
|
|
15
|
+
opts: PostgresPlanningServiceOptions = {}, alias: string = DEFAULT_ALIAS
|
|
16
|
+
): PlanningHostService => {
|
|
17
|
+
const { aliases, bus, limits, ...service } = opts
|
|
18
|
+
let host: PlanningHostService | undefined
|
|
19
|
+
const store = makePostgresPlanningStore({
|
|
20
|
+
aliases, bus, limits, ids: opts.ids, now: opts.now,
|
|
21
|
+
context: () => host?.ctx as BasicContext<any> | undefined,
|
|
22
|
+
})
|
|
23
|
+
host = makePlanningService({ ...service, store }, alias)
|
|
24
|
+
|
|
25
|
+
return host
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Register the four Postgres resources (each unless the context already has it), the planning
|
|
30
|
+
* service over them, and `context.planning()` — the one-call wiring of a hand-written backend.
|
|
31
|
+
*/
|
|
32
|
+
export const appendPostgresPlanning = <C extends BasicConfig, T extends BasicContext<C>>(
|
|
33
|
+
ctx: T, opts: PostgresPlanningServiceOptions = {}, alias: string = DEFAULT_ALIAS
|
|
34
|
+
): T & WithPlanningService => {
|
|
35
|
+
const context = ctx as T & WithPlanningService
|
|
36
|
+
for (const resource of makePlanningPostgresResources(planningPostgresAliases(opts.aliases))) {
|
|
37
|
+
if (!context.hasResource(resource.alias)) {
|
|
38
|
+
context.registerResource(resource)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
context.registerService(makePostgresPlanningService(opts, alias))
|
|
42
|
+
context.planning = () => context.service<PlanningHostService>(alias)
|
|
43
|
+
|
|
44
|
+
return context
|
|
45
|
+
}
|
package/src/sql.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { quoteIdent, rowToRecord } from '@owlmeans/postgres-resource'
|
|
2
|
+
import type { ColumnSpec, TableSpec } from '@owlmeans/postgres-resource'
|
|
3
|
+
import type { ResourceRecord } from '@owlmeans/resource'
|
|
4
|
+
import type { Pool, PoolClient, QueryResultRow } from 'pg'
|
|
5
|
+
|
|
6
|
+
/** Where a statement runs — the pool, or one open transaction. */
|
|
7
|
+
export interface SqlRunner {
|
|
8
|
+
query: <R extends QueryResultRow = QueryResultRow>(text: string, params?: unknown[]) => Promise<R[]>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The compiled specs of the four tables, read once the resources have initialized. */
|
|
12
|
+
export interface PlanningTables {
|
|
13
|
+
card: TableSpec
|
|
14
|
+
transition: TableSpec
|
|
15
|
+
link: TableSpec
|
|
16
|
+
schema: TableSpec
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A runner and the tables it addresses — what every statement builder takes. */
|
|
20
|
+
export interface SqlContext {
|
|
21
|
+
runner: SqlRunner
|
|
22
|
+
tables: PlanningTables
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const poolRunner = (pool: Pool): SqlRunner => ({
|
|
26
|
+
query: async <R extends QueryResultRow = QueryResultRow>(text: string, params?: unknown[]) =>
|
|
27
|
+
(await pool.query<R>(text, params as never[])).rows,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export const clientRunner = (client: PoolClient, onError?: (error: unknown) => void): SqlRunner => ({
|
|
31
|
+
query: async <R extends QueryResultRow = QueryResultRow>(text: string, params?: unknown[]) => {
|
|
32
|
+
try {
|
|
33
|
+
return (await client.query<R>(text, params as never[])).rows
|
|
34
|
+
} catch (error) {
|
|
35
|
+
onError?.(error)
|
|
36
|
+
throw error
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/** A property's physical column, quoted. */
|
|
42
|
+
export const col = (spec: TableSpec, property: string): string => quoteIdent(spec.byProperty[property].column)
|
|
43
|
+
|
|
44
|
+
/** A value as the driver takes it for a column: JSON text for jsonb, `null` for absent. */
|
|
45
|
+
export const driverValue = (column: ColumnSpec, value: unknown): unknown => {
|
|
46
|
+
if (value === undefined || value === null) {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
// node-postgres would write a JS array as a Postgres array literal, which a jsonb column refuses.
|
|
50
|
+
return column.jsonb ? JSON.stringify(value) : value
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A row back as a record: physical columns to properties with the package's coercions, `null`
|
|
55
|
+
* dropped (the planning records leave an absent value out), private columns removed.
|
|
56
|
+
*/
|
|
57
|
+
export const recordOf = <T extends ResourceRecord>(
|
|
58
|
+
row: Record<string, unknown>, spec: TableSpec, hidden: readonly string[] = []
|
|
59
|
+
): T => {
|
|
60
|
+
const record = rowToRecord<T>(row, spec) as Record<string, unknown>
|
|
61
|
+
for (const [key, value] of Object.entries(record)) {
|
|
62
|
+
if (value === null || hidden.includes(key)) {
|
|
63
|
+
delete record[key]
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return record as T
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A resource read answered with drizzle's property-keyed rows: `null` and private keys dropped. */
|
|
70
|
+
export const cleanRecord = <T extends object>(record: T, hidden: readonly string[] = []): T =>
|
|
71
|
+
Object.fromEntries(Object.entries(record).filter(([key, value]) => value !== null && value !== undefined && !hidden.includes(key))) as T
|
|
72
|
+
|
|
73
|
+
export interface InsertStatement {
|
|
74
|
+
text: string
|
|
75
|
+
params: unknown[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `INSERT INTO <table> (<every column but the skipped>) VALUES (…)` for a record, with an optional
|
|
80
|
+
* tail (`ON CONFLICT …`, `RETURNING …`).
|
|
81
|
+
*/
|
|
82
|
+
export const insertOf = (
|
|
83
|
+
spec: TableSpec, record: Record<string, unknown>, opts: { skip?: readonly string[], tail?: string, as?: string } = {}
|
|
84
|
+
): InsertStatement => {
|
|
85
|
+
const columns = spec.columns.filter(column => !(opts.skip ?? []).includes(column.property))
|
|
86
|
+
const params = columns.map(column => driverValue(column, record[column.property]))
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
text: `INSERT INTO ${spec.qualified}${opts.as != null ? ` AS ${opts.as}` : ''}`
|
|
90
|
+
+ ` (${columns.map(column => quoteIdent(column.column)).join(', ')})`
|
|
91
|
+
+ ` VALUES (${columns.map((_, index) => `$${index + 1}`).join(', ')})${opts.tail != null ? ` ${opts.tail}` : ''}`,
|
|
92
|
+
params,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A WHERE over fixed fields: a string is equality, a list is `= ANY`, `undefined` is skipped.
|
|
98
|
+
* Parameters start after `offset`.
|
|
99
|
+
*/
|
|
100
|
+
export const whereOf = (
|
|
101
|
+
spec: TableSpec, fields: Record<string, string | readonly string[] | undefined>, offset: number = 0
|
|
102
|
+
): { text: string, params: unknown[] } => {
|
|
103
|
+
const clauses: string[] = []
|
|
104
|
+
const params: unknown[] = []
|
|
105
|
+
for (const [property, value] of Object.entries(fields)) {
|
|
106
|
+
if (value === undefined) {
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
params.push(Array.isArray(value) ? [...value] : value)
|
|
110
|
+
clauses.push(Array.isArray(value)
|
|
111
|
+
? `${col(spec, property)} = ANY($${offset + params.length})`
|
|
112
|
+
: `${col(spec, property)} = $${offset + params.length}`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { text: clauses.length > 0 ? clauses.join(' AND ') : 'TRUE', params }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The driver error's Postgres code and the constraint (index) it names, through a `cause` chain. */
|
|
119
|
+
export const pgFault = (error: unknown): { code?: string, constraint?: string } => {
|
|
120
|
+
let current: unknown = error
|
|
121
|
+
for (let depth = 0; depth < 5 && current != null && typeof current === 'object'; depth++) {
|
|
122
|
+
const candidate = current as { code?: unknown, constraint?: unknown, cause?: unknown }
|
|
123
|
+
if (typeof candidate.code === 'string' && /^[0-9A-Z]{5}$/.test(candidate.code)) {
|
|
124
|
+
return {
|
|
125
|
+
code: candidate.code,
|
|
126
|
+
...(typeof candidate.constraint === 'string' ? { constraint: candidate.constraint } : {}),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
current = candidate.cause
|
|
130
|
+
}
|
|
131
|
+
return {}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const UNIQUE_VIOLATION = '23505'
|
|
135
|
+
export const LOCK_NOT_AVAILABLE = '55P03'
|
package/src/store/bus.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { CommitEvent, Unsubscribe } from '@owlmeans/planning'
|
|
2
|
+
import { advisoryKey, quoteIdent } from '@owlmeans/postgres-resource'
|
|
3
|
+
import { Client } from 'pg'
|
|
4
|
+
import type { ClientConfig, Notification, Pool } from 'pg'
|
|
5
|
+
import { BUS_BACKOFF, NOTIFY_PAYLOAD_MAX } from '../consts.js'
|
|
6
|
+
import type { SqlRunner } from '../sql.js'
|
|
7
|
+
|
|
8
|
+
/** A settled commit, without its record — the card is re-read where one is needed. */
|
|
9
|
+
export interface CommitFrame {
|
|
10
|
+
p: string
|
|
11
|
+
t: 'c'
|
|
12
|
+
e: Omit<CommitEvent, 'record'>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A write of an organization's data-defined schemas. */
|
|
16
|
+
export interface SchemaFrame {
|
|
17
|
+
p: string
|
|
18
|
+
t: 's'
|
|
19
|
+
e: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type BusFrame = CommitFrame | SchemaFrame
|
|
23
|
+
|
|
24
|
+
export interface PlanningBus {
|
|
25
|
+
enabled: boolean
|
|
26
|
+
/** NOTIFY a frame inside a transaction — delivered only if it commits. A no-op when disabled. */
|
|
27
|
+
notify: (runner: SqlRunner, frame: Omit<CommitFrame, 'p'> | Omit<SchemaFrame, 'p'>) => Promise<void>
|
|
28
|
+
onCommit: (listener: (event: CommitEvent) => void | Promise<void>) => Unsubscribe
|
|
29
|
+
onSchema: (listener: (entityId: string) => void) => Unsubscribe
|
|
30
|
+
/** Open the LISTEN connection if it is not open (lazily, on the first subscription or wait). */
|
|
31
|
+
ensure: () => void
|
|
32
|
+
connected: () => boolean
|
|
33
|
+
close: () => Promise<void>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PlanningBusOptions {
|
|
37
|
+
enabled: boolean
|
|
38
|
+
processId: string
|
|
39
|
+
/** The qualified transition table — what the channel name is derived from. */
|
|
40
|
+
table: () => Promise<string>
|
|
41
|
+
/** The pool whose configuration the dedicated connection copies. */
|
|
42
|
+
pool: () => Promise<Pool>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The channel of one transition table: `planning_` and a short hash of its qualified name, so two
|
|
47
|
+
* schemas in one database never hear each other.
|
|
48
|
+
*/
|
|
49
|
+
export const planningChannel = (qualified: string): string => {
|
|
50
|
+
const [first, second] = advisoryKey(`planning-channel:${qualified}`)
|
|
51
|
+
return `planning_${(first >>> 0).toString(16).padStart(8, '0')}${(second >>> 0).toString(16).padStart(8, '0')}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const payloadOf = (frame: BusFrame): string | null => {
|
|
55
|
+
const text = JSON.stringify(frame)
|
|
56
|
+
if (new TextEncoder().encode(text).length < NOTIFY_PAYLOAD_MAX) {
|
|
57
|
+
return text
|
|
58
|
+
}
|
|
59
|
+
if (frame.t === 'c' && frame.e.error != null) {
|
|
60
|
+
// The reason is in the row; a listener reads it with the status.
|
|
61
|
+
const { error: _error, ...event } = frame.e
|
|
62
|
+
return payloadOf({ ...frame, e: event })
|
|
63
|
+
}
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The cross-process commit bus over LISTEN/NOTIFY.
|
|
69
|
+
*
|
|
70
|
+
* NOTIFY runs inside the transaction that settled the commit, so a frame is heard only if that
|
|
71
|
+
* commit landed. LISTEN holds ONE dedicated connection — a `pg.Client` built from the pool's own
|
|
72
|
+
* configuration, never a pooled one, so it takes no pool slot — opened on the first subscription
|
|
73
|
+
* or wait. A dropped connection reconnects with backoff (1 s doubling to 30 s); meanwhile the commit
|
|
74
|
+
* hub's poll ladder still answers every waiter. A process ignores its own frames (`p`): it delivered
|
|
75
|
+
* them itself, after its commit.
|
|
76
|
+
*/
|
|
77
|
+
export const makePlanningBus = (opts: PlanningBusOptions): PlanningBus => {
|
|
78
|
+
const commitListeners = new Set<(event: CommitEvent) => void | Promise<void>>()
|
|
79
|
+
const schemaListeners = new Set<(entityId: string) => void>()
|
|
80
|
+
let client: Client | undefined
|
|
81
|
+
let connecting: Promise<void> | undefined
|
|
82
|
+
let retry: ReturnType<typeof setTimeout> | undefined
|
|
83
|
+
let backoff = BUS_BACKOFF[0]
|
|
84
|
+
let closed = false
|
|
85
|
+
let live = false
|
|
86
|
+
let channel: string | undefined
|
|
87
|
+
|
|
88
|
+
const channelOf = async (): Promise<string> => channel ??= planningChannel(await opts.table())
|
|
89
|
+
|
|
90
|
+
const dispatch = (message: Notification): void => {
|
|
91
|
+
if (message.channel !== channel || message.payload == null) {
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
let frame: BusFrame
|
|
95
|
+
try {
|
|
96
|
+
frame = JSON.parse(message.payload) as BusFrame
|
|
97
|
+
} catch {
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
if (frame.p === opts.processId) {
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
if (frame.t === 'c') {
|
|
104
|
+
for (const listener of [...commitListeners]) {
|
|
105
|
+
void Promise.resolve().then(() => listener(frame.e as CommitEvent))
|
|
106
|
+
.catch(error => console.error('planning-postgres: commit listener failed:', error))
|
|
107
|
+
}
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
if (frame.t === 's') {
|
|
111
|
+
for (const listener of [...schemaListeners]) {
|
|
112
|
+
try {
|
|
113
|
+
listener(frame.e)
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error('planning-postgres: schema listener failed:', error)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const lost = (target: Client, error?: unknown): void => {
|
|
122
|
+
if (client !== target) {
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
client = undefined
|
|
126
|
+
live = false
|
|
127
|
+
void target.end().catch(() => undefined)
|
|
128
|
+
if (closed) {
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
if (error != null) {
|
|
132
|
+
console.warn('planning-postgres: commit bus connection lost, reconnecting:', error instanceof Error ? error.message : error)
|
|
133
|
+
}
|
|
134
|
+
retry = setTimeout(() => { retry = undefined; bus.ensure() }, backoff)
|
|
135
|
+
backoff = Math.min(backoff * 2, BUS_BACKOFF[1])
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const connect = async (): Promise<void> => {
|
|
139
|
+
let pool: Pool
|
|
140
|
+
let listen: string
|
|
141
|
+
try {
|
|
142
|
+
pool = await opts.pool()
|
|
143
|
+
listen = await channelOf()
|
|
144
|
+
} catch {
|
|
145
|
+
// The store cannot reach its tables yet (its calls say why); ask again later.
|
|
146
|
+
if (!closed) {
|
|
147
|
+
retry = setTimeout(() => { retry = undefined; bus.ensure() }, backoff)
|
|
148
|
+
backoff = Math.min(backoff * 2, BUS_BACKOFF[1])
|
|
149
|
+
}
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
// The pool's own client class and configuration (the password stays a non-enumerable property
|
|
153
|
+
// of that object, so the object itself is handed over, never a copy).
|
|
154
|
+
const Factory = (pool as unknown as { Client?: typeof Client }).Client ?? Client
|
|
155
|
+
const target = new Factory(pool.options as ClientConfig)
|
|
156
|
+
target.on('error', error => lost(target, error))
|
|
157
|
+
target.on('end', () => lost(target))
|
|
158
|
+
target.on('notification', dispatch)
|
|
159
|
+
client = target
|
|
160
|
+
try {
|
|
161
|
+
await target.connect()
|
|
162
|
+
await target.query(`LISTEN ${quoteIdent(listen)}`)
|
|
163
|
+
live = true
|
|
164
|
+
backoff = BUS_BACKOFF[0]
|
|
165
|
+
} catch (error) {
|
|
166
|
+
lost(target, error)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const bus: PlanningBus = {
|
|
171
|
+
enabled: opts.enabled,
|
|
172
|
+
|
|
173
|
+
notify: async (runner, frame) => {
|
|
174
|
+
if (!opts.enabled) {
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
const payload = payloadOf({ ...frame, p: opts.processId } as BusFrame)
|
|
178
|
+
if (payload != null) {
|
|
179
|
+
await runner.query('SELECT pg_notify($1, $2)', [await channelOf(), payload])
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
onCommit: listener => {
|
|
184
|
+
commitListeners.add(listener)
|
|
185
|
+
return () => { commitListeners.delete(listener) }
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
onSchema: listener => {
|
|
189
|
+
schemaListeners.add(listener)
|
|
190
|
+
return () => { schemaListeners.delete(listener) }
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
ensure: () => {
|
|
194
|
+
if (!opts.enabled || closed || client != null || connecting != null || retry != null) {
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
connecting = connect().finally(() => { connecting = undefined })
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
connected: () => live,
|
|
201
|
+
|
|
202
|
+
close: async () => {
|
|
203
|
+
closed = true
|
|
204
|
+
if (retry != null) {
|
|
205
|
+
clearTimeout(retry)
|
|
206
|
+
retry = undefined
|
|
207
|
+
}
|
|
208
|
+
await connecting?.catch(() => undefined)
|
|
209
|
+
const target = client
|
|
210
|
+
client = undefined
|
|
211
|
+
live = false
|
|
212
|
+
await target?.end().catch(() => undefined)
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return bus
|
|
217
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { IntrinsicStatus, PlanningError, WorkcardKind } from '@owlmeans/planning'
|
|
2
|
+
import type { ProjectionStore, SummaryView, Workcard } from '@owlmeans/planning'
|
|
3
|
+
import { quoteIdent } from '@owlmeans/postgres-resource'
|
|
4
|
+
import type { Criteria } from '@owlmeans/resource'
|
|
5
|
+
import { wantsSpecifications } from '@owlmeans/server-planning/store'
|
|
6
|
+
import { PlanningPostgresError } from '../errors.js'
|
|
7
|
+
import { cleanRecord, col, driverValue, insertOf, recordOf } from '../sql.js'
|
|
8
|
+
import type { SqlContext } from '../sql.js'
|
|
9
|
+
import type { PlanningCardRecord, PlanningCardResource } from '../types.js'
|
|
10
|
+
|
|
11
|
+
/** Columns no caller ever sees. */
|
|
12
|
+
export const CARD_PRIVATE = ['headAt'] as const
|
|
13
|
+
|
|
14
|
+
const and = (where: Criteria<Workcard> | undefined, ...extra: Criteria<Workcard>[]): Criteria<Workcard> => {
|
|
15
|
+
const base = (where ?? {}) as Criteria<Workcard> & { $and?: Criteria<Workcard>[] }
|
|
16
|
+
return { ...base, $and: [...(base.$and ?? []), ...extra] } as Criteria<Workcard>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const NOT_SPECIFICATION = { kind: { $ne: WorkcardKind.Specification } } as Criteria<Workcard>
|
|
20
|
+
|
|
21
|
+
/** A card list names specifications only when it asks for them — the rule every store keeps. */
|
|
22
|
+
export const routedCriteria = (where?: Criteria<Workcard>): Criteria<Workcard> =>
|
|
23
|
+
wantsSpecifications(where) ? (where ?? {}) as Criteria<Workcard> : and(where, NOT_SPECIFICATION)
|
|
24
|
+
|
|
25
|
+
/** A card row with its private bookkeeping (what the fold reads), or `null`. */
|
|
26
|
+
export const readCardRow = async (sql: SqlContext, id: string, entityId?: string): Promise<PlanningCardRecord | null> => {
|
|
27
|
+
const spec = sql.tables.card
|
|
28
|
+
const rows = await sql.runner.query(
|
|
29
|
+
`SELECT * FROM ${spec.qualified} WHERE ${col(spec, 'id')} = $1${entityId != null ? ` AND ${col(spec, 'entityId')} = $2` : ''}`,
|
|
30
|
+
entityId != null ? [id, entityId] : [id]
|
|
31
|
+
)
|
|
32
|
+
return rows[0] == null ? null : recordOf<PlanningCardRecord>(rows[0], spec)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const readCard = async (sql: SqlContext, id: string, entityId: string): Promise<Workcard | null> => {
|
|
36
|
+
const row = await readCardRow(sql, id, entityId)
|
|
37
|
+
return row == null ? null : cleanRecord(row, CARD_PRIVATE)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Write a folded card whole. `head` is never lowered — an allocation made while the fold ran stays
|
|
42
|
+
* — and `headAt` is never touched.
|
|
43
|
+
*
|
|
44
|
+
* A create (`seq` 1) is an upsert. Anything later is an UPDATE of a row that must still exist: a
|
|
45
|
+
* card purged with its project while one of its own transitions was folding is not resurrected.
|
|
46
|
+
*
|
|
47
|
+
* @throws {PlanningPostgresError} `fold:card-vanished:<id>`
|
|
48
|
+
*/
|
|
49
|
+
export const writeCard = async (sql: SqlContext, card: Workcard): Promise<void> => {
|
|
50
|
+
const spec = sql.tables.card
|
|
51
|
+
if (card.id == null) {
|
|
52
|
+
throw new PlanningPostgresError('malformed:put-without-id')
|
|
53
|
+
}
|
|
54
|
+
const record = { ...card, head: Math.max(card.head ?? card.seq, card.seq) } as Record<string, unknown>
|
|
55
|
+
const head = col(spec, 'head')
|
|
56
|
+
const seq = col(spec, 'seq')
|
|
57
|
+
const assigned = spec.columns
|
|
58
|
+
.filter(column => !['id', 'headAt', 'head'].includes(column.property))
|
|
59
|
+
.map(column => column.column)
|
|
60
|
+
|
|
61
|
+
if (card.seq <= 1) {
|
|
62
|
+
const insert = insertOf(spec, record, {
|
|
63
|
+
skip: CARD_PRIVATE,
|
|
64
|
+
as: 'existing',
|
|
65
|
+
tail: `ON CONFLICT (${col(spec, 'id')}) DO UPDATE SET `
|
|
66
|
+
+ [...assigned.map(name => `${quoteIdent(name)} = EXCLUDED.${quoteIdent(name)}`),
|
|
67
|
+
`${head} = GREATEST(COALESCE(existing.${head}, existing.${seq}), EXCLUDED.${head})`].join(', '),
|
|
68
|
+
})
|
|
69
|
+
await sql.runner.query(insert.text, insert.params)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const columns = spec.columns.filter(column => assigned.includes(column.column))
|
|
74
|
+
const params = [card.id, ...columns.map(column => driverValue(column, record[column.property])), record.head]
|
|
75
|
+
const updated = await sql.runner.query(
|
|
76
|
+
`UPDATE ${spec.qualified} SET ${columns.map((column, index) => `${quoteIdent(column.column)} = $${index + 2}`).join(', ')},`
|
|
77
|
+
+ ` ${head} = GREATEST(COALESCE(${head}, ${seq}), $${columns.length + 2}) WHERE ${col(spec, 'id')} = $1 RETURNING ${col(spec, 'id')}`,
|
|
78
|
+
params
|
|
79
|
+
)
|
|
80
|
+
if (updated.length === 0) {
|
|
81
|
+
throw new PlanningPostgresError(`fold:card-vanished:${card.id}`)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const dropCard = async (sql: SqlContext, id: string, entityId: string): Promise<void> => {
|
|
86
|
+
const spec = sql.tables.card
|
|
87
|
+
await sql.runner.query(
|
|
88
|
+
`DELETE FROM ${spec.qualified} WHERE ${col(spec, 'id')} = $1 AND ${col(spec, 'entityId')} = $2`, [id, entityId]
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const emptyCounts = () => ({
|
|
93
|
+
total: 0, [IntrinsicStatus.Planned]: 0, [IntrinsicStatus.InProgress]: 0, [IntrinsicStatus.Closed]: 0,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
export interface CardPortDeps {
|
|
97
|
+
sql: () => Promise<SqlContext>
|
|
98
|
+
resource: () => PlanningCardResource
|
|
99
|
+
project: (card: string) => Promise<void>
|
|
100
|
+
purge: (project: string, entityId: string) => Promise<number>
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The projection port. Point reads and writes are this package's own statements; lists, counts and
|
|
105
|
+
* summaries go through the resource, so a criteria means here what it means everywhere
|
|
106
|
+
* (`criteriaToSql`).
|
|
107
|
+
*/
|
|
108
|
+
export const makeCardPort = (deps: CardPortDeps): ProjectionStore => ({
|
|
109
|
+
get: async (id, entityId) => await readCard(await deps.sql(), id, entityId),
|
|
110
|
+
|
|
111
|
+
list: async (where, opts) => {
|
|
112
|
+
await deps.sql()
|
|
113
|
+
const listed = await deps.resource().list(routedCriteria(where) as Criteria<PlanningCardRecord>, opts as never)
|
|
114
|
+
return { ...listed, items: listed.items.map(item => cleanRecord(item, CARD_PRIVATE) as Workcard) }
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
count: async where => {
|
|
118
|
+
await deps.sql()
|
|
119
|
+
return await deps.resource().count(routedCriteria(where) as Criteria<PlanningCardRecord>)
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
summary: async (parents, where) => {
|
|
123
|
+
if (parents.length === 0) {
|
|
124
|
+
return {}
|
|
125
|
+
}
|
|
126
|
+
await deps.sql()
|
|
127
|
+
const rows = await deps.resource().countBy(
|
|
128
|
+
and(where, NOT_SPECIFICATION, { parent: parents } as Criteria<Workcard>) as Criteria<PlanningCardRecord>,
|
|
129
|
+
['parent', 'intrinsic']
|
|
130
|
+
)
|
|
131
|
+
return rows.reduce<SummaryView>((view, row) => {
|
|
132
|
+
const parent = row.parent as string
|
|
133
|
+
const counts = view[parent] ?? emptyCounts()
|
|
134
|
+
counts.total += row.count
|
|
135
|
+
counts[row.intrinsic as IntrinsicStatus] = (counts[row.intrinsic as IntrinsicStatus] ?? 0) + row.count
|
|
136
|
+
view[parent] = counts
|
|
137
|
+
return view
|
|
138
|
+
}, {})
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
put: async card => { await writeCard(await deps.sql(), card) },
|
|
142
|
+
|
|
143
|
+
drop: async (id, entityId) => { await dropCard(await deps.sql(), id, entityId) },
|
|
144
|
+
|
|
145
|
+
project: async card => { await deps.project(card) },
|
|
146
|
+
|
|
147
|
+
purge: async (project, entityId) => {
|
|
148
|
+
if (project == null || project === '') {
|
|
149
|
+
throw new PlanningError('purge:empty')
|
|
150
|
+
}
|
|
151
|
+
return await deps.purge(project, entityId)
|
|
152
|
+
},
|
|
153
|
+
})
|