@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
package/src/service.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { BasicConfig, BasicContext } from '@owlmeans/context'
2
+ import type { WithPlanningService } from '@owlmeans/planning'
3
+ import { DEFAULT_ALIAS, makePlanningService } from '@owlmeans/server-planning'
4
+ import type { PlanningHostService } from '@owlmeans/server-planning'
5
+ import { makePlanningPostgresResources } from './resource.js'
6
+ import { makePostgresPlanningStore, planningPostgresAliases } from './store/index.js'
7
+ import type { PostgresPlanningServiceOptions } from './types.js'
8
+
9
+ /**
10
+ * The planning service over a Postgres store — what a target's own `services/planning.ts`
11
+ * exports as its `makeService()`. The store resolves its four resources on the service's context
12
+ * at the first call, so the resources are registered separately (`resources/planning/*.ts`).
13
+ */
14
+ export const makePostgresPlanningService = (
15
+ opts: PostgresPlanningServiceOptions = {}, alias: string = DEFAULT_ALIAS
16
+ ): PlanningHostService => {
17
+ const { aliases, bus, limits, ...service } = opts
18
+ let host: PlanningHostService | undefined
19
+ const store = makePostgresPlanningStore({
20
+ aliases, bus, limits, ids: opts.ids, now: opts.now,
21
+ context: () => host?.ctx as BasicContext<any> | undefined,
22
+ })
23
+ host = makePlanningService({ ...service, store }, alias)
24
+
25
+ return host
26
+ }
27
+
28
+ /**
29
+ * Register the four Postgres resources (each unless the context already has it), the planning
30
+ * service over them, and `context.planning()` — the one-call wiring of a hand-written backend.
31
+ */
32
+ export const appendPostgresPlanning = <C extends BasicConfig, T extends BasicContext<C>>(
33
+ ctx: T, opts: PostgresPlanningServiceOptions = {}, alias: string = DEFAULT_ALIAS
34
+ ): T & WithPlanningService => {
35
+ const context = ctx as T & WithPlanningService
36
+ for (const resource of makePlanningPostgresResources(planningPostgresAliases(opts.aliases))) {
37
+ if (!context.hasResource(resource.alias)) {
38
+ context.registerResource(resource)
39
+ }
40
+ }
41
+ context.registerService(makePostgresPlanningService(opts, alias))
42
+ context.planning = () => context.service<PlanningHostService>(alias)
43
+
44
+ return context
45
+ }
package/src/sql.ts ADDED
@@ -0,0 +1,135 @@
1
+ import { quoteIdent, rowToRecord } from '@owlmeans/postgres-resource'
2
+ import type { ColumnSpec, TableSpec } from '@owlmeans/postgres-resource'
3
+ import type { ResourceRecord } from '@owlmeans/resource'
4
+ import type { Pool, PoolClient, QueryResultRow } from 'pg'
5
+
6
+ /** Where a statement runs — the pool, or one open transaction. */
7
+ export interface SqlRunner {
8
+ query: <R extends QueryResultRow = QueryResultRow>(text: string, params?: unknown[]) => Promise<R[]>
9
+ }
10
+
11
+ /** The compiled specs of the four tables, read once the resources have initialized. */
12
+ export interface PlanningTables {
13
+ card: TableSpec
14
+ transition: TableSpec
15
+ link: TableSpec
16
+ schema: TableSpec
17
+ }
18
+
19
+ /** A runner and the tables it addresses — what every statement builder takes. */
20
+ export interface SqlContext {
21
+ runner: SqlRunner
22
+ tables: PlanningTables
23
+ }
24
+
25
+ export const poolRunner = (pool: Pool): SqlRunner => ({
26
+ query: async <R extends QueryResultRow = QueryResultRow>(text: string, params?: unknown[]) =>
27
+ (await pool.query<R>(text, params as never[])).rows,
28
+ })
29
+
30
+ export const clientRunner = (client: PoolClient, onError?: (error: unknown) => void): SqlRunner => ({
31
+ query: async <R extends QueryResultRow = QueryResultRow>(text: string, params?: unknown[]) => {
32
+ try {
33
+ return (await client.query<R>(text, params as never[])).rows
34
+ } catch (error) {
35
+ onError?.(error)
36
+ throw error
37
+ }
38
+ },
39
+ })
40
+
41
+ /** A property's physical column, quoted. */
42
+ export const col = (spec: TableSpec, property: string): string => quoteIdent(spec.byProperty[property].column)
43
+
44
+ /** A value as the driver takes it for a column: JSON text for jsonb, `null` for absent. */
45
+ export const driverValue = (column: ColumnSpec, value: unknown): unknown => {
46
+ if (value === undefined || value === null) {
47
+ return null
48
+ }
49
+ // node-postgres would write a JS array as a Postgres array literal, which a jsonb column refuses.
50
+ return column.jsonb ? JSON.stringify(value) : value
51
+ }
52
+
53
+ /**
54
+ * A row back as a record: physical columns to properties with the package's coercions, `null`
55
+ * dropped (the planning records leave an absent value out), private columns removed.
56
+ */
57
+ export const recordOf = <T extends ResourceRecord>(
58
+ row: Record<string, unknown>, spec: TableSpec, hidden: readonly string[] = []
59
+ ): T => {
60
+ const record = rowToRecord<T>(row, spec) as Record<string, unknown>
61
+ for (const [key, value] of Object.entries(record)) {
62
+ if (value === null || hidden.includes(key)) {
63
+ delete record[key]
64
+ }
65
+ }
66
+ return record as T
67
+ }
68
+
69
+ /** A resource read answered with drizzle's property-keyed rows: `null` and private keys dropped. */
70
+ export const cleanRecord = <T extends object>(record: T, hidden: readonly string[] = []): T =>
71
+ Object.fromEntries(Object.entries(record).filter(([key, value]) => value !== null && value !== undefined && !hidden.includes(key))) as T
72
+
73
+ export interface InsertStatement {
74
+ text: string
75
+ params: unknown[]
76
+ }
77
+
78
+ /**
79
+ * `INSERT INTO <table> (<every column but the skipped>) VALUES (…)` for a record, with an optional
80
+ * tail (`ON CONFLICT …`, `RETURNING …`).
81
+ */
82
+ export const insertOf = (
83
+ spec: TableSpec, record: Record<string, unknown>, opts: { skip?: readonly string[], tail?: string, as?: string } = {}
84
+ ): InsertStatement => {
85
+ const columns = spec.columns.filter(column => !(opts.skip ?? []).includes(column.property))
86
+ const params = columns.map(column => driverValue(column, record[column.property]))
87
+
88
+ return {
89
+ text: `INSERT INTO ${spec.qualified}${opts.as != null ? ` AS ${opts.as}` : ''}`
90
+ + ` (${columns.map(column => quoteIdent(column.column)).join(', ')})`
91
+ + ` VALUES (${columns.map((_, index) => `$${index + 1}`).join(', ')})${opts.tail != null ? ` ${opts.tail}` : ''}`,
92
+ params,
93
+ }
94
+ }
95
+
96
+ /**
97
+ * A WHERE over fixed fields: a string is equality, a list is `= ANY`, `undefined` is skipped.
98
+ * Parameters start after `offset`.
99
+ */
100
+ export const whereOf = (
101
+ spec: TableSpec, fields: Record<string, string | readonly string[] | undefined>, offset: number = 0
102
+ ): { text: string, params: unknown[] } => {
103
+ const clauses: string[] = []
104
+ const params: unknown[] = []
105
+ for (const [property, value] of Object.entries(fields)) {
106
+ if (value === undefined) {
107
+ continue
108
+ }
109
+ params.push(Array.isArray(value) ? [...value] : value)
110
+ clauses.push(Array.isArray(value)
111
+ ? `${col(spec, property)} = ANY($${offset + params.length})`
112
+ : `${col(spec, property)} = $${offset + params.length}`)
113
+ }
114
+
115
+ return { text: clauses.length > 0 ? clauses.join(' AND ') : 'TRUE', params }
116
+ }
117
+
118
+ /** The driver error's Postgres code and the constraint (index) it names, through a `cause` chain. */
119
+ export const pgFault = (error: unknown): { code?: string, constraint?: string } => {
120
+ let current: unknown = error
121
+ for (let depth = 0; depth < 5 && current != null && typeof current === 'object'; depth++) {
122
+ const candidate = current as { code?: unknown, constraint?: unknown, cause?: unknown }
123
+ if (typeof candidate.code === 'string' && /^[0-9A-Z]{5}$/.test(candidate.code)) {
124
+ return {
125
+ code: candidate.code,
126
+ ...(typeof candidate.constraint === 'string' ? { constraint: candidate.constraint } : {}),
127
+ }
128
+ }
129
+ current = candidate.cause
130
+ }
131
+ return {}
132
+ }
133
+
134
+ export const UNIQUE_VIOLATION = '23505'
135
+ export const LOCK_NOT_AVAILABLE = '55P03'
@@ -0,0 +1,217 @@
1
+ import type { CommitEvent, Unsubscribe } from '@owlmeans/planning'
2
+ import { advisoryKey, quoteIdent } from '@owlmeans/postgres-resource'
3
+ import { Client } from 'pg'
4
+ import type { ClientConfig, Notification, Pool } from 'pg'
5
+ import { BUS_BACKOFF, NOTIFY_PAYLOAD_MAX } from '../consts.js'
6
+ import type { SqlRunner } from '../sql.js'
7
+
8
+ /** A settled commit, without its record — the card is re-read where one is needed. */
9
+ export interface CommitFrame {
10
+ p: string
11
+ t: 'c'
12
+ e: Omit<CommitEvent, 'record'>
13
+ }
14
+
15
+ /** A write of an organization's data-defined schemas. */
16
+ export interface SchemaFrame {
17
+ p: string
18
+ t: 's'
19
+ e: string
20
+ }
21
+
22
+ export type BusFrame = CommitFrame | SchemaFrame
23
+
24
+ export interface PlanningBus {
25
+ enabled: boolean
26
+ /** NOTIFY a frame inside a transaction — delivered only if it commits. A no-op when disabled. */
27
+ notify: (runner: SqlRunner, frame: Omit<CommitFrame, 'p'> | Omit<SchemaFrame, 'p'>) => Promise<void>
28
+ onCommit: (listener: (event: CommitEvent) => void | Promise<void>) => Unsubscribe
29
+ onSchema: (listener: (entityId: string) => void) => Unsubscribe
30
+ /** Open the LISTEN connection if it is not open (lazily, on the first subscription or wait). */
31
+ ensure: () => void
32
+ connected: () => boolean
33
+ close: () => Promise<void>
34
+ }
35
+
36
+ export interface PlanningBusOptions {
37
+ enabled: boolean
38
+ processId: string
39
+ /** The qualified transition table — what the channel name is derived from. */
40
+ table: () => Promise<string>
41
+ /** The pool whose configuration the dedicated connection copies. */
42
+ pool: () => Promise<Pool>
43
+ }
44
+
45
+ /**
46
+ * The channel of one transition table: `planning_` and a short hash of its qualified name, so two
47
+ * schemas in one database never hear each other.
48
+ */
49
+ export const planningChannel = (qualified: string): string => {
50
+ const [first, second] = advisoryKey(`planning-channel:${qualified}`)
51
+ return `planning_${(first >>> 0).toString(16).padStart(8, '0')}${(second >>> 0).toString(16).padStart(8, '0')}`
52
+ }
53
+
54
+ const payloadOf = (frame: BusFrame): string | null => {
55
+ const text = JSON.stringify(frame)
56
+ if (new TextEncoder().encode(text).length < NOTIFY_PAYLOAD_MAX) {
57
+ return text
58
+ }
59
+ if (frame.t === 'c' && frame.e.error != null) {
60
+ // The reason is in the row; a listener reads it with the status.
61
+ const { error: _error, ...event } = frame.e
62
+ return payloadOf({ ...frame, e: event })
63
+ }
64
+ return null
65
+ }
66
+
67
+ /**
68
+ * The cross-process commit bus over LISTEN/NOTIFY.
69
+ *
70
+ * NOTIFY runs inside the transaction that settled the commit, so a frame is heard only if that
71
+ * commit landed. LISTEN holds ONE dedicated connection — a `pg.Client` built from the pool's own
72
+ * configuration, never a pooled one, so it takes no pool slot — opened on the first subscription
73
+ * or wait. A dropped connection reconnects with backoff (1 s doubling to 30 s); meanwhile the commit
74
+ * hub's poll ladder still answers every waiter. A process ignores its own frames (`p`): it delivered
75
+ * them itself, after its commit.
76
+ */
77
+ export const makePlanningBus = (opts: PlanningBusOptions): PlanningBus => {
78
+ const commitListeners = new Set<(event: CommitEvent) => void | Promise<void>>()
79
+ const schemaListeners = new Set<(entityId: string) => void>()
80
+ let client: Client | undefined
81
+ let connecting: Promise<void> | undefined
82
+ let retry: ReturnType<typeof setTimeout> | undefined
83
+ let backoff = BUS_BACKOFF[0]
84
+ let closed = false
85
+ let live = false
86
+ let channel: string | undefined
87
+
88
+ const channelOf = async (): Promise<string> => channel ??= planningChannel(await opts.table())
89
+
90
+ const dispatch = (message: Notification): void => {
91
+ if (message.channel !== channel || message.payload == null) {
92
+ return
93
+ }
94
+ let frame: BusFrame
95
+ try {
96
+ frame = JSON.parse(message.payload) as BusFrame
97
+ } catch {
98
+ return
99
+ }
100
+ if (frame.p === opts.processId) {
101
+ return
102
+ }
103
+ if (frame.t === 'c') {
104
+ for (const listener of [...commitListeners]) {
105
+ void Promise.resolve().then(() => listener(frame.e as CommitEvent))
106
+ .catch(error => console.error('planning-postgres: commit listener failed:', error))
107
+ }
108
+ return
109
+ }
110
+ if (frame.t === 's') {
111
+ for (const listener of [...schemaListeners]) {
112
+ try {
113
+ listener(frame.e)
114
+ } catch (error) {
115
+ console.error('planning-postgres: schema listener failed:', error)
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ const lost = (target: Client, error?: unknown): void => {
122
+ if (client !== target) {
123
+ return
124
+ }
125
+ client = undefined
126
+ live = false
127
+ void target.end().catch(() => undefined)
128
+ if (closed) {
129
+ return
130
+ }
131
+ if (error != null) {
132
+ console.warn('planning-postgres: commit bus connection lost, reconnecting:', error instanceof Error ? error.message : error)
133
+ }
134
+ retry = setTimeout(() => { retry = undefined; bus.ensure() }, backoff)
135
+ backoff = Math.min(backoff * 2, BUS_BACKOFF[1])
136
+ }
137
+
138
+ const connect = async (): Promise<void> => {
139
+ let pool: Pool
140
+ let listen: string
141
+ try {
142
+ pool = await opts.pool()
143
+ listen = await channelOf()
144
+ } catch {
145
+ // The store cannot reach its tables yet (its calls say why); ask again later.
146
+ if (!closed) {
147
+ retry = setTimeout(() => { retry = undefined; bus.ensure() }, backoff)
148
+ backoff = Math.min(backoff * 2, BUS_BACKOFF[1])
149
+ }
150
+ return
151
+ }
152
+ // The pool's own client class and configuration (the password stays a non-enumerable property
153
+ // of that object, so the object itself is handed over, never a copy).
154
+ const Factory = (pool as unknown as { Client?: typeof Client }).Client ?? Client
155
+ const target = new Factory(pool.options as ClientConfig)
156
+ target.on('error', error => lost(target, error))
157
+ target.on('end', () => lost(target))
158
+ target.on('notification', dispatch)
159
+ client = target
160
+ try {
161
+ await target.connect()
162
+ await target.query(`LISTEN ${quoteIdent(listen)}`)
163
+ live = true
164
+ backoff = BUS_BACKOFF[0]
165
+ } catch (error) {
166
+ lost(target, error)
167
+ }
168
+ }
169
+
170
+ const bus: PlanningBus = {
171
+ enabled: opts.enabled,
172
+
173
+ notify: async (runner, frame) => {
174
+ if (!opts.enabled) {
175
+ return
176
+ }
177
+ const payload = payloadOf({ ...frame, p: opts.processId } as BusFrame)
178
+ if (payload != null) {
179
+ await runner.query('SELECT pg_notify($1, $2)', [await channelOf(), payload])
180
+ }
181
+ },
182
+
183
+ onCommit: listener => {
184
+ commitListeners.add(listener)
185
+ return () => { commitListeners.delete(listener) }
186
+ },
187
+
188
+ onSchema: listener => {
189
+ schemaListeners.add(listener)
190
+ return () => { schemaListeners.delete(listener) }
191
+ },
192
+
193
+ ensure: () => {
194
+ if (!opts.enabled || closed || client != null || connecting != null || retry != null) {
195
+ return
196
+ }
197
+ connecting = connect().finally(() => { connecting = undefined })
198
+ },
199
+
200
+ connected: () => live,
201
+
202
+ close: async () => {
203
+ closed = true
204
+ if (retry != null) {
205
+ clearTimeout(retry)
206
+ retry = undefined
207
+ }
208
+ await connecting?.catch(() => undefined)
209
+ const target = client
210
+ client = undefined
211
+ live = false
212
+ await target?.end().catch(() => undefined)
213
+ },
214
+ }
215
+
216
+ return bus
217
+ }
@@ -0,0 +1,153 @@
1
+ import { IntrinsicStatus, PlanningError, WorkcardKind } from '@owlmeans/planning'
2
+ import type { ProjectionStore, SummaryView, Workcard } from '@owlmeans/planning'
3
+ import { quoteIdent } from '@owlmeans/postgres-resource'
4
+ import type { Criteria } from '@owlmeans/resource'
5
+ import { wantsSpecifications } from '@owlmeans/server-planning/store'
6
+ import { PlanningPostgresError } from '../errors.js'
7
+ import { cleanRecord, col, driverValue, insertOf, recordOf } from '../sql.js'
8
+ import type { SqlContext } from '../sql.js'
9
+ import type { PlanningCardRecord, PlanningCardResource } from '../types.js'
10
+
11
+ /** Columns no caller ever sees. */
12
+ export const CARD_PRIVATE = ['headAt'] as const
13
+
14
+ const and = (where: Criteria<Workcard> | undefined, ...extra: Criteria<Workcard>[]): Criteria<Workcard> => {
15
+ const base = (where ?? {}) as Criteria<Workcard> & { $and?: Criteria<Workcard>[] }
16
+ return { ...base, $and: [...(base.$and ?? []), ...extra] } as Criteria<Workcard>
17
+ }
18
+
19
+ const NOT_SPECIFICATION = { kind: { $ne: WorkcardKind.Specification } } as Criteria<Workcard>
20
+
21
+ /** A card list names specifications only when it asks for them — the rule every store keeps. */
22
+ export const routedCriteria = (where?: Criteria<Workcard>): Criteria<Workcard> =>
23
+ wantsSpecifications(where) ? (where ?? {}) as Criteria<Workcard> : and(where, NOT_SPECIFICATION)
24
+
25
+ /** A card row with its private bookkeeping (what the fold reads), or `null`. */
26
+ export const readCardRow = async (sql: SqlContext, id: string, entityId?: string): Promise<PlanningCardRecord | null> => {
27
+ const spec = sql.tables.card
28
+ const rows = await sql.runner.query(
29
+ `SELECT * FROM ${spec.qualified} WHERE ${col(spec, 'id')} = $1${entityId != null ? ` AND ${col(spec, 'entityId')} = $2` : ''}`,
30
+ entityId != null ? [id, entityId] : [id]
31
+ )
32
+ return rows[0] == null ? null : recordOf<PlanningCardRecord>(rows[0], spec)
33
+ }
34
+
35
+ export const readCard = async (sql: SqlContext, id: string, entityId: string): Promise<Workcard | null> => {
36
+ const row = await readCardRow(sql, id, entityId)
37
+ return row == null ? null : cleanRecord(row, CARD_PRIVATE)
38
+ }
39
+
40
+ /**
41
+ * Write a folded card whole. `head` is never lowered — an allocation made while the fold ran stays
42
+ * — and `headAt` is never touched.
43
+ *
44
+ * A create (`seq` 1) is an upsert. Anything later is an UPDATE of a row that must still exist: a
45
+ * card purged with its project while one of its own transitions was folding is not resurrected.
46
+ *
47
+ * @throws {PlanningPostgresError} `fold:card-vanished:<id>`
48
+ */
49
+ export const writeCard = async (sql: SqlContext, card: Workcard): Promise<void> => {
50
+ const spec = sql.tables.card
51
+ if (card.id == null) {
52
+ throw new PlanningPostgresError('malformed:put-without-id')
53
+ }
54
+ const record = { ...card, head: Math.max(card.head ?? card.seq, card.seq) } as Record<string, unknown>
55
+ const head = col(spec, 'head')
56
+ const seq = col(spec, 'seq')
57
+ const assigned = spec.columns
58
+ .filter(column => !['id', 'headAt', 'head'].includes(column.property))
59
+ .map(column => column.column)
60
+
61
+ if (card.seq <= 1) {
62
+ const insert = insertOf(spec, record, {
63
+ skip: CARD_PRIVATE,
64
+ as: 'existing',
65
+ tail: `ON CONFLICT (${col(spec, 'id')}) DO UPDATE SET `
66
+ + [...assigned.map(name => `${quoteIdent(name)} = EXCLUDED.${quoteIdent(name)}`),
67
+ `${head} = GREATEST(COALESCE(existing.${head}, existing.${seq}), EXCLUDED.${head})`].join(', '),
68
+ })
69
+ await sql.runner.query(insert.text, insert.params)
70
+ return
71
+ }
72
+
73
+ const columns = spec.columns.filter(column => assigned.includes(column.column))
74
+ const params = [card.id, ...columns.map(column => driverValue(column, record[column.property])), record.head]
75
+ const updated = await sql.runner.query(
76
+ `UPDATE ${spec.qualified} SET ${columns.map((column, index) => `${quoteIdent(column.column)} = $${index + 2}`).join(', ')},`
77
+ + ` ${head} = GREATEST(COALESCE(${head}, ${seq}), $${columns.length + 2}) WHERE ${col(spec, 'id')} = $1 RETURNING ${col(spec, 'id')}`,
78
+ params
79
+ )
80
+ if (updated.length === 0) {
81
+ throw new PlanningPostgresError(`fold:card-vanished:${card.id}`)
82
+ }
83
+ }
84
+
85
+ export const dropCard = async (sql: SqlContext, id: string, entityId: string): Promise<void> => {
86
+ const spec = sql.tables.card
87
+ await sql.runner.query(
88
+ `DELETE FROM ${spec.qualified} WHERE ${col(spec, 'id')} = $1 AND ${col(spec, 'entityId')} = $2`, [id, entityId]
89
+ )
90
+ }
91
+
92
+ const emptyCounts = () => ({
93
+ total: 0, [IntrinsicStatus.Planned]: 0, [IntrinsicStatus.InProgress]: 0, [IntrinsicStatus.Closed]: 0,
94
+ })
95
+
96
+ export interface CardPortDeps {
97
+ sql: () => Promise<SqlContext>
98
+ resource: () => PlanningCardResource
99
+ project: (card: string) => Promise<void>
100
+ purge: (project: string, entityId: string) => Promise<number>
101
+ }
102
+
103
+ /**
104
+ * The projection port. Point reads and writes are this package's own statements; lists, counts and
105
+ * summaries go through the resource, so a criteria means here what it means everywhere
106
+ * (`criteriaToSql`).
107
+ */
108
+ export const makeCardPort = (deps: CardPortDeps): ProjectionStore => ({
109
+ get: async (id, entityId) => await readCard(await deps.sql(), id, entityId),
110
+
111
+ list: async (where, opts) => {
112
+ await deps.sql()
113
+ const listed = await deps.resource().list(routedCriteria(where) as Criteria<PlanningCardRecord>, opts as never)
114
+ return { ...listed, items: listed.items.map(item => cleanRecord(item, CARD_PRIVATE) as Workcard) }
115
+ },
116
+
117
+ count: async where => {
118
+ await deps.sql()
119
+ return await deps.resource().count(routedCriteria(where) as Criteria<PlanningCardRecord>)
120
+ },
121
+
122
+ summary: async (parents, where) => {
123
+ if (parents.length === 0) {
124
+ return {}
125
+ }
126
+ await deps.sql()
127
+ const rows = await deps.resource().countBy(
128
+ and(where, NOT_SPECIFICATION, { parent: parents } as Criteria<Workcard>) as Criteria<PlanningCardRecord>,
129
+ ['parent', 'intrinsic']
130
+ )
131
+ return rows.reduce<SummaryView>((view, row) => {
132
+ const parent = row.parent as string
133
+ const counts = view[parent] ?? emptyCounts()
134
+ counts.total += row.count
135
+ counts[row.intrinsic as IntrinsicStatus] = (counts[row.intrinsic as IntrinsicStatus] ?? 0) + row.count
136
+ view[parent] = counts
137
+ return view
138
+ }, {})
139
+ },
140
+
141
+ put: async card => { await writeCard(await deps.sql(), card) },
142
+
143
+ drop: async (id, entityId) => { await dropCard(await deps.sql(), id, entityId) },
144
+
145
+ project: async card => { await deps.project(card) },
146
+
147
+ purge: async (project, entityId) => {
148
+ if (project == null || project === '') {
149
+ throw new PlanningError('purge:empty')
150
+ }
151
+ return await deps.purge(project, entityId)
152
+ },
153
+ })