@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,213 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { uuid } from '@owlmeans/basic-ids'
|
|
3
|
+
import {
|
|
4
|
+
CommitFailed, CommitState, isPending, TITLE_MAX, TransitionAction, WorkcardKind,
|
|
5
|
+
} from '@owlmeans/planning'
|
|
6
|
+
import type { PlanningFacade, PlanningPlugin, Transition } from '@owlmeans/planning'
|
|
7
|
+
import { advisoryKey } from '@owlmeans/postgres-resource'
|
|
8
|
+
import type { PostgresResource } from '@owlmeans/postgres-resource'
|
|
9
|
+
import { createBook, createBranch, LIBRARY, planningConformancePlugin } from '@owlmeans/server-planning/conformance'
|
|
10
|
+
import { Client } from 'pg'
|
|
11
|
+
|
|
12
|
+
import { LOST_ALLOCATION, RES_PLANNING_CARD } from '../src/index.js'
|
|
13
|
+
import type { Booted } from './context.js'
|
|
14
|
+
import { gate, makeSuite } from './context.js'
|
|
15
|
+
|
|
16
|
+
/** A lending library's books, folded under the conditions a two-round-trip append creates. */
|
|
17
|
+
const suite = makeSuite('fold')
|
|
18
|
+
const it = gate.skip ? test.skip : test
|
|
19
|
+
|
|
20
|
+
const LIMITS = { gapGraceMs: 300, healAfterMs: 50, lockTimeoutMs: 300 }
|
|
21
|
+
|
|
22
|
+
describe('@owlmeans/planning-postgres — the fold', () => {
|
|
23
|
+
if (gate.skip) {
|
|
24
|
+
test.skip(gate.reason ?? 'postgres gate closed', () => {})
|
|
25
|
+
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let booted: Booted
|
|
30
|
+
const hooked: Array<[string, TransitionAction]> = []
|
|
31
|
+
|
|
32
|
+
/** Catalogues a new book from its own `after` hook — a write to the very card that just committed. */
|
|
33
|
+
const catalogue: PlanningPlugin = {
|
|
34
|
+
name: 'catalogue',
|
|
35
|
+
after: async (event, ctx) => {
|
|
36
|
+
hooked.push([event.transition, event.action])
|
|
37
|
+
if (event.action === TransitionAction.Create && event.type === LIBRARY.book) {
|
|
38
|
+
await ctx.facade.execute({
|
|
39
|
+
card: event.card, action: TransitionAction.Update, changes: { description: 'Catalogued' }, key: `catalogue:${event.card}`,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeAll(async () => {
|
|
46
|
+
booted = await suite.boot({ limits: LIMITS, plugins: [planningConformancePlugin, catalogue] })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
afterAll(async () => {
|
|
50
|
+
await suite.teardown()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const start = async (): Promise<{ org: string, planning: PlanningFacade, book: string }> => {
|
|
54
|
+
const org = `library-${uuid()}`
|
|
55
|
+
const planning = booted.facade(org)
|
|
56
|
+
const branch = await createBranch(planning)
|
|
57
|
+
const book = await createBook(planning, branch.id!)
|
|
58
|
+
return { org, planning, book: book.id! }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const row = (org: string, card: string, seq: number, patch: Partial<Transition> = {}): Transition => ({
|
|
62
|
+
entityId: org, card, kind: WorkcardKind.Card, type: LIBRARY.book, seq, action: TransitionAction.Update,
|
|
63
|
+
changes: {}, actor: {}, at: new Date().toISOString(), commit: { state: CommitState.Pending }, ...patch,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const stateOf = async (id: string): Promise<CommitState | undefined> =>
|
|
67
|
+
(await booted.store.transitions.get(id))?.commit.state
|
|
68
|
+
|
|
69
|
+
it('an after hook runs once per commit, after it, and may write to the card it saw commit', async () => {
|
|
70
|
+
const { planning, book } = await start()
|
|
71
|
+
|
|
72
|
+
expect((await planning.cards.get(book)).description).toBe('Catalogued')
|
|
73
|
+
const created = hooked.filter(([, action]) => action === TransitionAction.Create)
|
|
74
|
+
expect(new Set(created.map(([transition]) => transition)).size).toBe(created.length)
|
|
75
|
+
const updates = (await planning.transitions.list({ card: book })).items.filter(entry => entry.action === TransitionAction.Update)
|
|
76
|
+
expect(updates).toHaveLength(1)
|
|
77
|
+
expect(hooked.filter(([transition]) => transition === updates[0].id)).toHaveLength(1)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('a young gap waits for its appender, whose own fold then commits both', async () => {
|
|
81
|
+
const { org, planning, book } = await start()
|
|
82
|
+
const at = (await booted.store.transitions.nextSeq(book, null))
|
|
83
|
+
const next = await booted.store.transitions.nextSeq(book, null)
|
|
84
|
+
const third = await booted.store.transitions.append(row(org, book, next, { changes: { title: 'Third' } }))
|
|
85
|
+
await booted.store.cards.project(book)
|
|
86
|
+
|
|
87
|
+
expect(await stateOf(third.id!)).toBe(CommitState.Pending)
|
|
88
|
+
expect((await planning.cards.get(book)).seq).toBe(2)
|
|
89
|
+
|
|
90
|
+
const second = await booted.store.transitions.append(row(org, book, at, { changes: { title: 'Second' } }))
|
|
91
|
+
await booted.store.cards.project(book)
|
|
92
|
+
|
|
93
|
+
expect([await stateOf(second.id!), await stateOf(third.id!)]).toEqual([CommitState.Committed, CommitState.Committed])
|
|
94
|
+
const card = await planning.cards.get(book)
|
|
95
|
+
expect([card.title, card.seq, card.head]).toEqual(['Third', 4, 4])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('an allocation lost past the grace becomes a failed placeholder the fold steps over', async () => {
|
|
99
|
+
const { org, planning, book } = await start()
|
|
100
|
+
await booted.store.transitions.nextSeq(book, null)
|
|
101
|
+
const next = await booted.store.transitions.nextSeq(book, null)
|
|
102
|
+
const late = await booted.store.transitions.append(row(org, book, next, {
|
|
103
|
+
changes: { title: 'After the hole' }, at: new Date(Date.now() - 3_600_000).toISOString(),
|
|
104
|
+
}))
|
|
105
|
+
await booted.store.cards.project(book)
|
|
106
|
+
|
|
107
|
+
expect(await stateOf(late.id!)).toBe(CommitState.Committed)
|
|
108
|
+
const hole = (await planning.transitions.list({ card: book, size: 0 })).items.find(entry => entry.seq === next - 1)!
|
|
109
|
+
expect([hole.commit.state, hole.cause, hole.commit.error]).toEqual([CommitState.Failed, LOST_ALLOCATION, LOST_ALLOCATION])
|
|
110
|
+
expect((await planning.cards.get(book)).title).toBe('After the hole')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('an allocation lost with no row at all is released by the next fold', async () => {
|
|
114
|
+
const { planning, book } = await start()
|
|
115
|
+
await booted.store.transitions.nextSeq(book, null)
|
|
116
|
+
expect(isPending(await planning.cards.get(book))).toBe(true)
|
|
117
|
+
|
|
118
|
+
await new Promise(resolve => setTimeout(resolve, LIMITS.gapGraceMs + 50))
|
|
119
|
+
await booted.store.fold(book)
|
|
120
|
+
|
|
121
|
+
const card = await planning.cards.get(book)
|
|
122
|
+
expect(isPending(card)).toBe(false)
|
|
123
|
+
await planning.execute({ card: book, action: TransitionAction.Update, changes: { title: 'Moving on' } }, { wait: true })
|
|
124
|
+
expect((await planning.cards.get(book)).title).toBe('Moving on')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('a failed row at the cursor is folded past without failing the next one', async () => {
|
|
128
|
+
const { org, planning, book } = await start()
|
|
129
|
+
const failed = await booted.store.transitions.append(row(org, book, await booted.store.transitions.nextSeq(book, null)))
|
|
130
|
+
await booted.store.transitions.commit(failed.id!, { state: CommitState.Failed, at: new Date().toISOString(), error: 'refused elsewhere' })
|
|
131
|
+
const after = await booted.store.transitions.append(row(org, book, await booted.store.transitions.nextSeq(book, null), {
|
|
132
|
+
changes: { title: 'Not out of order' },
|
|
133
|
+
}))
|
|
134
|
+
await booted.store.cards.project(book)
|
|
135
|
+
|
|
136
|
+
expect(await stateOf(after.id!)).toBe(CommitState.Committed)
|
|
137
|
+
expect((await planning.cards.get(book)).title).toBe('Not out of order')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('a write the database refuses fails that transition alone, and the fold goes on', async () => {
|
|
141
|
+
const { org, planning, book } = await start()
|
|
142
|
+
const before = await planning.cards.get(book)
|
|
143
|
+
const oversized = await booted.store.transitions.append(row(org, book, await booted.store.transitions.nextSeq(book, null), {
|
|
144
|
+
changes: { title: 'x'.repeat(TITLE_MAX + 10) },
|
|
145
|
+
}))
|
|
146
|
+
await booted.store.cards.project(book)
|
|
147
|
+
|
|
148
|
+
const status = await booted.store.commits.status(oversized.id!)
|
|
149
|
+
expect(status.state).toBe(CommitState.Failed)
|
|
150
|
+
const card = await planning.cards.get(book)
|
|
151
|
+
expect([card.title, card.seq]).toEqual([before.title, before.seq + 1])
|
|
152
|
+
|
|
153
|
+
await planning.execute({ card: book, action: TransitionAction.Update, changes: { title: 'Fits' } }, { wait: true })
|
|
154
|
+
expect((await planning.cards.get(book)).title).toBe('Fits')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('a transaction a statement broke is retried, then its pending transitions fail for their waiters', async () => {
|
|
158
|
+
const { planning, book } = await start()
|
|
159
|
+
const schema = `"${suite.schema}"`
|
|
160
|
+
await booted.pg.query(
|
|
161
|
+
`CREATE OR REPLACE FUNCTION ${schema}.refuse_commit() RETURNS trigger AS $$ BEGIN`
|
|
162
|
+
+ ` IF NEW."card" = '${book}' AND NEW."commit"->>'state' = 'committed' THEN RAISE EXCEPTION 'refused by the shelf'; END IF;`
|
|
163
|
+
+ ` RETURN NEW; END $$ LANGUAGE plpgsql`
|
|
164
|
+
)
|
|
165
|
+
await booted.pg.query(
|
|
166
|
+
`CREATE TRIGGER refuse_commit BEFORE UPDATE ON ${schema}."planning_transition" FOR EACH ROW EXECUTE FUNCTION ${schema}.refuse_commit()`
|
|
167
|
+
)
|
|
168
|
+
try {
|
|
169
|
+
const receipt = await planning.execute({ card: book, action: TransitionAction.Update, changes: { title: 'Never' } })
|
|
170
|
+
const refusal = await receipt.committed({ timeout: 5_000 }).catch(error => error)
|
|
171
|
+
expect(refusal).toBeInstanceOf(CommitFailed)
|
|
172
|
+
expect(refusal.message).toContain('refused by the shelf')
|
|
173
|
+
expect((await planning.cards.get(book)).title).not.toBe('Never')
|
|
174
|
+
} finally {
|
|
175
|
+
await booted.pg.query(`DROP TRIGGER refuse_commit ON ${schema}."planning_transition"`)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await planning.execute({ card: book, action: TransitionAction.Update, changes: { title: 'Afterwards' } }, { wait: true })
|
|
179
|
+
expect((await planning.cards.get(book)).title).toBe('Afterwards')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('a fold that cannot take the card\'s lock yields, and a waiter heals it once the lock is free', async () => {
|
|
183
|
+
const { planning, book } = await start()
|
|
184
|
+
const table = booted.context.resource<PostgresResource<never>>(RES_PLANNING_CARD).table.qualified
|
|
185
|
+
const [first, second] = advisoryKey(`planning:${table}:${book}`)
|
|
186
|
+
const holder = new Client({ connectionString: gate.env.POSTGRES_URL as string })
|
|
187
|
+
await holder.connect()
|
|
188
|
+
try {
|
|
189
|
+
await holder.query('BEGIN')
|
|
190
|
+
await holder.query('SELECT pg_advisory_xact_lock($1, $2)', [first, second])
|
|
191
|
+
|
|
192
|
+
const receipt = await planning.execute({ card: book, action: TransitionAction.Update, changes: { title: 'Held back' } })
|
|
193
|
+
expect(receipt.transition.commit.state).toBe(CommitState.Pending)
|
|
194
|
+
const waiting = planning.commits.wait(receipt.transition.id!, { timeout: 8_000 })
|
|
195
|
+
|
|
196
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
197
|
+
await holder.query('ROLLBACK')
|
|
198
|
+
expect((await waiting)?.title).toBe('Held back')
|
|
199
|
+
} finally {
|
|
200
|
+
await holder.end().catch(() => undefined)
|
|
201
|
+
}
|
|
202
|
+
}, 15_000)
|
|
203
|
+
|
|
204
|
+
it('recover folds what no process is folding', async () => {
|
|
205
|
+
const { org, book } = await start()
|
|
206
|
+
const stale = await booted.store.transitions.append(row(org, book, await booted.store.transitions.nextSeq(book, null), {
|
|
207
|
+
changes: { title: 'Recovered' }, at: new Date(Date.now() - 120_000).toISOString(),
|
|
208
|
+
}))
|
|
209
|
+
|
|
210
|
+
expect(await booted.store.recover({ olderThanMs: 60_000 })).toBeGreaterThanOrEqual(1)
|
|
211
|
+
expect(await stateOf(stale.id!)).toBe(CommitState.Committed)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, spyOn, test } from 'bun:test'
|
|
2
|
+
import { getDeclaration, resetDeclarations, schemaToTableSpec } from '@owlmeans/postgres-resource'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
makePlanningCardPostgres, makePlanningLinkPostgres, makePlanningSchemaPostgres, makePlanningTransitionPostgres,
|
|
6
|
+
PlanningCardTableSchema, PlanningLinkTableSchema, PlanningSchemaTableSchema, PlanningTransitionTableSchema,
|
|
7
|
+
RES_PLANNING_CARD, RES_PLANNING_LINK, RES_PLANNING_SCHEMA, RES_PLANNING_TRANSITION,
|
|
8
|
+
} from '../src/index.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* No database: `schemaToTableSpec` is the compiler `init()` runs, so what a table becomes is pinned
|
|
12
|
+
* here — above all that no timestamp becomes `timestamptz`, which would marshal through `Date` and
|
|
13
|
+
* break the ISO-string order the store relies on.
|
|
14
|
+
*/
|
|
15
|
+
describe('@owlmeans/planning-postgres — table shapes', () => {
|
|
16
|
+
test('the card table: text timestamps, integer counters, text body, a private headAt', () => {
|
|
17
|
+
const spec = schemaToTableSpec(RES_PLANNING_CARD, PlanningCardTableSchema, 'app', 'planning_card', true)
|
|
18
|
+
const typeOf = (property: string) => [spec.byProperty[property].sqlType, spec.byProperty[property].jsonType]
|
|
19
|
+
|
|
20
|
+
for (const timestamp of ['createdAt', 'updatedAt', 'closedAt', 'headAt']) {
|
|
21
|
+
expect([timestamp, ...typeOf(timestamp)]).toEqual([timestamp, 'text', 'string'])
|
|
22
|
+
}
|
|
23
|
+
for (const counter of ['seq', 'head', 'revision', 'bodyChars']) {
|
|
24
|
+
expect([counter, typeOf(counter)[0]]).toEqual([counter, 'integer'])
|
|
25
|
+
}
|
|
26
|
+
expect(typeOf('body')[0]).toBe('text')
|
|
27
|
+
expect(typeOf('order')[0]).toBe('double precision')
|
|
28
|
+
expect([spec.byProperty.parents.array, spec.byProperty.labels.array]).toEqual([true, true])
|
|
29
|
+
expect([spec.byProperty.fields.jsonb, spec.byProperty.flows.jsonb]).toEqual([true, true])
|
|
30
|
+
expect(spec.byProperty.createdAt.notNull).toBe(true)
|
|
31
|
+
expect(spec.byProperty.headAt.notNull).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('the log, the links and the schema records keep text timestamps too', () => {
|
|
35
|
+
const log = schemaToTableSpec(RES_PLANNING_TRANSITION, PlanningTransitionTableSchema, 'app', 'planning_transition', true)
|
|
36
|
+
const links = schemaToTableSpec(RES_PLANNING_LINK, PlanningLinkTableSchema, 'app', 'planning_link', true)
|
|
37
|
+
const schemas = schemaToTableSpec(RES_PLANNING_SCHEMA, PlanningSchemaTableSchema, 'app', 'planning_schema', true)
|
|
38
|
+
|
|
39
|
+
expect([log.byProperty.at.sqlType, log.byProperty.seq.sqlType, log.byProperty.commit.jsonb]).toEqual(['text', 'integer', true])
|
|
40
|
+
expect(links.byProperty.createdAt.sqlType).toBe('text')
|
|
41
|
+
expect([schemas.byProperty.version.sqlType, schemas.byProperty.rev.sqlType, schemas.byProperty.project.sqlType]).toEqual(['integer', 'integer', 'text'])
|
|
42
|
+
// The private revision row is a legal kind.
|
|
43
|
+
expect(schemas.checks.find(check => check.name?.endsWith('_kind_enum'))?.expression).toContain(`'head'`)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('each maker declares its indexes once, however often it runs', () => {
|
|
47
|
+
for (const alias of [RES_PLANNING_CARD, RES_PLANNING_TRANSITION, RES_PLANNING_LINK, RES_PLANNING_SCHEMA]) {
|
|
48
|
+
resetDeclarations(alias)
|
|
49
|
+
}
|
|
50
|
+
const warn = spyOn(console, 'warn')
|
|
51
|
+
try {
|
|
52
|
+
for (let run = 0; run < 2; run++) {
|
|
53
|
+
makePlanningCardPostgres()
|
|
54
|
+
makePlanningTransitionPostgres()
|
|
55
|
+
makePlanningLinkPostgres()
|
|
56
|
+
makePlanningSchemaPostgres()
|
|
57
|
+
}
|
|
58
|
+
const names = (alias: string) => getDeclaration(alias).indexes.map(index => index.name).sort()
|
|
59
|
+
|
|
60
|
+
expect(names(RES_PLANNING_CARD)).toEqual([
|
|
61
|
+
'planning_card_code', 'planning_card_entity_intrinsic', 'planning_card_entity_kind_type', 'planning_card_labels',
|
|
62
|
+
'planning_card_parent_order', 'planning_card_parent_status', 'planning_card_parents', 'planning_card_slot',
|
|
63
|
+
])
|
|
64
|
+
expect(names(RES_PLANNING_TRANSITION)).toEqual([
|
|
65
|
+
'planning_transition_card_seq', 'planning_transition_entity_key', 'planning_transition_pending', 'planning_transition_project_at',
|
|
66
|
+
])
|
|
67
|
+
expect(names(RES_PLANNING_LINK)).toEqual([
|
|
68
|
+
'planning_link_edge', 'planning_link_entity_type', 'planning_link_inbound', 'planning_link_project',
|
|
69
|
+
])
|
|
70
|
+
expect(names(RES_PLANNING_SCHEMA)).toEqual(['planning_schema_rev', 'planning_schema_scope'])
|
|
71
|
+
|
|
72
|
+
schemaToTableSpec(RES_PLANNING_CARD, PlanningCardTableSchema, 'app', 'planning_card', true, getDeclaration(RES_PLANNING_CARD).indexes)
|
|
73
|
+
expect(warn).not.toHaveBeenCalled()
|
|
74
|
+
} finally {
|
|
75
|
+
warn.mockRestore()
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('the unique and partial indexes the store relies on', () => {
|
|
80
|
+
const find = (alias: string, name: string) => getDeclaration(alias).indexes.find(index => index.name === name)
|
|
81
|
+
|
|
82
|
+
expect(find(RES_PLANNING_TRANSITION, 'planning_transition_card_seq')).toMatchObject({ columns: ['card', 'seq'], unique: true })
|
|
83
|
+
expect(find(RES_PLANNING_TRANSITION, 'planning_transition_entity_key')).toMatchObject({ unique: true, where: '"key" IS NOT NULL' })
|
|
84
|
+
expect(find(RES_PLANNING_TRANSITION, 'planning_transition_pending')?.where).toContain(`'pending'`)
|
|
85
|
+
expect(find(RES_PLANNING_LINK, 'planning_link_edge')).toMatchObject({ columns: ['from', 'to', 'type'], unique: true })
|
|
86
|
+
expect(find(RES_PLANNING_SCHEMA, 'planning_schema_scope')).toMatchObject({ unique: true })
|
|
87
|
+
expect(find(RES_PLANNING_CARD, 'planning_card_parents')?.method).toBe('gin')
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { schemaToTableSpec } from '@owlmeans/postgres-resource'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
insertOf, pgFault, planningChannel, PlanningLinkTableSchema, PlanningPostgresError, RES_PLANNING_LINK, whereOf,
|
|
6
|
+
} from '../src/index.js'
|
|
7
|
+
|
|
8
|
+
const links = schemaToTableSpec(RES_PLANNING_LINK, PlanningLinkTableSchema, 'app', 'planning_link', true)
|
|
9
|
+
|
|
10
|
+
describe('@owlmeans/planning-postgres — statements', () => {
|
|
11
|
+
test('a fixed-field where binds every value and reads a list as = ANY', () => {
|
|
12
|
+
const where = whereOf(links, { entityId: 'library-1', from: ['a', 'b'], to: undefined, type: 'sequel-of' }, 2)
|
|
13
|
+
|
|
14
|
+
expect(where.text).toBe('"entityId" = $3 AND "from" = ANY($4) AND "type" = $5')
|
|
15
|
+
expect(where.params).toEqual(['library-1', ['a', 'b'], 'sequel-of'])
|
|
16
|
+
expect(whereOf(links, {}).text).toBe('TRUE')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('an insert names every column, JSON-encodes jsonb and binds absence as null', () => {
|
|
20
|
+
const insert = insertOf(links, {
|
|
21
|
+
id: 'l1', entityId: 'library-1', type: 'sequel-of', from: 'b2', to: 'b1', fields: { shelf: 3 }, createdAt: '2026-01-01T00:00:00.000Z',
|
|
22
|
+
}, { tail: 'RETURNING *' })
|
|
23
|
+
|
|
24
|
+
expect(insert.text).toStartWith(`INSERT INTO "app"."planning_link" (`)
|
|
25
|
+
expect(insert.text).toEndWith(' RETURNING *')
|
|
26
|
+
expect(insert.params).toContain('{"shelf":3}')
|
|
27
|
+
expect(insert.params.filter(value => value === null)).toHaveLength(2)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('a driver fault is read through its cause chain', () => {
|
|
31
|
+
const driver = Object.assign(new Error('duplicate key'), { code: '23505', constraint: 'planning_link_edge' })
|
|
32
|
+
const wrapped = Object.assign(new Error('query failed'), { cause: driver })
|
|
33
|
+
|
|
34
|
+
expect(pgFault(wrapped)).toEqual({ code: '23505', constraint: 'planning_link_edge' })
|
|
35
|
+
expect(pgFault(new Error('plain'))).toEqual({})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('the channel is a short identifier of the qualified table, different per schema', () => {
|
|
39
|
+
const first = planningChannel('"branch_a"."planning_transition"')
|
|
40
|
+
const second = planningChannel('"branch_b"."planning_transition"')
|
|
41
|
+
|
|
42
|
+
expect(first).toMatch(/^planning_[0-9a-f]{16}$/)
|
|
43
|
+
expect(first).not.toBe(second)
|
|
44
|
+
expect(planningChannel('"branch_a"."planning_transition"')).toBe(first)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('its faults carry the package marker', () => {
|
|
48
|
+
expect(new PlanningPostgresError('resource-missing:planning-card').message).toBe('planning-postgres:resource-missing:planning-card')
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { TransitionAction, WorkcardKind } from '@owlmeans/planning'
|
|
3
|
+
import { introspectTable, planSync } from '@owlmeans/postgres-resource'
|
|
4
|
+
import type { PostgresResource } from '@owlmeans/postgres-resource'
|
|
5
|
+
import { createBranch, LIBRARY } from '@owlmeans/server-planning/conformance'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
PlanningPostgresError, RES_PLANNING_CARD, RES_PLANNING_LINK, RES_PLANNING_SCHEMA, RES_PLANNING_TRANSITION,
|
|
9
|
+
} from '../src/index.js'
|
|
10
|
+
import { gate, makeSuite } from './context.js'
|
|
11
|
+
|
|
12
|
+
const ALIASES = [RES_PLANNING_CARD, RES_PLANNING_TRANSITION, RES_PLANNING_LINK, RES_PLANNING_SCHEMA]
|
|
13
|
+
|
|
14
|
+
const suite = makeSuite('sync')
|
|
15
|
+
const it = gate.skip ? test.skip : test
|
|
16
|
+
|
|
17
|
+
describe('@owlmeans/planning-postgres — tables and wiring', () => {
|
|
18
|
+
if (gate.skip) {
|
|
19
|
+
test.skip(gate.reason ?? 'postgres gate closed', () => {})
|
|
20
|
+
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterAll(async () => {
|
|
25
|
+
await suite.teardown()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('a second boot reconciles nothing on any of the four tables', async () => {
|
|
29
|
+
await suite.boot()
|
|
30
|
+
const again = await suite.boot()
|
|
31
|
+
const { pool } = await again.context.resource<PostgresResource<never>>(RES_PLANNING_CARD).db()
|
|
32
|
+
const client = await pool.connect()
|
|
33
|
+
try {
|
|
34
|
+
for (const alias of ALIASES) {
|
|
35
|
+
const table = again.context.resource<PostgresResource<never>>(alias).table
|
|
36
|
+
const live = await introspectTable(client, table.schema, table.table, table.qualified)
|
|
37
|
+
expect([alias, planSync(table, live).statements.map(statement => statement.sql)]).toEqual([alias, []])
|
|
38
|
+
}
|
|
39
|
+
} finally {
|
|
40
|
+
client.release()
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('names the file a missing resource is registered by', async () => {
|
|
45
|
+
const bare = await suite.boot({ wiring: 'none' })
|
|
46
|
+
const refusal = await bare.facade('library-1').execute({
|
|
47
|
+
card: { kind: WorkcardKind.Project, type: LIBRARY.branch, title: 'Nowhere to go' }, action: TransitionAction.Create,
|
|
48
|
+
}).catch(error => error)
|
|
49
|
+
|
|
50
|
+
expect(refusal).toBeInstanceOf(PlanningPostgresError)
|
|
51
|
+
expect(refusal.message).toBe('planning-postgres:resource-missing:planning-card: add src/resources/planning/card.ts')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('appendPostgresPlanning registers the four resources and a working service in one call', async () => {
|
|
55
|
+
const appended = await suite.boot({ wiring: 'append' })
|
|
56
|
+
|
|
57
|
+
expect(ALIASES.every(alias => appended.context.hasResource(alias))).toBe(true)
|
|
58
|
+
const branch = await createBranch(appended.facade('library-2'), 'Appended branch')
|
|
59
|
+
expect((await appended.facade('library-2').cards.get(branch.id!)).title).toBe('Appended branch')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": [
|
|
3
|
+
"@owlmeans/dep-config/tsconfig.base.json",
|
|
4
|
+
"@owlmeans/dep-config/tsconfig.node.json"
|
|
5
|
+
],
|
|
6
|
+
"compilerOptions": {
|
|
7
|
+
"types": [
|
|
8
|
+
"bun"
|
|
9
|
+
],
|
|
10
|
+
"rootDir": "../",
|
|
11
|
+
"noEmit": true
|
|
12
|
+
},
|
|
13
|
+
"include": [
|
|
14
|
+
"./**/*",
|
|
15
|
+
"../src/**/*"
|
|
16
|
+
]
|
|
17
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": [
|
|
3
|
+
"@owlmeans/dep-config/tsconfig.base.json",
|
|
4
|
+
"@owlmeans/dep-config/tsconfig.node.json"
|
|
5
|
+
],
|
|
6
|
+
"compilerOptions": {
|
|
7
|
+
"rootDir": "./src/",
|
|
8
|
+
"outDir": "./build/"
|
|
9
|
+
},
|
|
10
|
+
"exclude": ["./dist/**/*", "./build/**/*", "./tests/**/*", "./*.ts"]
|
|
11
|
+
}
|