@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,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
+ })