@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.
Files changed (93) hide show
  1. package/README.md +88 -0
  2. package/agent-meta/manifest.json +16 -0
  3. package/agent-meta/skills/planning-postgres/SKILL.md +292 -0
  4. package/build/consts.d.ts +45 -0
  5. package/build/consts.d.ts.map +1 -0
  6. package/build/consts.js +45 -0
  7. package/build/consts.js.map +1 -0
  8. package/build/errors.d.ts +10 -0
  9. package/build/errors.d.ts.map +1 -0
  10. package/build/errors.js +13 -0
  11. package/build/errors.js.map +1 -0
  12. package/build/index.d.ts +9 -0
  13. package/build/index.d.ts.map +1 -0
  14. package/build/index.js +8 -0
  15. package/build/index.js.map +1 -0
  16. package/build/resource.d.ts +32 -0
  17. package/build/resource.d.ts.map +1 -0
  18. package/build/resource.js +94 -0
  19. package/build/resource.js.map +1 -0
  20. package/build/schemas.d.ts +20 -0
  21. package/build/schemas.d.ts.map +1 -0
  22. package/build/schemas.js +64 -0
  23. package/build/schemas.js.map +1 -0
  24. package/build/service.d.ts +16 -0
  25. package/build/service.d.ts.map +1 -0
  26. package/build/service.js +34 -0
  27. package/build/service.js.map +1 -0
  28. package/build/sql.d.ts +61 -0
  29. package/build/sql.d.ts.map +1 -0
  30. package/build/sql.js +90 -0
  31. package/build/sql.js.map +1 -0
  32. package/build/store/bus.d.ts +52 -0
  33. package/build/store/bus.d.ts.map +1 -0
  34. package/build/store/bus.js +167 -0
  35. package/build/store/bus.js.map +1 -0
  36. package/build/store/cards.d.ts +35 -0
  37. package/build/store/cards.d.ts.map +1 -0
  38. package/build/store/cards.js +112 -0
  39. package/build/store/cards.js.map +1 -0
  40. package/build/store/fold.d.ts +56 -0
  41. package/build/store/fold.d.ts.map +1 -0
  42. package/build/store/fold.js +453 -0
  43. package/build/store/fold.js.map +1 -0
  44. package/build/store/index.d.ts +22 -0
  45. package/build/store/index.d.ts.map +1 -0
  46. package/build/store/index.js +206 -0
  47. package/build/store/index.js.map +1 -0
  48. package/build/store/links.d.ts +18 -0
  49. package/build/store/links.d.ts.map +1 -0
  50. package/build/store/links.js +54 -0
  51. package/build/store/links.js.map +1 -0
  52. package/build/store/schemas.d.ts +32 -0
  53. package/build/store/schemas.d.ts.map +1 -0
  54. package/build/store/schemas.js +143 -0
  55. package/build/store/schemas.js.map +1 -0
  56. package/build/store/specs.d.ts +10 -0
  57. package/build/store/specs.d.ts.map +1 -0
  58. package/build/store/specs.js +47 -0
  59. package/build/store/specs.js.map +1 -0
  60. package/build/store/transitions.d.ts +44 -0
  61. package/build/store/transitions.d.ts.map +1 -0
  62. package/build/store/transitions.js +164 -0
  63. package/build/store/transitions.js.map +1 -0
  64. package/build/types.d.ts +72 -0
  65. package/build/types.d.ts.map +1 -0
  66. package/build/types.js +2 -0
  67. package/build/types.js.map +1 -0
  68. package/package.json +56 -0
  69. package/src/consts.ts +54 -0
  70. package/src/errors.ts +15 -0
  71. package/src/index.ts +9 -0
  72. package/src/resource.ts +133 -0
  73. package/src/schemas.ts +84 -0
  74. package/src/service.ts +45 -0
  75. package/src/sql.ts +135 -0
  76. package/src/store/bus.ts +217 -0
  77. package/src/store/cards.ts +153 -0
  78. package/src/store/fold.ts +560 -0
  79. package/src/store/index.ts +237 -0
  80. package/src/store/links.ts +77 -0
  81. package/src/store/schemas.ts +174 -0
  82. package/src/store/specs.ts +72 -0
  83. package/src/store/transitions.ts +221 -0
  84. package/src/types.ts +73 -0
  85. package/tests/bus.spec.ts +142 -0
  86. package/tests/conformance.spec.ts +37 -0
  87. package/tests/context.ts +143 -0
  88. package/tests/fold.spec.ts +213 -0
  89. package/tests/schema.spec.ts +89 -0
  90. package/tests/sql.spec.ts +50 -0
  91. package/tests/sync.spec.ts +61 -0
  92. package/tests/tsconfig.json +17 -0
  93. 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
+ })
@@ -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
+ }