@shipfox/api-definitions 12.3.0 → 14.0.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 (223) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +45 -0
  3. package/dist/core/entities/index.d.ts +1 -1
  4. package/dist/core/entities/index.d.ts.map +1 -1
  5. package/dist/core/entities/index.js +1 -1
  6. package/dist/core/entities/index.js.map +1 -1
  7. package/dist/core/entities/integration-context.d.ts +4 -0
  8. package/dist/core/entities/integration-context.d.ts.map +1 -1
  9. package/dist/core/entities/integration-context.js.map +1 -1
  10. package/dist/core/entities/sync-state.d.ts +10 -4
  11. package/dist/core/entities/sync-state.d.ts.map +1 -1
  12. package/dist/core/entities/sync-state.js +26 -7
  13. package/dist/core/entities/sync-state.js.map +1 -1
  14. package/dist/core/entities/validation-diagnostic.d.ts +8 -0
  15. package/dist/core/entities/validation-diagnostic.d.ts.map +1 -0
  16. package/dist/core/entities/validation-diagnostic.js +3 -0
  17. package/dist/core/entities/validation-diagnostic.js.map +1 -0
  18. package/dist/core/entities/workflow-definition.d.ts +2 -0
  19. package/dist/core/entities/workflow-definition.d.ts.map +1 -1
  20. package/dist/core/entities/workflow-definition.js.map +1 -1
  21. package/dist/core/errors.d.ts +10 -0
  22. package/dist/core/errors.d.ts.map +1 -1
  23. package/dist/core/errors.js +9 -0
  24. package/dist/core/errors.js.map +1 -1
  25. package/dist/core/index.d.ts +2 -1
  26. package/dist/core/index.d.ts.map +1 -1
  27. package/dist/core/index.js +2 -1
  28. package/dist/core/index.js.map +1 -1
  29. package/dist/core/integrations.d.ts +3 -1
  30. package/dist/core/integrations.d.ts.map +1 -1
  31. package/dist/core/integrations.js +10 -2
  32. package/dist/core/integrations.js.map +1 -1
  33. package/dist/core/parse-definition.d.ts +4 -4
  34. package/dist/core/parse-definition.d.ts.map +1 -1
  35. package/dist/core/parse-definition.js +5 -5
  36. package/dist/core/parse-definition.js.map +1 -1
  37. package/dist/core/resolve-definition-at-ref.d.ts +66 -0
  38. package/dist/core/resolve-definition-at-ref.d.ts.map +1 -0
  39. package/dist/core/resolve-definition-at-ref.js +374 -0
  40. package/dist/core/resolve-definition-at-ref.js.map +1 -0
  41. package/dist/core/sync-definitions.d.ts +2 -2
  42. package/dist/core/sync-definitions.d.ts.map +1 -1
  43. package/dist/core/sync-definitions.js +5 -5
  44. package/dist/core/sync-definitions.js.map +1 -1
  45. package/dist/core/validate-definition.d.ts +3 -3
  46. package/dist/core/validate-definition.d.ts.map +1 -1
  47. package/dist/core/validate-definition.js +17 -12
  48. package/dist/core/validate-definition.js.map +1 -1
  49. package/dist/core/workflow-model/cron-trigger.d.ts +1 -1
  50. package/dist/core/workflow-model/cron-trigger.d.ts.map +1 -1
  51. package/dist/core/workflow-model/cron-trigger.js +9 -5
  52. package/dist/core/workflow-model/cron-trigger.js.map +1 -1
  53. package/dist/core/workflow-model/index.d.ts +1 -1
  54. package/dist/core/workflow-model/index.d.ts.map +1 -1
  55. package/dist/core/workflow-model/index.js.map +1 -1
  56. package/dist/core/workflow-model/invalid-workflow-model-error.d.ts +4 -2
  57. package/dist/core/workflow-model/invalid-workflow-model-error.d.ts.map +1 -1
  58. package/dist/core/workflow-model/invalid-workflow-model-error.js.map +1 -1
  59. package/dist/core/workflow-model/normalize-job-listening.d.ts.map +1 -1
  60. package/dist/core/workflow-model/normalize-job-listening.js +51 -30
  61. package/dist/core/workflow-model/normalize-job-listening.js.map +1 -1
  62. package/dist/core/workflow-model/normalize-triggers.d.ts +1 -1
  63. package/dist/core/workflow-model/normalize-triggers.d.ts.map +1 -1
  64. package/dist/core/workflow-model/normalize-triggers.js +48 -18
  65. package/dist/core/workflow-model/normalize-triggers.js.map +1 -1
  66. package/dist/core/workflow-model/normalize-workflow-document.d.ts +1 -1
  67. package/dist/core/workflow-model/normalize-workflow-document.d.ts.map +1 -1
  68. package/dist/core/workflow-model/normalize-workflow-document.js +3 -3
  69. package/dist/core/workflow-model/normalize-workflow-document.js.map +1 -1
  70. package/dist/core/workflow-model/parse-interpolation-field.d.ts.map +1 -1
  71. package/dist/core/workflow-model/parse-interpolation-field.js +1 -15
  72. package/dist/core/workflow-model/parse-interpolation-field.js.map +1 -1
  73. package/dist/core/workflow-model/validate-predicate-expression.d.ts +3 -1
  74. package/dist/core/workflow-model/validate-predicate-expression.d.ts.map +1 -1
  75. package/dist/core/workflow-model/validate-predicate-expression.js +3 -0
  76. package/dist/core/workflow-model/validate-predicate-expression.js.map +1 -1
  77. package/dist/core/workflow-model/validation-issue.d.ts +2 -1
  78. package/dist/core/workflow-model/validation-issue.d.ts.map +1 -1
  79. package/dist/core/workflow-model/validation-issue.js +12 -15
  80. package/dist/core/workflow-model/validation-issue.js.map +1 -1
  81. package/dist/db/db.d.ts +196 -4
  82. package/dist/db/db.d.ts.map +1 -1
  83. package/dist/db/db.js +3 -1
  84. package/dist/db/db.js.map +1 -1
  85. package/dist/db/definition-triggers.js +3 -1
  86. package/dist/db/definition-triggers.js.map +1 -1
  87. package/dist/db/definitions.d.ts +17 -0
  88. package/dist/db/definitions.d.ts.map +1 -1
  89. package/dist/db/definitions.js +190 -23
  90. package/dist/db/definitions.js.map +1 -1
  91. package/dist/db/index.d.ts +2 -1
  92. package/dist/db/index.d.ts.map +1 -1
  93. package/dist/db/index.js +2 -1
  94. package/dist/db/index.js.map +1 -1
  95. package/dist/db/schema/definitions.d.ts +17 -0
  96. package/dist/db/schema/definitions.d.ts.map +1 -1
  97. package/dist/db/schema/definitions.js +8 -0
  98. package/dist/db/schema/definitions.js.map +1 -1
  99. package/dist/db/schema/sync-states.d.ts +7 -3
  100. package/dist/db/schema/sync-states.d.ts.map +1 -1
  101. package/dist/db/schema/sync-states.js +5 -1
  102. package/dist/db/schema/sync-states.js.map +1 -1
  103. package/dist/db/schema/workflows.d.ts +78 -0
  104. package/dist/db/schema/workflows.d.ts.map +1 -0
  105. package/dist/db/schema/workflows.js +17 -0
  106. package/dist/db/schema/workflows.js.map +1 -0
  107. package/dist/db/sync-states.d.ts +2 -2
  108. package/dist/db/sync-states.d.ts.map +1 -1
  109. package/dist/db/sync-states.js +4 -4
  110. package/dist/db/sync-states.js.map +1 -1
  111. package/dist/index.d.ts.map +1 -1
  112. package/dist/index.js +5 -1
  113. package/dist/index.js.map +1 -1
  114. package/dist/metrics/index.d.ts +2 -0
  115. package/dist/metrics/index.d.ts.map +1 -0
  116. package/dist/metrics/index.js +3 -0
  117. package/dist/metrics/index.js.map +1 -0
  118. package/dist/metrics/instance.d.ts +4 -0
  119. package/dist/metrics/instance.d.ts.map +1 -0
  120. package/dist/metrics/instance.js +19 -0
  121. package/dist/metrics/instance.js.map +1 -0
  122. package/dist/presentation/dto/definition.d.ts.map +1 -1
  123. package/dist/presentation/dto/definition.js +11 -4
  124. package/dist/presentation/dto/definition.js.map +1 -1
  125. package/dist/presentation/inter-module.d.ts +9 -1
  126. package/dist/presentation/inter-module.d.ts.map +1 -1
  127. package/dist/presentation/inter-module.js +66 -2
  128. package/dist/presentation/inter-module.js.map +1 -1
  129. package/dist/presentation/routes/create-definition.d.ts.map +1 -1
  130. package/dist/presentation/routes/create-definition.js +16 -3
  131. package/dist/presentation/routes/create-definition.js.map +1 -1
  132. package/dist/presentation/routes/get-definition.d.ts.map +1 -1
  133. package/dist/presentation/routes/get-definition.js +18 -4
  134. package/dist/presentation/routes/get-definition.js.map +1 -1
  135. package/dist/presentation/routes/project-access.d.ts +1 -0
  136. package/dist/presentation/routes/project-access.d.ts.map +1 -1
  137. package/dist/presentation/routes/validate-definition.js +3 -3
  138. package/dist/presentation/routes/validate-definition.js.map +1 -1
  139. package/dist/temporal/activities/sync-activities.d.ts +3 -3
  140. package/dist/temporal/activities/sync-activities.d.ts.map +1 -1
  141. package/dist/temporal/activities/sync-activities.js +7 -4
  142. package/dist/temporal/activities/sync-activities.js.map +1 -1
  143. package/dist/temporal/workflows/definition-sync.js +1 -1
  144. package/dist/temporal/workflows/definition-sync.js.map +1 -1
  145. package/dist/temporal/workflows/index.bundle.js +50 -22
  146. package/dist/tsconfig.test.tsbuildinfo +1 -1
  147. package/drizzle/0003_nebulous_nemesis.sql +11 -0
  148. package/drizzle/0004_workflow_ref_lookup.sql +1 -0
  149. package/drizzle/meta/0003_snapshot.json +627 -0
  150. package/drizzle/meta/0004_snapshot.json +649 -0
  151. package/drizzle/meta/_journal.json +14 -0
  152. package/drizzle.config.ts +1 -0
  153. package/package.json +7 -7
  154. package/src/core/entities/index.ts +1 -1
  155. package/src/core/entities/integration-context.ts +4 -0
  156. package/src/core/entities/sync-state.test.ts +49 -0
  157. package/src/core/entities/sync-state.ts +35 -12
  158. package/src/core/entities/validation-diagnostic.ts +8 -0
  159. package/src/core/entities/workflow-definition.ts +2 -0
  160. package/src/core/errors.ts +26 -0
  161. package/src/core/index.ts +12 -0
  162. package/src/core/integrations.test.ts +38 -0
  163. package/src/core/integrations.ts +10 -1
  164. package/src/core/parse-definition-diagnostics.test.ts +47 -0
  165. package/src/core/parse-definition.test.ts +29 -5
  166. package/src/core/parse-definition.ts +7 -7
  167. package/src/core/resolve-definition-at-ref.test.ts +792 -0
  168. package/src/core/resolve-definition-at-ref.ts +551 -0
  169. package/src/core/sync-definitions.test.ts +61 -2
  170. package/src/core/sync-definitions.ts +7 -7
  171. package/src/core/validate-definition.test.ts +31 -22
  172. package/src/core/validate-definition.ts +21 -13
  173. package/src/core/workflow-model/cron-trigger.ts +6 -2
  174. package/src/core/workflow-model/index.ts +1 -0
  175. package/src/core/workflow-model/invalid-workflow-model-error.ts +5 -1
  176. package/src/core/workflow-model/normalize-job-listening.ts +55 -23
  177. package/src/core/workflow-model/normalize-triggers.ts +44 -15
  178. package/src/core/workflow-model/normalize-workflow-document.test.ts +494 -36
  179. package/src/core/workflow-model/normalize-workflow-document.ts +8 -4
  180. package/src/core/workflow-model/parse-interpolation-field.ts +1 -23
  181. package/src/core/workflow-model/validate-predicate-expression.ts +6 -0
  182. package/src/core/workflow-model/validation-issue.ts +8 -13
  183. package/src/db/db.ts +7 -1
  184. package/src/db/definition-triggers.test.ts +44 -0
  185. package/src/db/definition-triggers.ts +1 -1
  186. package/src/db/definitions.test.ts +300 -1
  187. package/src/db/definitions.ts +282 -28
  188. package/src/db/index.ts +2 -0
  189. package/src/db/schema/definitions.test.ts +1 -0
  190. package/src/db/schema/definitions.ts +11 -0
  191. package/src/db/schema/sync-states.ts +12 -3
  192. package/src/db/schema/workflows.ts +23 -0
  193. package/src/db/sync-states.test.ts +87 -18
  194. package/src/db/sync-states.ts +6 -6
  195. package/src/db/workflow-lineage-migration.test.ts +158 -0
  196. package/src/index.ts +3 -1
  197. package/src/metrics/index.ts +1 -0
  198. package/src/metrics/instance.test.ts +50 -0
  199. package/src/metrics/instance.ts +23 -0
  200. package/src/presentation/dto/definition.test.ts +89 -1
  201. package/src/presentation/dto/definition.ts +8 -5
  202. package/src/presentation/inter-module.test.ts +177 -0
  203. package/src/presentation/inter-module.ts +77 -4
  204. package/src/presentation/routes/create-definition.test.ts +38 -0
  205. package/src/presentation/routes/create-definition.ts +20 -3
  206. package/src/presentation/routes/get-definition.test.ts +125 -11
  207. package/src/presentation/routes/get-definition.ts +21 -4
  208. package/src/presentation/routes/list-definitions.test.ts +1 -1
  209. package/src/presentation/routes/validate-definition.test.ts +1 -1
  210. package/src/presentation/routes/validate-definition.ts +3 -3
  211. package/src/presentation/subscribers/on-integration-connection-available.test.ts +3 -0
  212. package/src/temporal/activities/sync-activities.test.ts +46 -2
  213. package/src/temporal/activities/sync-activities.ts +9 -7
  214. package/src/temporal/workflows/definition-sync.ts +1 -1
  215. package/test/factories/definition.ts +1 -0
  216. package/test/globalSetup.ts +2 -0
  217. package/tsconfig.build.tsbuildinfo +1 -1
  218. package/dist/core/entities/validation-warning.d.ts +0 -6
  219. package/dist/core/entities/validation-warning.d.ts.map +0 -1
  220. package/dist/core/entities/validation-warning.js +0 -3
  221. package/dist/core/entities/validation-warning.js.map +0 -1
  222. package/src/core/entities/validation-warning.ts +0 -5
  223. package/src/core/parse-definition-warnings.test.ts +0 -33
@@ -5,7 +5,19 @@ import {
5
5
  } from '@shipfox/api-definitions-dto';
6
6
  import {writeOutboxEvent} from '@shipfox/node-outbox';
7
7
  import type {WorkflowDocument} from '@shipfox/workflow-document';
8
- import {and, asc, eq, gt, isNull, notInArray, or, type SQL, sql} from 'drizzle-orm';
8
+ import {
9
+ and,
10
+ asc,
11
+ eq,
12
+ gt,
13
+ inArray,
14
+ isNotNull,
15
+ isNull,
16
+ notInArray,
17
+ or,
18
+ type SQL,
19
+ sql,
20
+ } from 'drizzle-orm';
9
21
  import type {
10
22
  WorkflowDefinition,
11
23
  WorkflowDefinitionPayload,
@@ -14,8 +26,9 @@ import type {
14
26
  import type {WorkflowModel} from '#core/entities/workflow-model.js';
15
27
  import {db} from './db.js';
16
28
  import {definitionTriggersFor} from './definition-triggers.js';
17
- import {toDefinition, workflowDefinitions} from './schema/definitions.js';
29
+ import {type DefinitionDb, toDefinition, workflowDefinitions} from './schema/definitions.js';
18
30
  import {definitionsOutbox} from './schema/outbox.js';
31
+ import {workflowWorkflows} from './schema/workflows.js';
19
32
 
20
33
  type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
21
34
 
@@ -33,7 +46,155 @@ export interface UpsertDefinitionParams {
33
46
  ref?: string | undefined;
34
47
  }
35
48
 
36
- function buildUpsertQuery(tx: Tx, params: UpsertDefinitionParams) {
49
+ /**
50
+ * Finds or creates the workflow lineage for (projectId, configPath) and returns
51
+ * its id. Pathless manual definitions share a project-scoped lineage because
52
+ * their request has no more specific stable key.
53
+ */
54
+ async function findOrCreateWorkflow(
55
+ tx: Tx,
56
+ params: {projectId: string; configPath: string | null},
57
+ ): Promise<string> {
58
+ if (params.configPath === null) {
59
+ // Serialize the first pathless definition for a project so concurrent saves
60
+ // cannot create multiple project-scoped lineages.
61
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${params.projectId}, 0))`);
62
+ const existing = await tx
63
+ .select({workflowId: workflowDefinitions.workflowId})
64
+ .from(workflowDefinitions)
65
+ .where(
66
+ and(
67
+ eq(workflowDefinitions.projectId, params.projectId),
68
+ isNull(workflowDefinitions.configPath),
69
+ eq(workflowDefinitions.source, 'manual'),
70
+ isNotNull(workflowDefinitions.workflowId),
71
+ ),
72
+ )
73
+ .orderBy(asc(workflowDefinitions.createdAt), asc(workflowDefinitions.id))
74
+ .limit(1);
75
+ const existingRow = existing[0];
76
+ if (existingRow?.workflowId) return existingRow.workflowId;
77
+
78
+ const inserted = await tx
79
+ .insert(workflowWorkflows)
80
+ .values({projectId: params.projectId, configPath: null})
81
+ .onConflictDoNothing()
82
+ .returning({id: workflowWorkflows.id});
83
+ const insertedRow = inserted[0];
84
+ if (insertedRow) return insertedRow.id;
85
+
86
+ const existingLineage = await tx
87
+ .select({id: workflowWorkflows.id})
88
+ .from(workflowWorkflows)
89
+ .where(
90
+ and(
91
+ eq(workflowWorkflows.projectId, params.projectId),
92
+ isNull(workflowWorkflows.configPath),
93
+ ),
94
+ )
95
+ .limit(1);
96
+ const existingLineageRow = existingLineage[0];
97
+ if (!existingLineageRow) {
98
+ throw new Error(`Pathless workflow lineage missing for project ${params.projectId}`);
99
+ }
100
+ return existingLineageRow.id;
101
+ }
102
+
103
+ const inserted = await tx
104
+ .insert(workflowWorkflows)
105
+ .values({projectId: params.projectId, configPath: params.configPath})
106
+ .onConflictDoNothing()
107
+ .returning({id: workflowWorkflows.id});
108
+ const insertedRow = inserted[0];
109
+ if (insertedRow) return insertedRow.id;
110
+
111
+ const existing = await tx
112
+ .select({id: workflowWorkflows.id})
113
+ .from(workflowWorkflows)
114
+ .where(
115
+ and(
116
+ eq(workflowWorkflows.projectId, params.projectId),
117
+ eq(workflowWorkflows.configPath, params.configPath),
118
+ ),
119
+ )
120
+ .limit(1);
121
+ const existingRow = existing[0];
122
+ if (!existingRow) {
123
+ throw new Error(
124
+ `Workflow lineage upsert returned no row for project ${params.projectId} and config path ${params.configPath}`,
125
+ );
126
+ }
127
+ return existingRow.id;
128
+ }
129
+
130
+ async function findOrCreateWorkflows(
131
+ tx: Tx,
132
+ params: {projectId: string; configPaths: string[]},
133
+ ): Promise<Map<string, string>> {
134
+ const configPaths = [...new Set(params.configPaths)].sort((left, right) =>
135
+ left < right ? -1 : left > right ? 1 : 0,
136
+ );
137
+ if (configPaths.length === 0) return new Map();
138
+
139
+ await tx
140
+ .insert(workflowWorkflows)
141
+ .values(configPaths.map((configPath) => ({projectId: params.projectId, configPath})))
142
+ .onConflictDoNothing();
143
+
144
+ const rows = await tx
145
+ .select({id: workflowWorkflows.id, configPath: workflowWorkflows.configPath})
146
+ .from(workflowWorkflows)
147
+ .where(
148
+ and(
149
+ eq(workflowWorkflows.projectId, params.projectId),
150
+ inArray(workflowWorkflows.configPath, configPaths),
151
+ ),
152
+ );
153
+ const workflowIds = new Map<string, string>();
154
+ for (const row of rows) {
155
+ if (row.configPath !== null) workflowIds.set(row.configPath, row.id);
156
+ }
157
+ for (const configPath of configPaths) {
158
+ if (!workflowIds.has(configPath)) {
159
+ throw new Error(
160
+ `Workflow lineage upsert returned no row for project ${params.projectId} and config path ${configPath}`,
161
+ );
162
+ }
163
+ }
164
+ return workflowIds;
165
+ }
166
+
167
+ async function ensureWorkflowId(
168
+ tx: Tx,
169
+ row: Pick<DefinitionDb, 'id' | 'projectId' | 'configPath' | 'workflowId'>,
170
+ ): Promise<string> {
171
+ if (row.workflowId !== null) return row.workflowId;
172
+
173
+ const workflowId = await findOrCreateWorkflow(tx, {
174
+ projectId: row.projectId,
175
+ configPath: row.configPath,
176
+ });
177
+ const updated = await tx
178
+ .update(workflowDefinitions)
179
+ .set({workflowId})
180
+ .where(and(eq(workflowDefinitions.id, row.id), isNull(workflowDefinitions.workflowId)))
181
+ .returning({workflowId: workflowDefinitions.workflowId});
182
+ const updatedRow = updated[0];
183
+ if (updatedRow?.workflowId) return updatedRow.workflowId;
184
+
185
+ const current = await tx
186
+ .select({workflowId: workflowDefinitions.workflowId})
187
+ .from(workflowDefinitions)
188
+ .where(eq(workflowDefinitions.id, row.id))
189
+ .limit(1);
190
+ const currentRow = current[0];
191
+ if (!currentRow?.workflowId) {
192
+ throw new Error(`Definition ${row.id} still has no workflow lineage after reconciliation`);
193
+ }
194
+ return currentRow.workflowId;
195
+ }
196
+
197
+ function buildUpsertQuery(tx: Tx, params: UpsertDefinitionParams & {workflowId: string}) {
37
198
  const source = params.source ?? 'manual';
38
199
  if (source === 'vcs' && !params.configPath) {
39
200
  throw new Error('configPath is required for VCS definitions');
@@ -54,6 +215,7 @@ function buildUpsertQuery(tx: Tx, params: UpsertDefinitionParams) {
54
215
  };
55
216
 
56
217
  const set = {
218
+ workflowId: params.workflowId,
57
219
  name: params.name,
58
220
  source,
59
221
  definition,
@@ -65,6 +227,7 @@ function buildUpsertQuery(tx: Tx, params: UpsertDefinitionParams) {
65
227
 
66
228
  const values = {
67
229
  projectId: params.projectId,
230
+ workflowId: params.workflowId,
68
231
  configPath: params.configPath ?? null,
69
232
  source,
70
233
  sha: params.sha ?? null,
@@ -121,7 +284,11 @@ export async function upsertDefinition(
121
284
  params: UpsertDefinitionParams,
122
285
  ): Promise<WorkflowDefinition> {
123
286
  return await db().transaction(async (tx) => {
124
- const rows = await buildUpsertQuery(tx, params);
287
+ const workflowId = await findOrCreateWorkflow(tx, {
288
+ projectId: params.projectId,
289
+ configPath: params.configPath ?? null,
290
+ });
291
+ const rows = await buildUpsertQuery(tx, {...params, workflowId});
125
292
  const row = rows[0];
126
293
  if (!row) throw new Error('Upsert returned no rows');
127
294
 
@@ -140,16 +307,68 @@ export async function upsertDefinition(
140
307
  });
141
308
  }
142
309
 
310
+ /**
311
+ * Finds or creates the workflow lineage for (projectId, configPath) outside a
312
+ * sync. A dev definition uses the lineage for run numbering without ever
313
+ * persisting a definition row or publishing a resolution event.
314
+ */
315
+ export async function findOrCreateWorkflowLineage(params: {
316
+ projectId: string;
317
+ configPath: string;
318
+ }): Promise<string> {
319
+ return await db().transaction(async (tx) => {
320
+ return await findOrCreateWorkflow(tx, params);
321
+ });
322
+ }
323
+
143
324
  export async function getDefinitionById(id: string): Promise<WorkflowDefinition | undefined> {
325
+ return await db().transaction(async (tx) => {
326
+ const rows = await tx
327
+ .select()
328
+ .from(workflowDefinitions)
329
+ .where(and(eq(workflowDefinitions.id, id), isNull(workflowDefinitions.deletedAt)))
330
+ .limit(1);
331
+ const row = rows[0];
332
+
333
+ if (row) {
334
+ const workflowId = await ensureWorkflowId(tx, row);
335
+ return toDefinition({...row, workflowId});
336
+ }
337
+
338
+ return undefined;
339
+ });
340
+ }
341
+
342
+ export async function getWorkflowLineageById(
343
+ id: string,
344
+ ): Promise<{id: string; projectId: string} | undefined> {
345
+ const rows = await db()
346
+ .select({id: workflowWorkflows.id, projectId: workflowWorkflows.projectId})
347
+ .from(workflowWorkflows)
348
+ .where(eq(workflowWorkflows.id, id))
349
+ .limit(1);
350
+
351
+ return rows[0];
352
+ }
353
+
354
+ export async function getDefinitionByWorkflowId(params: {
355
+ workflowId: string;
356
+ ref: string;
357
+ }): Promise<WorkflowDefinition | undefined> {
144
358
  const rows = await db()
145
359
  .select()
146
360
  .from(workflowDefinitions)
147
- .where(and(eq(workflowDefinitions.id, id), isNull(workflowDefinitions.deletedAt)))
361
+ .where(
362
+ and(
363
+ eq(workflowDefinitions.workflowId, params.workflowId),
364
+ eq(workflowDefinitions.ref, params.ref),
365
+ isNull(workflowDefinitions.deletedAt),
366
+ ),
367
+ )
148
368
  .limit(1);
149
369
  const row = rows[0];
150
370
 
151
- if (!row) return undefined;
152
- return toDefinition(row);
371
+ return row ? toDefinition({...row, workflowId: params.workflowId}) : undefined;
153
372
  }
154
373
 
155
374
  export interface DefinitionCursor {
@@ -179,28 +398,35 @@ function cursorWhere(cursor: DefinitionCursor | undefined): SQL | undefined {
179
398
  export async function listDefinitions(
180
399
  params: ListDefinitionsParams,
181
400
  ): Promise<ListDefinitionsResult> {
182
- const conditions = [
183
- eq(workflowDefinitions.projectId, params.projectId),
184
- isNull(workflowDefinitions.deletedAt),
185
- ];
186
- const cursorCondition = cursorWhere(params.cursor);
187
- if (cursorCondition) conditions.push(cursorCondition);
188
-
189
- const rows = await db()
190
- .select()
191
- .from(workflowDefinitions)
192
- .where(and(...conditions))
193
- .orderBy(asc(workflowDefinitions.name), asc(workflowDefinitions.id))
194
- .limit(params.limit + 1);
195
-
196
- const hasMore = rows.length > params.limit;
197
- const pageRows = hasMore ? rows.slice(0, params.limit) : rows;
198
- const last = pageRows.at(-1);
401
+ return await db().transaction(async (tx) => {
402
+ const conditions = [
403
+ eq(workflowDefinitions.projectId, params.projectId),
404
+ isNull(workflowDefinitions.deletedAt),
405
+ ];
406
+ const cursorCondition = cursorWhere(params.cursor);
407
+ if (cursorCondition) conditions.push(cursorCondition);
408
+
409
+ const rows = await tx
410
+ .select()
411
+ .from(workflowDefinitions)
412
+ .where(and(...conditions))
413
+ .orderBy(asc(workflowDefinitions.name), asc(workflowDefinitions.id))
414
+ .limit(params.limit + 1);
415
+
416
+ const hasMore = rows.length > params.limit;
417
+ const pageRows = hasMore ? rows.slice(0, params.limit) : rows;
418
+ const last = pageRows.at(-1);
419
+ const definitions: WorkflowDefinition[] = [];
420
+ for (const row of pageRows) {
421
+ const workflowId = await ensureWorkflowId(tx, row);
422
+ definitions.push(toDefinition({...row, workflowId}));
423
+ }
199
424
 
200
- return {
201
- definitions: pageRows.map(toDefinition),
202
- nextCursor: hasMore && last ? {value: last.name, id: last.id} : null,
203
- };
425
+ return {
426
+ definitions,
427
+ nextCursor: hasMore && last ? {value: last.name, id: last.id} : null,
428
+ };
429
+ });
204
430
  }
205
431
 
206
432
  export async function listDefinitionsByProject(projectId: string): Promise<WorkflowDefinition[]> {
@@ -281,11 +507,17 @@ export async function applyVcsDefinitionsBatch(
281
507
  ): Promise<ApplyVcsDefinitionsBatchResult> {
282
508
  return await db().transaction(async (tx) => {
283
509
  let appliedCount = 0;
510
+ const prepared: Array<{
511
+ item: (typeof params.upserts)[number];
512
+ unchanged: boolean;
513
+ workflowId: string | null | undefined;
514
+ }> = [];
284
515
  for (const item of params.upserts) {
285
516
  const existing = await tx
286
517
  .select({
287
518
  contentHash: workflowDefinitions.contentHash,
288
519
  deletedAt: workflowDefinitions.deletedAt,
520
+ workflowId: workflowDefinitions.workflowId,
289
521
  })
290
522
  .from(workflowDefinitions)
291
523
  .where(
@@ -303,9 +535,31 @@ export async function applyVcsDefinitionsBatch(
303
535
  previous.deletedAt === null &&
304
536
  previous.contentHash === item.contentHash;
305
537
 
538
+ prepared.push({item, unchanged, workflowId: previous?.workflowId});
539
+ }
540
+
541
+ const workflowIds = await findOrCreateWorkflows(tx, {
542
+ projectId: params.projectId,
543
+ configPaths: prepared.filter(({unchanged}) => !unchanged).map(({item}) => item.configPath),
544
+ });
545
+
546
+ for (const {item, unchanged, workflowId: previousWorkflowId} of prepared) {
547
+ const workflowId = unchanged
548
+ ? (previousWorkflowId ??
549
+ (await findOrCreateWorkflow(tx, {
550
+ projectId: params.projectId,
551
+ configPath: item.configPath,
552
+ })))
553
+ : workflowIds.get(item.configPath);
554
+ if (!workflowId) {
555
+ throw new Error(
556
+ `Workflow lineage missing for project ${params.projectId} and config path ${item.configPath}`,
557
+ );
558
+ }
306
559
  const rows = await buildUpsertQuery(tx, {
307
560
  projectId: params.projectId,
308
561
  workspaceId: params.workspaceId,
562
+ workflowId,
309
563
  configPath: item.configPath,
310
564
  source: 'vcs',
311
565
  ref: params.ref,
package/src/db/index.ts CHANGED
@@ -12,6 +12,7 @@ export type {
12
12
  } from './definitions.js';
13
13
  export {
14
14
  applyVcsDefinitionsBatch,
15
+ findOrCreateWorkflowLineage,
15
16
  getDefinitionById,
16
17
  invalidateCache,
17
18
  listDefinitions,
@@ -21,6 +22,7 @@ export {
21
22
  } from './definitions.js';
22
23
  export {definitionsOutbox} from './schema/outbox.js';
23
24
  export {definitionSyncStates} from './schema/sync-states.js';
25
+ export {type WorkflowCreateDb, type WorkflowDb, workflowWorkflows} from './schema/workflows.js';
24
26
  export {
25
27
  type DefinitionSyncStateKey,
26
28
  getLatestDefinitionSyncState,
@@ -16,6 +16,7 @@ describe('toDefinition', () => {
16
16
  };
17
17
  const row: DefinitionDb = {
18
18
  id: '019e98ab-6656-7ca1-b9ad-1ca4442c479d',
19
+ workflowId: '019e98ab-6656-7ca1-b9ad-1ca4442c479e',
19
20
  projectId: '019e98ab-b90f-7265-b13c-8b441c991381',
20
21
  configPath: '.shipfox/workflows/manual.yml',
21
22
  source: 'manual',
@@ -6,6 +6,7 @@ import type {
6
6
  WorkflowDefinitionPayload,
7
7
  } from '#core/entities/workflow-definition.js';
8
8
  import {pgTable} from './common.js';
9
+ import {workflowWorkflows} from './workflows.js';
9
10
 
10
11
  export const definitionSourceEnum = pgEnum('definitions_source', ['manual', 'vcs']);
11
12
 
@@ -13,6 +14,8 @@ export const workflowDefinitions = pgTable(
13
14
  'workflow_definitions',
14
15
  {
15
16
  id: uuidv7PrimaryKey(),
17
+ // Schema-only upgrades leave historical rows null until they are touched.
18
+ workflowId: uuid('workflow_id').references(() => workflowWorkflows.id),
16
19
  projectId: uuid('project_id').notNull(),
17
20
  configPath: text('config_path'),
18
21
  source: definitionSourceEnum('source').notNull().default('manual'),
@@ -41,6 +44,9 @@ export const workflowDefinitions = pgTable(
41
44
  index('definitions_wd_project_name_id_idx')
42
45
  .on(table.projectId, table.name, table.id)
43
46
  .where(sql`"deleted_at" IS NULL`),
47
+ index('definitions_wd_workflow_ref_lookup')
48
+ .on(table.workflowId, table.ref)
49
+ .where(sql`"ref" IS NOT NULL AND "deleted_at" IS NULL`),
44
50
  // Binds source to its git coordinates so the partial indexes above stay
45
51
  // unambiguous: vcs rows carry a ref or sha; manual rows carry neither.
46
52
  check(
@@ -55,8 +61,13 @@ export type DefinitionCreateDb = typeof workflowDefinitions.$inferInsert;
55
61
  export type DefinitionUpdateDb = Partial<DefinitionCreateDb>;
56
62
 
57
63
  export function toDefinition(row: DefinitionDb): WorkflowDefinition {
64
+ if (row.workflowId === null) {
65
+ throw new Error(`Definition ${row.id} has no workflow lineage`);
66
+ }
67
+
58
68
  return {
59
69
  id: row.id,
70
+ workflowId: row.workflowId,
60
71
  projectId: row.projectId,
61
72
  configPath: row.configPath,
62
73
  source: row.source,
@@ -2,13 +2,17 @@ import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
2
2
  import {sql} from 'drizzle-orm';
3
3
  import {index, jsonb, pgEnum, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
4
4
  import type {
5
+ DefinitionSyncDiagnostic,
5
6
  DefinitionSyncErrorCode,
6
7
  DefinitionSyncState,
7
8
  DefinitionSyncStatus,
8
- DefinitionSyncWarning,
9
9
  } from '#core/entities/sync-state.js';
10
10
  import {pgTable} from './common.js';
11
11
 
12
+ type StoredDefinitionSyncDiagnostic = Omit<DefinitionSyncDiagnostic, 'severity'> & {
13
+ severity?: DefinitionSyncDiagnostic['severity'];
14
+ };
15
+
12
16
  export const definitionSyncStatusEnum = pgEnum('definitions_sync_status', [
13
17
  'pending',
14
18
  'syncing',
@@ -43,7 +47,7 @@ export const definitionSyncStates = pgTable(
43
47
  status: definitionSyncStatusEnum('status').notNull().default('pending'),
44
48
  lastErrorCode: definitionSyncErrorCodeEnum('last_error_code'),
45
49
  lastErrorMessage: text('last_error_message'),
46
- warnings: jsonb('warnings').notNull().default([]).$type<DefinitionSyncWarning[]>(),
50
+ warnings: jsonb('warnings').notNull().default([]).$type<StoredDefinitionSyncDiagnostic[]>(),
47
51
  startedAt: timestamp('started_at', {withTimezone: true}),
48
52
  finishedAt: timestamp('finished_at', {withTimezone: true}),
49
53
  createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
@@ -64,6 +68,11 @@ export type DefinitionSyncStateDb = typeof definitionSyncStates.$inferSelect;
64
68
  export type DefinitionSyncStateCreateDb = typeof definitionSyncStates.$inferInsert;
65
69
 
66
70
  export function toDefinitionSyncState(row: DefinitionSyncStateDb): DefinitionSyncState {
71
+ const diagnostics = (row.warnings ?? []).map(({severity, ...diagnostic}) => ({
72
+ ...diagnostic,
73
+ severity: severity ?? 'warning',
74
+ }));
75
+
67
76
  return {
68
77
  id: row.id,
69
78
  projectId: row.projectId,
@@ -73,7 +82,7 @@ export function toDefinitionSyncState(row: DefinitionSyncStateDb): DefinitionSyn
73
82
  status: row.status as DefinitionSyncStatus,
74
83
  lastErrorCode: row.lastErrorCode as DefinitionSyncErrorCode | null,
75
84
  lastErrorMessage: row.lastErrorMessage,
76
- warnings: row.warnings ?? [],
85
+ diagnostics,
77
86
  startedAt: row.startedAt,
78
87
  finishedAt: row.finishedAt,
79
88
  createdAt: row.createdAt,
@@ -0,0 +1,23 @@
1
+ import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
2
+ import {sql} from 'drizzle-orm';
3
+ import {text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
4
+ import {pgTable} from './common.js';
5
+
6
+ export const workflowWorkflows = pgTable(
7
+ 'workflows',
8
+ {
9
+ id: uuidv7PrimaryKey(),
10
+ projectId: uuid('project_id').notNull(),
11
+ configPath: text('config_path'),
12
+ createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
13
+ },
14
+ (table) => [
15
+ uniqueIndex('definitions_workflows_project_path_unique').on(table.projectId, table.configPath),
16
+ uniqueIndex('definitions_workflows_project_pathless_unique')
17
+ .on(table.projectId)
18
+ .where(sql`"config_path" IS NULL`),
19
+ ],
20
+ );
21
+
22
+ export type WorkflowDb = typeof workflowWorkflows.$inferSelect;
23
+ export type WorkflowCreateDb = typeof workflowWorkflows.$inferInsert;
@@ -1,13 +1,13 @@
1
1
  import {
2
+ DEFINITION_SYNC_DIAGNOSTICS_MAX_COUNT,
2
3
  DEFINITION_SYNC_WARNING_CODE_MAX_LENGTH,
3
4
  DEFINITION_SYNC_WARNING_MESSAGE_MAX_LENGTH,
4
5
  DEFINITION_SYNC_WARNING_PATH_MAX_LENGTH,
5
- DEFINITION_SYNC_WARNINGS_MAX_COUNT,
6
6
  } from '@shipfox/api-definitions-dto';
7
7
  import {eq} from 'drizzle-orm';
8
8
  import {db} from './db.js';
9
9
  import {definitionSyncStates} from './schema/sync-states.js';
10
- import {markDefinitionSyncState} from './sync-states.js';
10
+ import {getLatestDefinitionSyncState, markDefinitionSyncState} from './sync-states.js';
11
11
 
12
12
  describe('definition sync state queries', () => {
13
13
  let projectId: string;
@@ -65,14 +65,21 @@ describe('definition sync state queries', () => {
65
65
  expect(second.startedAt?.getTime()).toBe(first.startedAt?.getTime());
66
66
  });
67
67
 
68
- it('persists warnings and clears them on a subsequent sync', async () => {
69
- const warnings = [
68
+ it('persists diagnostics and clears them on a subsequent sync', async () => {
69
+ const diagnostics = [
70
+ {
71
+ code: 'invalid-trigger-event',
72
+ message: 'Trigger event is never delivered.',
73
+ path: 'triggers.on_demand',
74
+ severity: 'error',
75
+ },
70
76
  {
71
77
  code: 're-evaluating-command',
72
78
  message: 'Workflow data is re-executed as shell code.',
73
79
  path: 'jobs.build.steps.0.run',
80
+ severity: 'warning',
74
81
  },
75
- ];
82
+ ] as const;
76
83
 
77
84
  const succeeded = await markDefinitionSyncState({
78
85
  projectId,
@@ -80,10 +87,10 @@ describe('definition sync state queries', () => {
80
87
  sourceExternalRepositoryId: 'gitea-owner/platform',
81
88
  ref: 'main',
82
89
  status: 'succeeded',
83
- warnings,
90
+ diagnostics,
84
91
  });
85
92
 
86
- expect(succeeded.warnings).toEqual(warnings);
93
+ expect(succeeded.diagnostics).toEqual(diagnostics);
87
94
 
88
95
  const syncing = await markDefinitionSyncState({
89
96
  projectId,
@@ -93,27 +100,89 @@ describe('definition sync state queries', () => {
93
100
  status: 'syncing',
94
101
  });
95
102
 
96
- expect(syncing.warnings).toEqual([]);
103
+ expect(syncing.diagnostics).toEqual([]);
97
104
  });
98
105
 
99
- it('bounds warning payloads before persisting them', async () => {
106
+ it('orders errors before warnings and bounds diagnostic payloads before persisting them', async () => {
100
107
  const state = await markDefinitionSyncState({
101
108
  projectId,
102
109
  sourceConnectionId,
103
110
  sourceExternalRepositoryId: 'gitea-owner/platform',
104
111
  ref: 'main',
105
112
  status: 'succeeded',
106
- warnings: Array.from({length: DEFINITION_SYNC_WARNINGS_MAX_COUNT + 1}, (_, index) => ({
107
- code: `${index}-${'c'.repeat(DEFINITION_SYNC_WARNING_CODE_MAX_LENGTH)}`,
108
- message: `${index}-${'m'.repeat(DEFINITION_SYNC_WARNING_MESSAGE_MAX_LENGTH)}`,
109
- path: `${index}-${'p'.repeat(DEFINITION_SYNC_WARNING_PATH_MAX_LENGTH)}`,
110
- })),
113
+ diagnostics: [
114
+ {
115
+ code: 'warning-last',
116
+ message: 'warning last',
117
+ severity: 'warning',
118
+ },
119
+ {
120
+ code: 'error-first',
121
+ message: 'error first',
122
+ filePath: '.shipfox/workflows/deploy.yml',
123
+ severity: 'error',
124
+ },
125
+ ...Array.from({length: DEFINITION_SYNC_DIAGNOSTICS_MAX_COUNT}, (_, index) => ({
126
+ code: `${index}-${'c'.repeat(DEFINITION_SYNC_WARNING_CODE_MAX_LENGTH)}`,
127
+ message: `${index}-${'m'.repeat(DEFINITION_SYNC_WARNING_MESSAGE_MAX_LENGTH)}`,
128
+ path: `${index}-${'p'.repeat(DEFINITION_SYNC_WARNING_PATH_MAX_LENGTH)}`,
129
+ severity: 'warning' as const,
130
+ })),
131
+ ],
132
+ });
133
+
134
+ expect(state.diagnostics).toHaveLength(DEFINITION_SYNC_DIAGNOSTICS_MAX_COUNT);
135
+ expect(state.diagnostics[0]).toEqual({
136
+ code: 'error-first',
137
+ message: 'error first',
138
+ filePath: '.shipfox/workflows/deploy.yml',
139
+ severity: 'error',
140
+ });
141
+ expect(state.diagnostics[1]).toEqual({
142
+ code: 'warning-last',
143
+ message: 'warning last',
144
+ severity: 'warning',
145
+ });
146
+ expect(state.diagnostics[2]?.code).toHaveLength(DEFINITION_SYNC_WARNING_CODE_MAX_LENGTH);
147
+ expect(state.diagnostics[2]?.message).toHaveLength(DEFINITION_SYNC_WARNING_MESSAGE_MAX_LENGTH);
148
+ expect(state.diagnostics[2]?.path).toHaveLength(DEFINITION_SYNC_WARNING_PATH_MAX_LENGTH);
149
+ // The two sentinel diagnostics survive truncation; overflow warnings drop first.
150
+ expect(state.diagnostics[0]?.severity).toBe('error');
151
+ expect(state.diagnostics[1]?.severity).toBe('warning');
152
+ });
153
+
154
+ it('normalizes legacy stored warnings without a severity', async () => {
155
+ await db()
156
+ .insert(definitionSyncStates)
157
+ .values({
158
+ projectId,
159
+ sourceConnectionId,
160
+ sourceExternalRepositoryId: 'gitea-owner/platform',
161
+ ref: 'main',
162
+ status: 'succeeded',
163
+ warnings: [
164
+ {
165
+ code: 'legacy-warning',
166
+ message: 'Written before diagnostics had severity.',
167
+ path: 'jobs.build.steps.0.run',
168
+ },
169
+ ],
170
+ });
171
+
172
+ const state = await getLatestDefinitionSyncState({
173
+ projectId,
174
+ sourceConnectionId,
175
+ sourceExternalRepositoryId: 'gitea-owner/platform',
111
176
  });
112
177
 
113
- expect(state.warnings).toHaveLength(DEFINITION_SYNC_WARNINGS_MAX_COUNT);
114
- expect(state.warnings[0]?.code).toHaveLength(DEFINITION_SYNC_WARNING_CODE_MAX_LENGTH);
115
- expect(state.warnings[0]?.message).toHaveLength(DEFINITION_SYNC_WARNING_MESSAGE_MAX_LENGTH);
116
- expect(state.warnings[0]?.path).toHaveLength(DEFINITION_SYNC_WARNING_PATH_MAX_LENGTH);
178
+ expect(state?.diagnostics).toEqual([
179
+ {
180
+ code: 'legacy-warning',
181
+ message: 'Written before diagnostics had severity.',
182
+ path: 'jobs.build.steps.0.run',
183
+ severity: 'warning',
184
+ },
185
+ ]);
117
186
  });
118
187
 
119
188
  it('clears stale finish data when a sync starts again', async () => {