@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,237 @@
|
|
|
1
|
+
import { uuid } from '@owlmeans/basic-ids'
|
|
2
|
+
import { CommitState, TransitionAction } from '@owlmeans/planning'
|
|
3
|
+
import type { CommitStatus, Unsubscribe } from '@owlmeans/planning'
|
|
4
|
+
import type { PostgresResource } from '@owlmeans/postgres-resource'
|
|
5
|
+
import type { ResourceRecord } from '@owlmeans/resource'
|
|
6
|
+
import { makeCommitHub } from '@owlmeans/server-planning/store'
|
|
7
|
+
import type { CommitHub, CommitListener } from '@owlmeans/server-planning/store'
|
|
8
|
+
import type { Pool } from 'pg'
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_PLANNING_POSTGRES_LIMITS, PLANNING_POSTGRES_STORE, PLANNING_RESOURCE_FILES, RES_PLANNING_CARD,
|
|
11
|
+
RES_PLANNING_LINK, RES_PLANNING_SCHEMA, RES_PLANNING_TRANSITION,
|
|
12
|
+
} from '../consts.js'
|
|
13
|
+
import { PlanningPostgresError } from '../errors.js'
|
|
14
|
+
import { planningIndexName } from '../resource.js'
|
|
15
|
+
import { poolRunner } from '../sql.js'
|
|
16
|
+
import type { PlanningTables, SqlContext } from '../sql.js'
|
|
17
|
+
import type {
|
|
18
|
+
PlanningCardResource, PlanningLinkResource, PlanningPostgresAliases, PlanningPostgresLimits,
|
|
19
|
+
PlanningSchemaResource, PlanningTransitionResource, PostgresPlanningStore, PostgresPlanningStoreOptions,
|
|
20
|
+
} from '../types.js'
|
|
21
|
+
import { makePlanningBus } from './bus.js'
|
|
22
|
+
import { makeCardPort, readCard } from './cards.js'
|
|
23
|
+
import { makeFoldEngine } from './fold.js'
|
|
24
|
+
import { makeLinkPort } from './links.js'
|
|
25
|
+
import { makeSchemaPort } from './schemas.js'
|
|
26
|
+
import { makeSpecPort } from './specs.js'
|
|
27
|
+
import { makeTransitionPort, readTransition } from './transitions.js'
|
|
28
|
+
|
|
29
|
+
export * from './bus.js'
|
|
30
|
+
export * from './cards.js'
|
|
31
|
+
export * from './fold.js'
|
|
32
|
+
export * from './links.js'
|
|
33
|
+
export * from './schemas.js'
|
|
34
|
+
export * from './specs.js'
|
|
35
|
+
export * from './transitions.js'
|
|
36
|
+
|
|
37
|
+
const isoNow = (): string => new Date().toISOString()
|
|
38
|
+
|
|
39
|
+
/** The aliases a store resolves, each defaulting to the package's own resource alias. */
|
|
40
|
+
export const planningPostgresAliases = (aliases?: Partial<PlanningPostgresAliases>): PlanningPostgresAliases => ({
|
|
41
|
+
card: aliases?.card ?? RES_PLANNING_CARD,
|
|
42
|
+
transition: aliases?.transition ?? RES_PLANNING_TRANSITION,
|
|
43
|
+
link: aliases?.link ?? RES_PLANNING_LINK,
|
|
44
|
+
schema: aliases?.schema ?? RES_PLANNING_SCHEMA,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The Postgres `PlanningStore`: four tables, an inline fold (one transaction per card under its
|
|
49
|
+
* advisory lock), the commit hub fed by the fold and by a LISTEN/NOTIFY bus, and the data-defined
|
|
50
|
+
* schema port.
|
|
51
|
+
*
|
|
52
|
+
* Nothing is resolved at construction: the resources are looked up on the context at the first
|
|
53
|
+
* call — which also runs one `recover()` in the background — so the store can be handed to the
|
|
54
|
+
* planning service before the context initializes. A resource the context does not have is refused
|
|
55
|
+
* with the file a target adds for it.
|
|
56
|
+
*/
|
|
57
|
+
export const makePostgresPlanningStore = (opts: PostgresPlanningStoreOptions): PostgresPlanningStore => {
|
|
58
|
+
const aliases = planningPostgresAliases(opts.aliases)
|
|
59
|
+
const limits: PlanningPostgresLimits = { ...DEFAULT_PLANNING_POSTGRES_LIMITS, ...(opts.limits ?? {}) }
|
|
60
|
+
const ids = opts.ids ?? uuid
|
|
61
|
+
const now = opts.now ?? isoNow
|
|
62
|
+
const processId = uuid()
|
|
63
|
+
let committed: CommitListener | undefined
|
|
64
|
+
let closed = false
|
|
65
|
+
|
|
66
|
+
const resourceOf = <R extends PostgresResource<ResourceRecord>>(kind: keyof PlanningPostgresAliases) => (): R => {
|
|
67
|
+
const context = opts.context()
|
|
68
|
+
const alias = aliases[kind]
|
|
69
|
+
if (context == null) {
|
|
70
|
+
throw new PlanningPostgresError(`context-missing:${alias}`)
|
|
71
|
+
}
|
|
72
|
+
if (!context.hasResource(alias)) {
|
|
73
|
+
throw new PlanningPostgresError(`resource-missing:${alias}: add ${PLANNING_RESOURCE_FILES[kind]}`)
|
|
74
|
+
}
|
|
75
|
+
return context.resource<R>(alias)
|
|
76
|
+
}
|
|
77
|
+
const resources = {
|
|
78
|
+
card: resourceOf<PlanningCardResource>('card'),
|
|
79
|
+
transition: resourceOf<PlanningTransitionResource>('transition'),
|
|
80
|
+
link: resourceOf<PlanningLinkResource>('link'),
|
|
81
|
+
schema: resourceOf<PlanningSchemaResource>('schema'),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let ready: Promise<PlanningTables> | undefined
|
|
85
|
+
let recovered = false
|
|
86
|
+
const tables = (): Promise<PlanningTables> => {
|
|
87
|
+
ready ??= (async () => {
|
|
88
|
+
const resolved = [resources.card(), resources.transition(), resources.link(), resources.schema()]
|
|
89
|
+
// A resource registered after the context initialized is initialized here, once.
|
|
90
|
+
await Promise.all(resolved.map(async resource => { await resource.queryOne('SELECT 1') }))
|
|
91
|
+
const [card, transition, link, schema] = resolved.map(resource => resource.table)
|
|
92
|
+
return { card, transition, link, schema }
|
|
93
|
+
})()
|
|
94
|
+
const pending = ready
|
|
95
|
+
pending.then(() => {
|
|
96
|
+
if (!recovered && !closed) {
|
|
97
|
+
recovered = true
|
|
98
|
+
void engine.recover().catch(error => {
|
|
99
|
+
if (!closed) {
|
|
100
|
+
console.error('planning-postgres: recovery on first use failed:', error)
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
}, () => {
|
|
105
|
+
// A failed resolution (a missing resource) is asked again by the next call.
|
|
106
|
+
if (ready === pending) {
|
|
107
|
+
ready = undefined
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
return pending
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const pool = async (): Promise<Pool> => (await resources.card().db()).pool
|
|
114
|
+
const sql = async (): Promise<SqlContext> => ({ runner: poolRunner(await pool()), tables: await tables() })
|
|
115
|
+
|
|
116
|
+
const schemaWatchers = new Set<(entityId: string) => void>()
|
|
117
|
+
const touched = (entityId: string): void => {
|
|
118
|
+
for (const watcher of [...schemaWatchers]) {
|
|
119
|
+
try {
|
|
120
|
+
watcher(entityId)
|
|
121
|
+
} catch (error) {
|
|
122
|
+
console.error('planning-postgres: schema watcher failed:', error)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const bus = makePlanningBus({
|
|
128
|
+
enabled: opts.bus !== false,
|
|
129
|
+
processId,
|
|
130
|
+
table: async () => (await tables()).transition.qualified,
|
|
131
|
+
pool,
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const statusOf = async (transition: string): Promise<CommitStatus | null> => {
|
|
135
|
+
const context = await sql()
|
|
136
|
+
const row = await readTransition(context, transition)
|
|
137
|
+
if (row == null) {
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
const status: CommitStatus = { transition, state: row.commit.state }
|
|
141
|
+
if (row.commit.at != null) {
|
|
142
|
+
status.at = row.commit.at
|
|
143
|
+
}
|
|
144
|
+
if (row.commit.error != null) {
|
|
145
|
+
status.error = row.commit.error
|
|
146
|
+
}
|
|
147
|
+
if (row.commit.state === CommitState.Pending && Date.now() - Date.parse(row.at) > limits.healAfterMs) {
|
|
148
|
+
// Every waiter is a healer: a commit left pending by a lost fold is folded again.
|
|
149
|
+
engine.heal(row.card)
|
|
150
|
+
}
|
|
151
|
+
if (row.commit.state === CommitState.Committed) {
|
|
152
|
+
status.card = row.action === TransitionAction.Delete ? null : await readCard(context, row.card, row.entityId)
|
|
153
|
+
}
|
|
154
|
+
return status
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const hub = makeCommitHub({ status: statusOf })
|
|
158
|
+
bus.onCommit(async event => { await hub.publish(event) })
|
|
159
|
+
bus.onSchema(touched)
|
|
160
|
+
|
|
161
|
+
const engine = makeFoldEngine({
|
|
162
|
+
pool,
|
|
163
|
+
tables,
|
|
164
|
+
limits,
|
|
165
|
+
ids,
|
|
166
|
+
now,
|
|
167
|
+
bus,
|
|
168
|
+
hub,
|
|
169
|
+
committed: () => committed,
|
|
170
|
+
schemasTouched: touched,
|
|
171
|
+
closed: () => closed,
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const commits: CommitHub = {
|
|
175
|
+
...hub,
|
|
176
|
+
subscribe: (listener, filter) => {
|
|
177
|
+
bus.ensure()
|
|
178
|
+
return hub.subscribe(listener, filter)
|
|
179
|
+
},
|
|
180
|
+
wait: async (transition, waitOpts) => {
|
|
181
|
+
bus.ensure()
|
|
182
|
+
return await hub.wait(transition, waitOpts)
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const indexes = async () => {
|
|
187
|
+
await tables()
|
|
188
|
+
return {
|
|
189
|
+
cardSeq: planningIndexName(aliases.transition, 'card_seq'),
|
|
190
|
+
entityKey: planningIndexName(aliases.transition, 'entity_key'),
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const store: PostgresPlanningStore = {
|
|
195
|
+
alias: PLANNING_POSTGRES_STORE,
|
|
196
|
+
capabilities: { transitions: true, sync: true, purge: true, revisions: true },
|
|
197
|
+
newId: ids,
|
|
198
|
+
transitions: makeTransitionPort({ sql, resource: resources.transition, ids, now, indexes }),
|
|
199
|
+
cards: makeCardPort({
|
|
200
|
+
sql,
|
|
201
|
+
resource: resources.card,
|
|
202
|
+
project: async card => { await engine.fold(card) },
|
|
203
|
+
purge: engine.purge,
|
|
204
|
+
}),
|
|
205
|
+
specs: makeSpecPort({ sql, resource: resources.card }),
|
|
206
|
+
links: makeLinkPort({ sql, resource: resources.link, ids }),
|
|
207
|
+
commits,
|
|
208
|
+
schemas: makeSchemaPort({
|
|
209
|
+
sql,
|
|
210
|
+
pool,
|
|
211
|
+
tables,
|
|
212
|
+
bus,
|
|
213
|
+
ids,
|
|
214
|
+
now,
|
|
215
|
+
touched,
|
|
216
|
+
watch: listener => {
|
|
217
|
+
bus.ensure()
|
|
218
|
+
schemaWatchers.add(listener)
|
|
219
|
+
const unsubscribe: Unsubscribe = () => { schemaWatchers.delete(listener) }
|
|
220
|
+
return unsubscribe
|
|
221
|
+
},
|
|
222
|
+
}),
|
|
223
|
+
|
|
224
|
+
bind: listener => { committed = listener },
|
|
225
|
+
|
|
226
|
+
fold: async cardId => await engine.fold(cardId),
|
|
227
|
+
|
|
228
|
+
recover: async recoverOpts => await engine.recover(recoverOpts),
|
|
229
|
+
|
|
230
|
+
close: async () => {
|
|
231
|
+
closed = true
|
|
232
|
+
await bus.close()
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return store
|
|
237
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { PlanningError } from '@owlmeans/planning'
|
|
2
|
+
import type { Relationship, RelationshipStore, RelationshipWhere } from '@owlmeans/planning'
|
|
3
|
+
import type { Criteria, ListResult } from '@owlmeans/resource'
|
|
4
|
+
import { cleanRecord, col, insertOf, recordOf, whereOf } from '../sql.js'
|
|
5
|
+
import type { SqlContext } from '../sql.js'
|
|
6
|
+
import type { PlanningLinkResource } 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
|
+
export const linkCriteria = (where: RelationshipWhere): Criteria<Relationship> => clean({
|
|
12
|
+
entityId: where.entityId,
|
|
13
|
+
id: where.id,
|
|
14
|
+
from: where.from,
|
|
15
|
+
to: where.to,
|
|
16
|
+
type: where.type,
|
|
17
|
+
project: where.project,
|
|
18
|
+
}) as Criteria<Relationship>
|
|
19
|
+
|
|
20
|
+
const fixed = (where: RelationshipWhere) => ({
|
|
21
|
+
entityId: where.entityId, id: where.id, from: where.from, to: where.to, type: where.type, project: where.project,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
/** Edges by fixed fields — this package's own statement, so it runs inside a fold's transaction. */
|
|
25
|
+
export const listLinks = async (sql: SqlContext, where: RelationshipWhere): Promise<ListResult<Relationship>> => {
|
|
26
|
+
const spec = sql.tables.link
|
|
27
|
+
const condition = whereOf(spec, fixed(where))
|
|
28
|
+
const rows = await sql.runner.query(
|
|
29
|
+
`SELECT * FROM ${spec.qualified} WHERE ${condition.text} ORDER BY ${col(spec, 'createdAt')}, ${col(spec, 'id')}`, condition.params
|
|
30
|
+
)
|
|
31
|
+
const items = rows.map(row => recordOf<Relationship>(row, spec))
|
|
32
|
+
return { items, total: items.length }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One edge per `(from, to, type)`: an existing one is answered as it is. */
|
|
36
|
+
export const putLink = async (sql: SqlContext, link: Relationship, id: () => string): Promise<Relationship> => {
|
|
37
|
+
const spec = sql.tables.link
|
|
38
|
+
const statement = insertOf(spec, { ...link, id: link.id ?? id() } as unknown as Record<string, unknown>, {
|
|
39
|
+
tail: `ON CONFLICT (${col(spec, 'from')}, ${col(spec, 'to')}, ${col(spec, 'type')}) DO NOTHING RETURNING *`,
|
|
40
|
+
})
|
|
41
|
+
const inserted = await sql.runner.query(statement.text, statement.params)
|
|
42
|
+
if (inserted[0] != null) {
|
|
43
|
+
return recordOf<Relationship>(inserted[0], spec)
|
|
44
|
+
}
|
|
45
|
+
const existing = await listLinks(sql, { entityId: link.entityId, from: link.from, to: link.to, type: link.type })
|
|
46
|
+
return existing.items[0] ?? link
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @throws {PlanningError} `links:drop-empty` for a where naming nothing but the organization */
|
|
50
|
+
export const dropLinks = async (sql: SqlContext, where: RelationshipWhere): Promise<number> => {
|
|
51
|
+
if (where.id == null && where.from == null && where.to == null && where.type == null && where.project == null) {
|
|
52
|
+
throw new PlanningError('links:drop-empty')
|
|
53
|
+
}
|
|
54
|
+
const spec = sql.tables.link
|
|
55
|
+
const condition = whereOf(spec, fixed(where))
|
|
56
|
+
return (await sql.runner.query(
|
|
57
|
+
`DELETE FROM ${spec.qualified} WHERE ${condition.text} RETURNING ${col(spec, 'id')}`, condition.params
|
|
58
|
+
)).length
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface LinkPortDeps {
|
|
62
|
+
sql: () => Promise<SqlContext>
|
|
63
|
+
resource: () => PlanningLinkResource
|
|
64
|
+
ids: () => string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const makeLinkPort = (deps: LinkPortDeps): RelationshipStore => ({
|
|
68
|
+
list: async (where, opts) => {
|
|
69
|
+
await deps.sql()
|
|
70
|
+
const listed = await deps.resource().list(linkCriteria(where), opts)
|
|
71
|
+
return { ...listed, items: listed.items.map(item => cleanRecord(item)) }
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
put: async link => await putLink(await deps.sql(), link, deps.ids),
|
|
75
|
+
|
|
76
|
+
drop: async where => await dropLinks(await deps.sql(), where),
|
|
77
|
+
})
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { SchemaConflict } from '@owlmeans/planning'
|
|
2
|
+
import type { SchemaStore, ScopedSchemaRecord, Unsubscribe } from '@owlmeans/planning'
|
|
3
|
+
import type { Pool } from 'pg'
|
|
4
|
+
import { SCHEMA_HEAD_KEY, SCHEMA_HEAD_KIND } from '../consts.js'
|
|
5
|
+
import { clientRunner, col, insertOf, recordOf } from '../sql.js'
|
|
6
|
+
import type { PlanningTables, SqlContext, SqlRunner } from '../sql.js'
|
|
7
|
+
import type { PlanningBus } from './bus.js'
|
|
8
|
+
|
|
9
|
+
/** The scope's uniqueness expression — the one the `…_scope` index is built on. */
|
|
10
|
+
const scopeTarget = (sql: SqlContext): string => {
|
|
11
|
+
const spec = sql.tables.schema
|
|
12
|
+
return `(${col(spec, 'entityId')}, COALESCE(${col(spec, 'project')}, ''), ${col(spec, 'kind')}, ${col(spec, 'key')})`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Bump an organization's schema revision — its private `head` row, created at 1 — and answer the
|
|
17
|
+
* new value. Runs inside the write's own transaction, so the revision moves exactly when it commits.
|
|
18
|
+
*/
|
|
19
|
+
export const bumpRevision = async (sql: SqlContext, entityId: string, at: string, id: () => string): Promise<number> => {
|
|
20
|
+
const spec = sql.tables.schema
|
|
21
|
+
const statement = insertOf(spec, {
|
|
22
|
+
id: id(), entityId, kind: SCHEMA_HEAD_KIND, key: SCHEMA_HEAD_KEY, version: 1, rev: 1, definition: {}, createdAt: at,
|
|
23
|
+
}, {
|
|
24
|
+
as: 'existing',
|
|
25
|
+
tail: `ON CONFLICT ${scopeTarget(sql)} DO UPDATE SET ${col(spec, 'rev')} = COALESCE(existing.${col(spec, 'rev')}, 0) + 1,`
|
|
26
|
+
+ ` ${col(spec, 'updatedAt')} = EXCLUDED.${col(spec, 'createdAt')} RETURNING ${col(spec, 'rev')} AS rev`,
|
|
27
|
+
})
|
|
28
|
+
const rows = await sql.runner.query<{ rev: number }>(statement.text, statement.params)
|
|
29
|
+
return Number(rows[0]?.rev ?? 0)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Remove the layers of the given projects inside a transaction; answers how many records went. */
|
|
33
|
+
export const purgeSchemaLayers = async (sql: SqlContext, entityId: string, projects: readonly string[]): Promise<number> => {
|
|
34
|
+
if (projects.length === 0) {
|
|
35
|
+
return 0
|
|
36
|
+
}
|
|
37
|
+
const spec = sql.tables.schema
|
|
38
|
+
return (await sql.runner.query(
|
|
39
|
+
`DELETE FROM ${spec.qualified} WHERE ${col(spec, 'entityId')} = $1 AND ${col(spec, 'project')} = ANY($2) RETURNING ${col(spec, 'id')}`,
|
|
40
|
+
[entityId, [...projects]]
|
|
41
|
+
)).length
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SchemaPortDeps {
|
|
45
|
+
sql: () => Promise<SqlContext>
|
|
46
|
+
pool: () => Promise<Pool>
|
|
47
|
+
tables: () => Promise<PlanningTables>
|
|
48
|
+
bus: PlanningBus
|
|
49
|
+
ids: () => string
|
|
50
|
+
now: () => string
|
|
51
|
+
/** Every local watcher, told after a write of this process commits. */
|
|
52
|
+
touched: (entityId: string) => void
|
|
53
|
+
watch: (listener: (entityId: string) => void) => Unsubscribe
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The data-defined schema port over `planning-schema`.
|
|
58
|
+
*
|
|
59
|
+
* `put` is one transaction: bump the organization's revision row, write the record under a
|
|
60
|
+
* compare-and-set on `version` (an INSERT that must not conflict for version 1, an UPDATE guarded
|
|
61
|
+
* on `version - 1` after), NOTIFY a schema frame. `revision` reads the head row — one primary read,
|
|
62
|
+
* so every lookup of a resolved layer sees a write committed anywhere.
|
|
63
|
+
*/
|
|
64
|
+
export const makeSchemaPort = (deps: SchemaPortDeps): SchemaStore => {
|
|
65
|
+
const transaction = async <R>(run: (sql: SqlContext) => Promise<R>): Promise<R> => {
|
|
66
|
+
const client = await (await deps.pool()).connect()
|
|
67
|
+
const tables = await deps.tables()
|
|
68
|
+
const runner: SqlRunner = clientRunner(client)
|
|
69
|
+
try {
|
|
70
|
+
await client.query('BEGIN')
|
|
71
|
+
const result = await run({ runner, tables })
|
|
72
|
+
await client.query('COMMIT')
|
|
73
|
+
return result
|
|
74
|
+
} catch (error) {
|
|
75
|
+
await client.query('ROLLBACK').catch(() => undefined)
|
|
76
|
+
throw error
|
|
77
|
+
} finally {
|
|
78
|
+
client.release()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
list: async where => {
|
|
84
|
+
const sql = await deps.sql()
|
|
85
|
+
const spec = sql.tables.schema
|
|
86
|
+
const clauses = [`${col(spec, 'entityId')} = $1`, `${col(spec, 'kind')} <> '${SCHEMA_HEAD_KIND}'`]
|
|
87
|
+
const params: unknown[] = [where.entityId]
|
|
88
|
+
if (where.project === null) {
|
|
89
|
+
clauses.push(`${col(spec, 'project')} IS NULL`)
|
|
90
|
+
} else if (where.project !== undefined) {
|
|
91
|
+
params.push(where.project)
|
|
92
|
+
clauses.push(`${col(spec, 'project')} = $${params.length}`)
|
|
93
|
+
}
|
|
94
|
+
if (where.kind != null) {
|
|
95
|
+
params.push(where.kind)
|
|
96
|
+
clauses.push(`${col(spec, 'kind')} = $${params.length}`)
|
|
97
|
+
}
|
|
98
|
+
if (where.key != null) {
|
|
99
|
+
params.push(Array.isArray(where.key) ? where.key : [where.key])
|
|
100
|
+
clauses.push(`${col(spec, 'key')} = ANY($${params.length})`)
|
|
101
|
+
}
|
|
102
|
+
if (where.retired != null) {
|
|
103
|
+
clauses.push(where.retired ? `${col(spec, 'retired')} IS TRUE` : `${col(spec, 'retired')} IS NOT TRUE`)
|
|
104
|
+
}
|
|
105
|
+
const rows = await sql.runner.query(
|
|
106
|
+
`SELECT * FROM ${spec.qualified} WHERE ${clauses.join(' AND ')}`
|
|
107
|
+
+ ` ORDER BY ${col(spec, 'kind')}, ${col(spec, 'key')}, ${col(spec, 'project')} NULLS FIRST`, params
|
|
108
|
+
)
|
|
109
|
+
return rows.map(row => recordOf<ScopedSchemaRecord>(row, spec))
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
put: async record => {
|
|
113
|
+
const written = await transaction(async sql => {
|
|
114
|
+
const spec = sql.tables.schema
|
|
115
|
+
const rev = await bumpRevision(sql, record.entityId, deps.now(), deps.ids)
|
|
116
|
+
const values = { ...record, id: record.id ?? deps.ids(), rev, retired: record.retired === true ? true : undefined }
|
|
117
|
+
let rows: Record<string, unknown>[]
|
|
118
|
+
if (record.version === 1) {
|
|
119
|
+
const insert = insertOf(spec, values as unknown as Record<string, unknown>, {
|
|
120
|
+
tail: `ON CONFLICT ${scopeTarget(sql)} DO NOTHING RETURNING *`,
|
|
121
|
+
})
|
|
122
|
+
rows = await sql.runner.query(insert.text, insert.params)
|
|
123
|
+
} else {
|
|
124
|
+
rows = await sql.runner.query(
|
|
125
|
+
`UPDATE ${spec.qualified} SET ${col(spec, 'version')} = $5, ${col(spec, 'definition')} = $6::jsonb,`
|
|
126
|
+
+ ` ${col(spec, 'retired')} = $7, ${col(spec, 'rev')} = $8, ${col(spec, 'updatedAt')} = $9, ${col(spec, 'by')} = $10::jsonb`
|
|
127
|
+
+ ` WHERE ${col(spec, 'entityId')} = $1 AND COALESCE(${col(spec, 'project')}, '') = $2 AND ${col(spec, 'kind')} = $3`
|
|
128
|
+
+ ` AND ${col(spec, 'key')} = $4 AND ${col(spec, 'version')} = $5 - 1 RETURNING *`,
|
|
129
|
+
[
|
|
130
|
+
record.entityId, record.project ?? '', record.kind, record.key, record.version,
|
|
131
|
+
JSON.stringify(record.definition), record.retired === true ? true : null, rev,
|
|
132
|
+
record.updatedAt ?? deps.now(), record.by != null ? JSON.stringify(record.by) : null,
|
|
133
|
+
]
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
if (rows[0] == null) {
|
|
137
|
+
throw new SchemaConflict(`${record.kind}:${record.key}:version:${record.version}`)
|
|
138
|
+
}
|
|
139
|
+
await deps.bus.notify(sql.runner, { t: 's', e: record.entityId })
|
|
140
|
+
return recordOf<ScopedSchemaRecord>(rows[0], spec)
|
|
141
|
+
})
|
|
142
|
+
deps.touched(record.entityId)
|
|
143
|
+
return written
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
purge: async where => {
|
|
147
|
+
const count = await transaction(async sql => {
|
|
148
|
+
const purged = await purgeSchemaLayers(sql, where.entityId, [where.project])
|
|
149
|
+
if (purged > 0) {
|
|
150
|
+
await bumpRevision(sql, where.entityId, deps.now(), deps.ids)
|
|
151
|
+
await deps.bus.notify(sql.runner, { t: 's', e: where.entityId })
|
|
152
|
+
}
|
|
153
|
+
return purged
|
|
154
|
+
})
|
|
155
|
+
if (count > 0) {
|
|
156
|
+
deps.touched(where.entityId)
|
|
157
|
+
}
|
|
158
|
+
return count
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
revision: async entityId => {
|
|
162
|
+
const sql = await deps.sql()
|
|
163
|
+
const spec = sql.tables.schema
|
|
164
|
+
const rows = await sql.runner.query<{ rev: number | null }>(
|
|
165
|
+
`SELECT ${col(spec, 'rev')} AS rev FROM ${spec.qualified} WHERE ${col(spec, 'entityId')} = $1`
|
|
166
|
+
+ ` AND ${col(spec, 'project')} IS NULL AND ${col(spec, 'kind')} = '${SCHEMA_HEAD_KIND}' AND ${col(spec, 'key')} = '${SCHEMA_HEAD_KEY}'`,
|
|
167
|
+
[entityId]
|
|
168
|
+
)
|
|
169
|
+
return Number(rows[0]?.rev ?? 0)
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
watch: listener => deps.watch(listener),
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { listOptionsOf, specCriteriaOf, WorkcardKind } from '@owlmeans/planning'
|
|
2
|
+
import type { Specification, SpecificationStore } from '@owlmeans/planning'
|
|
3
|
+
import { applyQuery } from '@owlmeans/resource'
|
|
4
|
+
import type { Criteria } from '@owlmeans/resource'
|
|
5
|
+
import { revisionsFromLog } from '@owlmeans/server-planning/store'
|
|
6
|
+
import { cleanRecord, col, recordOf } from '../sql.js'
|
|
7
|
+
import type { SqlContext } from '../sql.js'
|
|
8
|
+
import type { PlanningCardRecord, PlanningCardResource } from '../types.js'
|
|
9
|
+
import { CARD_PRIVATE } from './cards.js'
|
|
10
|
+
import { listTransitions } from './transitions.js'
|
|
11
|
+
|
|
12
|
+
/** The current document of a slot is the highest revision, then the most recently updated. */
|
|
13
|
+
const currentOrder = (sql: SqlContext): string => {
|
|
14
|
+
const spec = sql.tables.card
|
|
15
|
+
return `COALESCE(${col(spec, 'revision')}, 0) DESC, COALESCE(${col(spec, 'updatedAt')}, ${col(spec, 'createdAt')}) DESC, ${col(spec, 'id')}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const specOf = (row: Record<string, unknown>, sql: SqlContext): Specification =>
|
|
19
|
+
cleanRecord(recordOf<PlanningCardRecord>(row, sql.tables.card), CARD_PRIVATE) as unknown as Specification
|
|
20
|
+
|
|
21
|
+
export interface SpecPortDeps {
|
|
22
|
+
sql: () => Promise<SqlContext>
|
|
23
|
+
resource: () => PlanningCardResource
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Specifications are rows of the card table (`kind: 'specification'`), read per parent and slot. */
|
|
27
|
+
export const makeSpecPort = (deps: SpecPortDeps): SpecificationStore => ({
|
|
28
|
+
current: async (parent, category, entityId) => {
|
|
29
|
+
const sql = await deps.sql()
|
|
30
|
+
const spec = sql.tables.card
|
|
31
|
+
const rows = await sql.runner.query(
|
|
32
|
+
`SELECT * FROM ${spec.qualified} WHERE ${col(spec, 'entityId')} = $1 AND ${col(spec, 'kind')} = $2`
|
|
33
|
+
+ ` AND ${col(spec, 'parent')} = $3 AND ${col(spec, 'category')} = $4 ORDER BY ${currentOrder(sql)} LIMIT 1`,
|
|
34
|
+
[entityId, WorkcardKind.Specification, parent, category]
|
|
35
|
+
)
|
|
36
|
+
return rows[0] == null ? null : specOf(rows[0], sql)
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
list: async (parent, entityId, query) => {
|
|
40
|
+
const sql = await deps.sql()
|
|
41
|
+
if (query?.all === true) {
|
|
42
|
+
const listed = await deps.resource().list(
|
|
43
|
+
specCriteriaOf(parent, query, { entityId }) as unknown as Criteria<PlanningCardRecord>, listOptionsOf(query) as never
|
|
44
|
+
)
|
|
45
|
+
return { ...listed, items: listed.items.map(item => cleanRecord(item, CARD_PRIVATE) as unknown as Specification) }
|
|
46
|
+
}
|
|
47
|
+
const spec = sql.tables.card
|
|
48
|
+
const categories = query?.category == null ? undefined : Array.isArray(query.category) ? query.category : [query.category]
|
|
49
|
+
const rows = await sql.runner.query(
|
|
50
|
+
`SELECT DISTINCT ON (${col(spec, 'category')}) * FROM ${spec.qualified}`
|
|
51
|
+
+ ` WHERE ${col(spec, 'entityId')} = $1 AND ${col(spec, 'kind')} = $2 AND ${col(spec, 'parent')} = $3`
|
|
52
|
+
+ `${categories != null ? ` AND ${col(spec, 'category')} = ANY($4)` : ''}`
|
|
53
|
+
+ ` ORDER BY ${col(spec, 'category')}, ${currentOrder(sql)}`,
|
|
54
|
+
categories != null ? [entityId, WorkcardKind.Specification, parent, categories] : [entityId, WorkcardKind.Specification, parent]
|
|
55
|
+
)
|
|
56
|
+
// One current document per category is a small set: its paging and sorting are the shared engine's.
|
|
57
|
+
return applyQuery(rows.map(row => specOf(row, sql)), undefined, listOptionsOf(query))
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
revisions: async (id, entityId, limit) => {
|
|
61
|
+
const sql = await deps.sql()
|
|
62
|
+
const spec = sql.tables.card
|
|
63
|
+
const found = await sql.runner.query(
|
|
64
|
+
`SELECT ${col(spec, 'id')} FROM ${spec.qualified} WHERE ${col(spec, 'id')} = $1 AND ${col(spec, 'entityId')} = $2 AND ${col(spec, 'kind')} = $3`,
|
|
65
|
+
[id, entityId, WorkcardKind.Specification]
|
|
66
|
+
)
|
|
67
|
+
if (found.length === 0) {
|
|
68
|
+
return []
|
|
69
|
+
}
|
|
70
|
+
return revisionsFromLog((await listTransitions(sql, { entityId, card: id })).items, limit)
|
|
71
|
+
},
|
|
72
|
+
})
|