@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
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { CommitState, PlanningError, WorkcardConflict } from '@owlmeans/planning'
|
|
2
|
+
import type { Transition, TransitionCommit, TransitionStore, TransitionWhere } from '@owlmeans/planning'
|
|
3
|
+
import type { Criteria, ListResult, Sort } from '@owlmeans/resource'
|
|
4
|
+
import { cleanRecord, col, insertOf, pgFault, recordOf, UNIQUE_VIOLATION, whereOf } from '../sql.js'
|
|
5
|
+
import type { SqlContext } from '../sql.js'
|
|
6
|
+
import type { PlanningTransitionResource } from '../types.js'
|
|
7
|
+
|
|
8
|
+
const clean = <T extends object>(record: T): T =>
|
|
9
|
+
Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T
|
|
10
|
+
|
|
11
|
+
/** The portable criteria of a transition query — what a resource list reads the log with. */
|
|
12
|
+
export const transitionCriteria = (where: TransitionWhere): Criteria<Transition> => clean({
|
|
13
|
+
entityId: where.entityId,
|
|
14
|
+
card: where.card,
|
|
15
|
+
project: where.project,
|
|
16
|
+
seq: where.sinceSeq != null ? { $gt: where.sinceSeq } : undefined,
|
|
17
|
+
'commit.state': where.state,
|
|
18
|
+
action: where.action,
|
|
19
|
+
}) as Criteria<Transition>
|
|
20
|
+
|
|
21
|
+
export const transitionOf = (row: Record<string, unknown>, sql: SqlContext): Transition =>
|
|
22
|
+
recordOf<Transition>(row, sql.tables.transition)
|
|
23
|
+
|
|
24
|
+
export const readTransition = async (sql: SqlContext, id: string): Promise<Transition | null> => {
|
|
25
|
+
const spec = sql.tables.transition
|
|
26
|
+
const rows = await sql.runner.query(`SELECT * FROM ${spec.qualified} WHERE ${col(spec, 'id')} = $1`, [id])
|
|
27
|
+
return rows[0] == null ? null : transitionOf(rows[0], sql)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const readTransitionByKey = async (sql: SqlContext, entityId: string, key: string): Promise<Transition | null> => {
|
|
31
|
+
const spec = sql.tables.transition
|
|
32
|
+
const rows = await sql.runner.query(
|
|
33
|
+
`SELECT * FROM ${spec.qualified} WHERE ${col(spec, 'entityId')} = $1 AND ${col(spec, 'key')} = $2`, [entityId, key]
|
|
34
|
+
)
|
|
35
|
+
return rows[0] == null ? null : transitionOf(rows[0], sql)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The log of one card (or several) in `seq` order, as a fold reads it: this package's own statement,
|
|
40
|
+
* so it runs inside a fold's transaction. `size` bounds it (0 is no limit); `total` tells a bounded
|
|
41
|
+
* read that more remain.
|
|
42
|
+
*/
|
|
43
|
+
export const listTransitions = async (
|
|
44
|
+
sql: SqlContext, where: TransitionWhere, size: number = 0
|
|
45
|
+
): Promise<ListResult<Transition>> => {
|
|
46
|
+
const spec = sql.tables.transition
|
|
47
|
+
const fixed = whereOf(spec, {
|
|
48
|
+
entityId: where.entityId,
|
|
49
|
+
card: where.card,
|
|
50
|
+
project: where.project,
|
|
51
|
+
action: where.action as string | string[] | undefined,
|
|
52
|
+
})
|
|
53
|
+
const params = [...fixed.params]
|
|
54
|
+
const clauses = [fixed.text]
|
|
55
|
+
if (where.sinceSeq != null) {
|
|
56
|
+
params.push(where.sinceSeq)
|
|
57
|
+
clauses.push(`${col(spec, 'seq')} > $${params.length}`)
|
|
58
|
+
}
|
|
59
|
+
if (where.state != null) {
|
|
60
|
+
params.push(where.state)
|
|
61
|
+
clauses.push(`(${col(spec, 'commit')}->>'state') = $${params.length}`)
|
|
62
|
+
}
|
|
63
|
+
const limit = size > 0 ? ` LIMIT ${Math.floor(size) + 1}` : ''
|
|
64
|
+
const rows = await sql.runner.query(
|
|
65
|
+
`SELECT * FROM ${spec.qualified} WHERE ${clauses.join(' AND ')} ORDER BY ${col(spec, 'seq')}, ${col(spec, 'id')}${limit}`, params
|
|
66
|
+
)
|
|
67
|
+
const items = rows.map(row => transitionOf(row, sql))
|
|
68
|
+
|
|
69
|
+
return size > 0 && items.length > size ? { items: items.slice(0, size), total: items.length } : { items, total: items.length }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const commitTransition = async (sql: SqlContext, id: string, commit: TransitionCommit): Promise<void> => {
|
|
73
|
+
const spec = sql.tables.transition
|
|
74
|
+
await sql.runner.query(
|
|
75
|
+
`UPDATE ${spec.qualified} SET ${col(spec, 'commit')} = $2::jsonb WHERE ${col(spec, 'id')} = $1`, [id, JSON.stringify(clean(commit))]
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Allocate the next seq — a compare-and-set of `head` on the card row, stamping `headAt`. A card
|
|
81
|
+
* whose create has not folded yet has no row to count on: it allocates `max(seq) + 1` from the log
|
|
82
|
+
* and lets the unique `(card, seq)` index refuse a repeat.
|
|
83
|
+
*
|
|
84
|
+
* @throws {WorkcardConflict}
|
|
85
|
+
*/
|
|
86
|
+
export const allocateSeq = async (
|
|
87
|
+
sql: SqlContext, card: string, expect: number | null | undefined, at: string
|
|
88
|
+
): Promise<number> => {
|
|
89
|
+
const cards = sql.tables.card
|
|
90
|
+
const head = col(cards, 'head')
|
|
91
|
+
const seq = col(cards, 'seq')
|
|
92
|
+
const guarded = expect != null
|
|
93
|
+
const allocated = await sql.runner.query<{ head: number }>(
|
|
94
|
+
`UPDATE ${cards.qualified} SET ${head} = COALESCE(${head}, ${seq}) + 1, ${col(cards, 'headAt')} = $2`
|
|
95
|
+
+ ` WHERE ${col(cards, 'id')} = $1${guarded ? ` AND COALESCE(${head}, ${seq}) = $3` : ''} RETURNING ${head} AS head`,
|
|
96
|
+
guarded ? [card, at, expect] : [card, at]
|
|
97
|
+
)
|
|
98
|
+
if (allocated[0] != null) {
|
|
99
|
+
return Number(allocated[0].head)
|
|
100
|
+
}
|
|
101
|
+
const existing = await sql.runner.query<{ head: number }>(
|
|
102
|
+
`SELECT COALESCE(${head}, ${seq}) AS head FROM ${cards.qualified} WHERE ${col(cards, 'id')} = $1`, [card]
|
|
103
|
+
)
|
|
104
|
+
if (existing[0] != null) {
|
|
105
|
+
throw new WorkcardConflict(`${card}:expected:${expect}:head:${existing[0].head}`)
|
|
106
|
+
}
|
|
107
|
+
const last = await lastSeq(sql, card)
|
|
108
|
+
if (guarded && expect !== last) {
|
|
109
|
+
throw new WorkcardConflict(`${card}:expected:${expect}:head:${last}`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return last + 1
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const lastSeq = async (sql: SqlContext, card: string): Promise<number> => {
|
|
116
|
+
const spec = sql.tables.transition
|
|
117
|
+
const rows = await sql.runner.query<{ last: number }>(
|
|
118
|
+
`SELECT COALESCE(MAX(${col(spec, 'seq')}), 0) AS last FROM ${spec.qualified} WHERE ${col(spec, 'card')} = $1`, [card]
|
|
119
|
+
)
|
|
120
|
+
return Number(rows[0]?.last ?? 0)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Give an allocation back when its append failed and nothing was allocated after it. */
|
|
124
|
+
const releaseSeq = async (sql: SqlContext, card: string, seq: number): Promise<void> => {
|
|
125
|
+
const cards = sql.tables.card
|
|
126
|
+
await sql.runner.query(
|
|
127
|
+
`UPDATE ${cards.qualified} SET ${col(cards, 'head')} = $2 - 1 WHERE ${col(cards, 'id')} = $1 AND ${col(cards, 'head')} = $2`,
|
|
128
|
+
[card, seq]
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface TransitionPortDeps {
|
|
133
|
+
sql: () => Promise<SqlContext>
|
|
134
|
+
resource: () => PlanningTransitionResource
|
|
135
|
+
ids: () => string
|
|
136
|
+
now: () => string
|
|
137
|
+
/** The card-seq and entity-key unique index names — how a violation is told apart. */
|
|
138
|
+
indexes: () => Promise<{ cardSeq: string, entityKey: string }>
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The log port. `append` is idempotent on its id; a lost race on the key releases the allocation
|
|
143
|
+
* and answers `PlanningError('duplicate-key')` (the executor then answers the winner's receipt); a
|
|
144
|
+
* lost race on `(card, seq)` — an allocation the fold gave up for lost and filled with a failed
|
|
145
|
+
* placeholder — re-allocates once and lands after it, then refuses with `WorkcardConflict`.
|
|
146
|
+
*/
|
|
147
|
+
export const makeTransitionPort = (deps: TransitionPortDeps): TransitionStore => {
|
|
148
|
+
const insert = async (sql: SqlContext, transition: Transition): Promise<Transition> => {
|
|
149
|
+
const statement = insertOf(sql.tables.transition, transition as unknown as Record<string, unknown>, { tail: 'RETURNING *' })
|
|
150
|
+
const rows = await sql.runner.query(statement.text, statement.params)
|
|
151
|
+
return transitionOf(rows[0], sql)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const store: TransitionStore = {
|
|
155
|
+
append: async transition => {
|
|
156
|
+
const sql = await deps.sql()
|
|
157
|
+
const indexes = await deps.indexes()
|
|
158
|
+
let current: Transition = {
|
|
159
|
+
...transition, id: transition.id ?? deps.ids(), commit: transition.commit ?? { state: CommitState.Pending },
|
|
160
|
+
}
|
|
161
|
+
for (let attempt = 0; ; attempt++) {
|
|
162
|
+
try {
|
|
163
|
+
return await insert(sql, current)
|
|
164
|
+
} catch (error) {
|
|
165
|
+
const fault = pgFault(error)
|
|
166
|
+
if (fault.code !== UNIQUE_VIOLATION) {
|
|
167
|
+
await releaseSeq(sql, current.card, current.seq).catch(() => undefined)
|
|
168
|
+
throw error
|
|
169
|
+
}
|
|
170
|
+
if (fault.constraint === indexes.entityKey) {
|
|
171
|
+
await releaseSeq(sql, current.card, current.seq)
|
|
172
|
+
throw new PlanningError(`duplicate-key:${current.key}`)
|
|
173
|
+
}
|
|
174
|
+
if (fault.constraint === indexes.cardSeq) {
|
|
175
|
+
if (attempt === 0 && current.seq > 1) {
|
|
176
|
+
current = { ...current, seq: await allocateSeq(sql, current.card, null, deps.now()) }
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
throw new WorkcardConflict(`${current.card}:seq:${current.seq}`)
|
|
180
|
+
}
|
|
181
|
+
const existing = await readTransition(sql, current.id!)
|
|
182
|
+
if (existing != null) {
|
|
183
|
+
return existing
|
|
184
|
+
}
|
|
185
|
+
throw error
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
get: async id => await readTransition(await deps.sql(), id),
|
|
191
|
+
|
|
192
|
+
byKey: async (entityId, key) => await readTransitionByKey(await deps.sql(), entityId, key),
|
|
193
|
+
|
|
194
|
+
list: async (where, opts) => {
|
|
195
|
+
await deps.sql()
|
|
196
|
+
const sort: Sort<Transition>[] = opts?.sort ?? (typeof where.card === 'string' ? ['seq'] : ['at', 'seq'])
|
|
197
|
+
const listed = await deps.resource().list(transitionCriteria(where), { ...opts, sort })
|
|
198
|
+
return { ...listed, items: listed.items.map(item => cleanRecord(item)) }
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
nextSeq: async (card, expect) => await allocateSeq(await deps.sql(), card, expect, deps.now()),
|
|
202
|
+
|
|
203
|
+
head: async card => {
|
|
204
|
+
const sql = await deps.sql()
|
|
205
|
+
const cards = sql.tables.card
|
|
206
|
+
const rows = await sql.runner.query<{ head: number }>(
|
|
207
|
+
`SELECT COALESCE(${col(cards, 'head')}, ${col(cards, 'seq')}) AS head FROM ${cards.qualified} WHERE ${col(cards, 'id')} = $1`, [card]
|
|
208
|
+
)
|
|
209
|
+
return rows[0] != null ? Number(rows[0].head) : await lastSeq(sql, card)
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
commit: async (id, commit) => { await commitTransition(await deps.sql(), id, commit) },
|
|
213
|
+
|
|
214
|
+
purge: async where => {
|
|
215
|
+
await deps.sql()
|
|
216
|
+
return await deps.resource().purge(transitionCriteria(where))
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return store
|
|
221
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { BasicContext } from '@owlmeans/context'
|
|
2
|
+
import type {
|
|
3
|
+
ProjectionStore, Relationship, RelationshipStore, SchemaStore, ScopedSchemaRecord, SpecificationStore,
|
|
4
|
+
Transition, TransitionStore, Workcard,
|
|
5
|
+
} from '@owlmeans/planning'
|
|
6
|
+
import type { PostgresResource } from '@owlmeans/postgres-resource'
|
|
7
|
+
import type {
|
|
8
|
+
BindablePlanningStore, CommitHub, FoldResult, PlanningServiceOptions,
|
|
9
|
+
} from '@owlmeans/server-planning'
|
|
10
|
+
import type { DEFAULT_PLANNING_POSTGRES_LIMITS } from './consts.js'
|
|
11
|
+
|
|
12
|
+
export type PlanningPostgresLimits = { -readonly [K in keyof typeof DEFAULT_PLANNING_POSTGRES_LIMITS]: number }
|
|
13
|
+
|
|
14
|
+
/** The resource aliases the store resolves its four tables by. */
|
|
15
|
+
export interface PlanningPostgresAliases {
|
|
16
|
+
card: string
|
|
17
|
+
transition: string
|
|
18
|
+
link: string
|
|
19
|
+
schema: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PlanningPostgresOptions {
|
|
23
|
+
aliases?: Partial<PlanningPostgresAliases>
|
|
24
|
+
/**
|
|
25
|
+
* NOTIFY every settled commit and schema write on the store's channel, and LISTEN for other
|
|
26
|
+
* processes' (default `true`). `false` leaves a waiter to its poll and a reader to the revision.
|
|
27
|
+
*/
|
|
28
|
+
bus?: boolean
|
|
29
|
+
limits?: Partial<PlanningPostgresLimits>
|
|
30
|
+
/** Card and transition ids. */
|
|
31
|
+
ids?: () => string
|
|
32
|
+
/** ISO-8601 timestamps. */
|
|
33
|
+
now?: () => string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PostgresPlanningStoreOptions extends PlanningPostgresOptions {
|
|
37
|
+
/** The context the four resources are registered on — read at call time, never at construction. */
|
|
38
|
+
context: () => BasicContext<any> | undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A card row: the record, plus the allocation bookkeeping no caller ever sees. */
|
|
42
|
+
export interface PlanningCardRecord extends Workcard {
|
|
43
|
+
/** When `head` last moved — how old a gap in the card's log is. Private to the store. */
|
|
44
|
+
headAt?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A schema row: a record, or the private per-organization revision counter (`kind: 'head'`). */
|
|
48
|
+
export type PlanningSchemaRow = Omit<ScopedSchemaRecord, 'kind'> & { kind: ScopedSchemaRecord['kind'] | 'head' }
|
|
49
|
+
|
|
50
|
+
export interface PlanningCardResource extends PostgresResource<PlanningCardRecord> { }
|
|
51
|
+
export interface PlanningTransitionResource extends PostgresResource<Transition> { }
|
|
52
|
+
export interface PlanningLinkResource extends PostgresResource<Relationship> { }
|
|
53
|
+
export interface PlanningSchemaResource extends PostgresResource<PlanningSchemaRow> { }
|
|
54
|
+
|
|
55
|
+
export interface PostgresPlanningStore extends BindablePlanningStore {
|
|
56
|
+
transitions: TransitionStore
|
|
57
|
+
cards: ProjectionStore
|
|
58
|
+
specs: SpecificationStore
|
|
59
|
+
links: RelationshipStore
|
|
60
|
+
commits: CommitHub
|
|
61
|
+
schemas: SchemaStore
|
|
62
|
+
/**
|
|
63
|
+
* Fold what is pending for one card — one transaction under its advisory lock, the settled
|
|
64
|
+
* events delivered after the commit.
|
|
65
|
+
*/
|
|
66
|
+
fold: (cardId: string) => Promise<FoldResult>
|
|
67
|
+
/** Fold every card whose oldest pending row is older than `olderThanMs`. Answers how many. */
|
|
68
|
+
recover: (opts?: { olderThanMs?: number, limit?: number }) => Promise<number>
|
|
69
|
+
/** Release the LISTEN connection and stop healing. */
|
|
70
|
+
close: () => Promise<void>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PostgresPlanningServiceOptions extends Omit<PlanningServiceOptions, 'store'>, PlanningPostgresOptions { }
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { uuid } from '@owlmeans/basic-ids'
|
|
3
|
+
import { CommitState, IntrinsicStatus, TransitionAction, WorkcardKind } from '@owlmeans/planning'
|
|
4
|
+
import type { CommitEvent, Transition } from '@owlmeans/planning'
|
|
5
|
+
import { createBook, createBranch, LIBRARY } from '@owlmeans/server-planning/conformance'
|
|
6
|
+
|
|
7
|
+
import type { Booted } from './context.js'
|
|
8
|
+
import { eventually, gate, makeSuite } from './context.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Two (and three) processes over one schema — each a booted context of its own, exactly as two
|
|
12
|
+
* replicas share a database. A lending library's branches and books travel between them.
|
|
13
|
+
*/
|
|
14
|
+
const suite = makeSuite('bus')
|
|
15
|
+
const it = gate.skip ? test.skip : test
|
|
16
|
+
|
|
17
|
+
describe('@owlmeans/planning-postgres — the commit bus', () => {
|
|
18
|
+
if (gate.skip) {
|
|
19
|
+
test.skip(gate.reason ?? 'postgres gate closed', () => {})
|
|
20
|
+
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let first: Booted
|
|
25
|
+
let second: Booted
|
|
26
|
+
let silent: Booted
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
first = await suite.boot()
|
|
30
|
+
second = await suite.boot()
|
|
31
|
+
silent = await suite.boot({ bus: false })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
afterAll(async () => {
|
|
35
|
+
await suite.teardown()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const pendingRow = (org: string, card: string, seq: number): Transition => ({
|
|
39
|
+
entityId: org, card, kind: WorkcardKind.Card, type: LIBRARY.book, seq, action: TransitionAction.Update,
|
|
40
|
+
changes: { title: `Edition ${seq}` }, actor: {}, at: new Date().toISOString(), commit: { state: CommitState.Pending },
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('another process hears a commit; a process hears its own exactly once', async () => {
|
|
44
|
+
const org = `library-${uuid()}`
|
|
45
|
+
const heard: CommitEvent[] = []
|
|
46
|
+
const unsubscribe = await first.facade(org).commits.subscribe(event => { heard.push(event) })
|
|
47
|
+
try {
|
|
48
|
+
const branch = await createBranch(second.facade(org))
|
|
49
|
+
// The LISTEN connection opens with the first subscription; write until it carries a frame.
|
|
50
|
+
await eventually(async () => {
|
|
51
|
+
if (heard.some(event => event.card === branch.id)) {
|
|
52
|
+
return true
|
|
53
|
+
}
|
|
54
|
+
await second.facade(org).execute({ card: branch.id!, action: TransitionAction.Transit, transition: 'activate' }).catch(() => undefined)
|
|
55
|
+
return heard.some(event => event.card === branch.id)
|
|
56
|
+
})
|
|
57
|
+
const remote = heard.find(event => event.card === branch.id)!
|
|
58
|
+
expect(remote.record).toBeUndefined()
|
|
59
|
+
expect(remote.entityId).toBe(org)
|
|
60
|
+
|
|
61
|
+
const own = await createBook(first.facade(org), branch.id!)
|
|
62
|
+
const receipt = (await first.facade(org).transitions.list({ card: own.id!, size: 0 })).items[0]
|
|
63
|
+
expect(heard.filter(event => event.transition === receipt.id)).toHaveLength(1)
|
|
64
|
+
expect(heard.find(event => event.transition === receipt.id)?.record?.id).toBe(own.id)
|
|
65
|
+
} finally {
|
|
66
|
+
unsubscribe()
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('concurrent writers in two processes each commit once, and the card folds every one', async () => {
|
|
71
|
+
const org = `library-${uuid()}`
|
|
72
|
+
const book = await createBook(first.facade(org), (await createBranch(first.facade(org))).id!)
|
|
73
|
+
const writes = Array.from({ length: 24 }, (_, index) => (index % 2 === 0 ? first : second).facade(org)
|
|
74
|
+
.execute({ card: book.id!, action: TransitionAction.Update, changes: { fields: { signed: index % 3 === 0 } } }))
|
|
75
|
+
const receipts = await Promise.all(writes)
|
|
76
|
+
|
|
77
|
+
const settled = await Promise.all(receipts.map(receipt => receipt.committed({ timeout: 15_000 })))
|
|
78
|
+
expect(settled.every(card => card != null)).toBe(true)
|
|
79
|
+
const log = (await first.facade(org).transitions.list({ card: book.id!, size: 0 })).items
|
|
80
|
+
const states = log.filter(entry => entry.action === TransitionAction.Update).map(entry => entry.commit.state)
|
|
81
|
+
expect(states.filter(state => state === CommitState.Committed).length + states.filter(state => state === CommitState.Failed).length)
|
|
82
|
+
.toBe(states.length)
|
|
83
|
+
const card = await first.facade(org).cards.get(book.id!)
|
|
84
|
+
expect([card.seq, card.head]).toEqual([log.at(-1)!.seq, log.at(-1)!.seq])
|
|
85
|
+
// Every write that landed a row is committed: no transient gap turned into a failure.
|
|
86
|
+
expect(receipts.every(receipt => log.find(entry => entry.id === receipt.transition.id)?.commit.state === CommitState.Committed)).toBe(true)
|
|
87
|
+
}, 30_000)
|
|
88
|
+
|
|
89
|
+
it('a waiter in one process is answered when another process folds', async () => {
|
|
90
|
+
const org = `library-${uuid()}`
|
|
91
|
+
const book = await createBook(second.facade(org), (await createBranch(second.facade(org))).id!)
|
|
92
|
+
const row = await second.store.transitions.append(pendingRow(org, book.id!, await second.store.transitions.nextSeq(book.id!, null)))
|
|
93
|
+
|
|
94
|
+
const waiting = first.facade(org).commits.wait(row.id!, { timeout: 8_000 })
|
|
95
|
+
await second.store.cards.project(book.id!)
|
|
96
|
+
|
|
97
|
+
expect((await waiting)?.title).toBe(`Edition ${row.seq}`)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('a schema write in one process reaches the watchers of another', async () => {
|
|
101
|
+
const org = `library-${uuid()}`
|
|
102
|
+
const heard: string[] = []
|
|
103
|
+
const unwatch = first.store.schemas.watch!(entityId => { heard.push(entityId) })
|
|
104
|
+
try {
|
|
105
|
+
await eventually(async () => {
|
|
106
|
+
await second.facade(org).definitions!.define({
|
|
107
|
+
flows: [{ id: `library:shelving-${uuid()}`, version: 1, statuses: [{ key: 'open', intrinsic: IntrinsicStatus.Planned }], transitions: [] }],
|
|
108
|
+
})
|
|
109
|
+
return heard.includes(org)
|
|
110
|
+
})
|
|
111
|
+
expect(heard).toContain(org)
|
|
112
|
+
} finally {
|
|
113
|
+
unwatch()
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('without the bus nothing is sent or heard, and a wait still resolves by polling', async () => {
|
|
118
|
+
const org = `library-${uuid()}`
|
|
119
|
+
const quiet: CommitEvent[] = []
|
|
120
|
+
const loud: CommitEvent[] = []
|
|
121
|
+
const releaseQuiet = await silent.facade(org).commits.subscribe(event => { quiet.push(event) })
|
|
122
|
+
const releaseLoud = await first.facade(org).commits.subscribe(event => { loud.push(event) })
|
|
123
|
+
try {
|
|
124
|
+
const branch = await createBranch(second.facade(org), 'Heard by the loud one')
|
|
125
|
+
await eventually(() => loud.some(event => event.card === branch.id))
|
|
126
|
+
const unheard = await createBranch(silent.facade(org), 'Never announced')
|
|
127
|
+
await new Promise(resolve => setTimeout(resolve, 300))
|
|
128
|
+
|
|
129
|
+
expect(quiet.some(event => event.card === branch.id)).toBe(false)
|
|
130
|
+
expect(loud.some(event => event.card === unheard.id)).toBe(false)
|
|
131
|
+
|
|
132
|
+
const book = await createBook(second.facade(org), branch.id!)
|
|
133
|
+
const row = await second.store.transitions.append(pendingRow(org, book.id!, await second.store.transitions.nextSeq(book.id!, null)))
|
|
134
|
+
const waiting = silent.facade(org).commits.wait(row.id!, { timeout: 8_000 })
|
|
135
|
+
await second.store.cards.project(book.id!)
|
|
136
|
+
expect((await waiting)?.title).toBe(`Edition ${row.seq}`)
|
|
137
|
+
} finally {
|
|
138
|
+
releaseQuiet()
|
|
139
|
+
releaseLoud()
|
|
140
|
+
}
|
|
141
|
+
}, 15_000)
|
|
142
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, test } from 'bun:test'
|
|
2
|
+
import { planningConformance } from '@owlmeans/server-planning/conformance'
|
|
3
|
+
import type { ConformanceSubject } from '@owlmeans/server-planning/conformance'
|
|
4
|
+
|
|
5
|
+
import type { Booted } from './context.js'
|
|
6
|
+
import { gate, makeSuite } from './context.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The store conformance suite of `@owlmeans/server-planning`, against a real database: the same
|
|
10
|
+
* cases the memory store answers, every one of them — the Postgres store implements the schema port.
|
|
11
|
+
* Each case works in its own organization, so one booted context serves them all.
|
|
12
|
+
*/
|
|
13
|
+
const suite = makeSuite('conformance')
|
|
14
|
+
|
|
15
|
+
describe('@owlmeans/planning-postgres — store conformance', () => {
|
|
16
|
+
if (gate.skip) {
|
|
17
|
+
test.skip(gate.reason ?? 'postgres gate closed', () => {})
|
|
18
|
+
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let booted: Booted
|
|
23
|
+
let subject: ConformanceSubject
|
|
24
|
+
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
booted = await suite.boot()
|
|
27
|
+
subject = { service: booted.service, store: booted.store, facade: booted.facade }
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
afterAll(async () => {
|
|
31
|
+
await suite.teardown()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
for (const entry of planningConformance) {
|
|
35
|
+
test(entry.name, async () => { await entry.run(subject) }, 20_000)
|
|
36
|
+
}
|
|
37
|
+
})
|
package/tests/context.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { postgresGate, randomNamespace } from '@owlmeans/test-integration'
|
|
2
|
+
import type { IntegrationGate, PostgresEnv } from '@owlmeans/test-integration'
|
|
3
|
+
import { PgAutoSync } from '@owlmeans/postgres-resource'
|
|
4
|
+
import { config, makeServerContext } from '@owlmeans/server-context'
|
|
5
|
+
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
6
|
+
import type { PlanningFacade, PlanningPlugin } from '@owlmeans/planning'
|
|
7
|
+
import type { PlanningHostService } from '@owlmeans/server-planning'
|
|
8
|
+
import { conformanceClock, planningConformancePlugin } from '@owlmeans/server-planning/conformance'
|
|
9
|
+
import { Pool } from 'pg'
|
|
10
|
+
|
|
11
|
+
import { appendPostgres } from '@owlmeans/postgres'
|
|
12
|
+
import type { PostgresService } from '@owlmeans/postgres'
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
appendPostgresPlanning, makePlanningCardPostgres, makePlanningLinkPostgres, makePlanningSchemaPostgres,
|
|
16
|
+
makePlanningTransitionPostgres, makePostgresPlanningService,
|
|
17
|
+
} from '../src/index.js'
|
|
18
|
+
import type { PlanningPostgresLimits, PostgresPlanningStore } from '../src/index.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Env-gated integration harness, the shape `@owlmeans/marketing-consent-postgres` uses: a real
|
|
22
|
+
* `ServerContext` per boot against a throwaway schema, dropped in `teardown()`. Specs skip cleanly
|
|
23
|
+
* when `POSTGRES_URL` is unset or nothing answers at it.
|
|
24
|
+
*/
|
|
25
|
+
export const gate: IntegrationGate<PostgresEnv> = postgresGate()
|
|
26
|
+
|
|
27
|
+
const url = (): string => gate.env.POSTGRES_URL as string
|
|
28
|
+
|
|
29
|
+
export const raw = async <R>(fn: (pool: Pool) => Promise<R>): Promise<R> => {
|
|
30
|
+
const pool = new Pool({ connectionString: url(), max: 1 })
|
|
31
|
+
try {
|
|
32
|
+
return await fn(pool)
|
|
33
|
+
} finally {
|
|
34
|
+
await pool.end().catch(() => undefined)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface BootOptions {
|
|
39
|
+
limits?: Partial<PlanningPostgresLimits>
|
|
40
|
+
bus?: boolean
|
|
41
|
+
plugins?: PlanningPlugin[]
|
|
42
|
+
/** `files` registers each resource the way a target's `resources/planning/*.ts` does; `append` uses `appendPostgresPlanning`. */
|
|
43
|
+
wiring?: 'files' | 'append' | 'none'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Booted {
|
|
47
|
+
context: ServerContext<ServerConfig>
|
|
48
|
+
pg: PostgresService
|
|
49
|
+
service: PlanningHostService
|
|
50
|
+
store: PostgresPlanningStore
|
|
51
|
+
facade: (entityId: string) => PlanningFacade
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface Suite {
|
|
55
|
+
schema: string
|
|
56
|
+
boot: (opts?: BootOptions) => Promise<Booted>
|
|
57
|
+
teardown: () => Promise<void>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const makeSuite = (label: string): Suite => {
|
|
61
|
+
const prefix = process.env.POSTGRES_TEST_DB_PREFIX ?? 'omt'
|
|
62
|
+
const schema = randomNamespace(`${prefix}_${label}`)
|
|
63
|
+
const pools: Pool[] = []
|
|
64
|
+
const stores: PostgresPlanningStore[] = []
|
|
65
|
+
|
|
66
|
+
const boot = async (opts: BootOptions = {}): Promise<Booted> => {
|
|
67
|
+
const cfg: ServerConfig = config('planning-pg-test', {
|
|
68
|
+
dbs: [{
|
|
69
|
+
service: 'postgres', alias: 'postgres', host: '127.0.0.1', schema,
|
|
70
|
+
meta: { url: url(), max: 4, retries: 3, retryDelayMillis: 200, autoSync: PgAutoSync.Full },
|
|
71
|
+
}],
|
|
72
|
+
} as Partial<ServerConfig>)
|
|
73
|
+
|
|
74
|
+
const context = makeServerContext(cfg) as ServerContext<ServerConfig>
|
|
75
|
+
appendPostgres(context)
|
|
76
|
+
const serviceOptions = {
|
|
77
|
+
plugins: opts.plugins ?? [planningConformancePlugin],
|
|
78
|
+
now: conformanceClock(),
|
|
79
|
+
...(opts.limits != null ? { limits: opts.limits } : {}),
|
|
80
|
+
...(opts.bus != null ? { bus: opts.bus } : {}),
|
|
81
|
+
}
|
|
82
|
+
if (opts.wiring === 'append') {
|
|
83
|
+
appendPostgresPlanning(context, serviceOptions)
|
|
84
|
+
} else {
|
|
85
|
+
if (opts.wiring !== 'none') {
|
|
86
|
+
for (const make of [makePlanningCardPostgres, makePlanningTransitionPostgres, makePlanningLinkPostgres, makePlanningSchemaPostgres]) {
|
|
87
|
+
context.registerResource(make())
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
context.registerService(makePostgresPlanningService(serviceOptions))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
context.configure()
|
|
94
|
+
const pg = context.service<PostgresService>('postgres')
|
|
95
|
+
try {
|
|
96
|
+
await context.init()
|
|
97
|
+
} finally {
|
|
98
|
+
for (const pool of Object.values(pg.clients ?? {})) {
|
|
99
|
+
if (!pools.includes(pool)) {
|
|
100
|
+
pools.push(pool)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const service = context.service<PlanningHostService>('planning')
|
|
105
|
+
const store = service.store() as PostgresPlanningStore
|
|
106
|
+
stores.push(store)
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
context,
|
|
110
|
+
pg,
|
|
111
|
+
service,
|
|
112
|
+
store,
|
|
113
|
+
facade: entityId => service.for({ entityId, profileId: 'librarian', channel: 'test' }),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const teardown = async (): Promise<void> => {
|
|
118
|
+
if (gate.skip) {
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
for (const store of stores.splice(0)) {
|
|
122
|
+
await store.close().catch(() => undefined)
|
|
123
|
+
}
|
|
124
|
+
await raw(async pool => { await pool.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`) })
|
|
125
|
+
.catch(error => console.error(`planning-postgres test teardown (${schema}):`, error))
|
|
126
|
+
while (pools.length > 0) {
|
|
127
|
+
await pools.pop()?.end().catch(() => undefined)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { schema, boot, teardown }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Wait until `check` answers true, polling — for what another process's delivery settles. */
|
|
135
|
+
export const eventually = async (check: () => boolean | Promise<boolean>, timeout: number = 5_000): Promise<void> => {
|
|
136
|
+
const deadline = Date.now() + timeout
|
|
137
|
+
while (!await check()) {
|
|
138
|
+
if (Date.now() > deadline) {
|
|
139
|
+
throw new Error('eventually: timed out')
|
|
140
|
+
}
|
|
141
|
+
await new Promise(resolve => setTimeout(resolve, 25))
|
|
142
|
+
}
|
|
143
|
+
}
|