@@ -2,11 +2,24 @@ import { describe, expect, it } from "vitest";
22import { readFileSync } from "node:fs" ;
33import { resolve } from "node:path" ;
44
5- // Asserts every scalar column of the run-subgraph models in the control-plane
6- // schema also exists in the dedicated schema (so the dedicated DB can hold the
7- // same row shape), and that the dedicated schema contains NO reference to a
8- // control-plane model name.
9- const CONTROL_PLANE_MODELS = [
5+ // Parses both the control-plane schema (`@trigger.dev/database`) and this
6+ // dedicated run-ops schema, then diffs every SCALAR field of each run-subgraph
7+ // model BIDIRECTIONALLY: a field present in either schema must exist in the
8+ // other with matching type/nullability/array-ness/`@default`, since a run row
9+ // must be writable to either physical database.
10+ //
11+ // Relation fields are excluded by design: the dedicated schema drops
12+ // control-plane `@relation`s to control-plane-owned models and replaces the
13+ // implicit many-to-many waitpoint relations with explicit FK-free join models
14+ // (`TaskRunWaitpoint`, `WaitpointRunConnection`, `CompletedWaitpoint`). Only
15+ // the scalar FK columns backing those relations (e.g. `projectId`) are
16+ // compared; the relation object fields (e.g. `project`) are skipped.
17+
18+ const CONTROL_PLANE_SCHEMA_PATH = "../../database/prisma/schema.prisma" ;
19+ const DEDICATED_SCHEMA_PATH = "./schema.prisma" ;
20+
21+ // Must never appear as a relation target in the dedicated schema.
22+ const CONTROL_PLANE_ONLY_MODELS = [
1023 "Organization" ,
1124 "OrgMember" ,
1225 "Project" ,
@@ -19,73 +32,232 @@ const CONTROL_PLANE_MODELS = [
1932 "TaskQueue" ,
2033] ;
2134
22- function readSchema ( rel : string ) {
35+ // Must have every scalar column reproduced in the dedicated schema.
36+ const RUN_SUBGRAPH_MODELS = [
37+ "TaskRun" ,
38+ "TaskRunAttempt" ,
39+ "TaskRunExecutionSnapshot" ,
40+ "TaskRunWaitpoint" ,
41+ "TaskRunCheckpoint" ,
42+ "CheckpointRestoreEvent" ,
43+ "TaskRunTag" ,
44+ "Waitpoint" ,
45+ "WaitpointTag" ,
46+ "BatchTaskRun" ,
47+ "TaskRunDependency" ,
48+ "BatchTaskRunItem" ,
49+ "BatchTaskRunError" ,
50+ "Checkpoint" ,
51+ ] ;
52+
53+ function readSchema ( rel : string ) : string {
2354 return readFileSync ( resolve ( __dirname , rel ) , "utf8" ) ;
2455}
2556
26- // Prisma comments (`///` docs and `//` lines) may legitimately mention
27- // control-plane model names in prose, which would false-match the drift
28- // regexes below. Strip them so parity assertions only see real schema syntax.
29- function stripComments ( schema : string ) {
30- return schema . replace ( / \/ \/ .* $ / gm, "" ) ;
57+ // Strips `/* */` block comments and `//`/`///` line comments so prose mentioning
58+ // model names can't false-match below. (Neither schema has `/*` inside a string.)
59+ function stripComments ( schema : string ) : string {
60+ return schema . replace ( / \/ \* [ \s \S ] * ?\* \/ / g, "" ) . replace ( / \/ \/ .* $ / gm, "" ) ;
61+ }
62+
63+ interface FieldInfo {
64+ type : string ;
65+ optional : boolean ;
66+ array : boolean ;
67+ default : string | null ;
68+ }
69+
70+ type ModelFields = Map < string , FieldInfo > ;
71+
72+ // Prisma model blocks don't nest, so a lazy match to the next ` }`-only line suffices.
73+ function extractModelBlocks ( schema : string ) : Map < string , string > {
74+ const blocks = new Map < string , string > ( ) ;
75+ const re = / ^ m o d e l \s + ( \w + ) \s * \{ ( [ \s \S ] * ?) \n \} / gm;
76+ let match : RegExpExecArray | null ;
77+ while ( ( match = re . exec ( schema ) ) ) {
78+ blocks . set ( match [ 1 ] , match [ 2 ] ) ;
79+ }
80+ return blocks ;
81+ }
82+
83+ // Raw argument of `@default(...)`, honoring nested parens (`@default(cuid())`). Null if absent.
84+ function extractDefault ( attrs : string ) : string | null {
85+ const marker = "@default(" ;
86+ const idx = attrs . indexOf ( marker ) ;
87+ if ( idx === - 1 ) return null ;
88+ const start = idx + marker . length ;
89+ let depth = 0 ;
90+ for ( let i = start ; i < attrs . length ; i ++ ) {
91+ if ( attrs [ i ] === "(" ) {
92+ depth ++ ;
93+ } else if ( attrs [ i ] === ")" ) {
94+ if ( depth === 0 ) return attrs . slice ( start , i ) ;
95+ depth -- ;
96+ }
97+ }
98+ return attrs . slice ( start ) ;
99+ }
100+
101+ // Excludes relation fields: anything carrying `@relation`, plus back-relation fields
102+ // (e.g. `checkpoints Checkpoint[]`) identified by their type being another model name.
103+ // `unparsed` collects any content line the field regex fails to match, so a future
104+ // multi-line field can never silently vanish from the diff.
105+ function parseScalarFields (
106+ body : string ,
107+ modelNames : Set < string >
108+ ) : { fields : ModelFields ; unparsed : string [ ] } {
109+ const fields : ModelFields = new Map ( ) ;
110+ const unparsed : string [ ] = [ ] ;
111+ for ( const rawLine of body . split ( "\n" ) ) {
112+ const line = rawLine . trim ( ) ;
113+ if ( ! line || line . startsWith ( "@@" ) ) continue ;
114+ const match = line . match ( / ^ ( \w + ) \s + ( [ A - Z a - z _ ] \w * ) ( \[ \] ) ? ( \? ) ? \s * ( .* ) $ / ) ;
115+ if ( ! match ) {
116+ unparsed . push ( line ) ;
117+ continue ;
118+ }
119+ const [ , name , type , arrayMark , optionalMark , rest ] = match ;
120+ if ( rest . includes ( "@relation" ) ) continue ;
121+ if ( modelNames . has ( type ) ) continue ;
122+ fields . set ( name , {
123+ type,
124+ array : arrayMark === "[]" ,
125+ optional : optionalMark === "?" ,
126+ default : extractDefault ( rest ) ,
127+ } ) ;
128+ }
129+ return { fields, unparsed } ;
130+ }
131+
132+ function parseSchema ( schema : string ) {
133+ const stripped = stripComments ( schema ) ;
134+ const modelBlocks = extractModelBlocks ( stripped ) ;
135+ const modelNames = new Set ( modelBlocks . keys ( ) ) ;
136+ const models = new Map < string , ModelFields > ( ) ;
137+ const unparsed : string [ ] = [ ] ;
138+ for ( const [ name , body ] of modelBlocks ) {
139+ const parsed = parseScalarFields ( body , modelNames ) ;
140+ models . set ( name , parsed . fields ) ;
141+ for ( const line of parsed . unparsed ) {
142+ unparsed . push ( `${ name } : ${ line } ` ) ;
143+ }
144+ }
145+ return { models, modelNames, unparsed } ;
146+ }
147+
148+ function describeField ( field : FieldInfo | undefined ) : string {
149+ if ( ! field ) return "<missing>" ;
150+ const array = field . array ? "[]" : "" ;
151+ const optional = field . optional ? "?" : "" ;
152+ const withDefault = field . default !== null ? ` @default(${ field . default } )` : "" ;
153+ return `${ field . type } ${ array } ${ optional } ${ withDefault } ` ;
31154}
32155
156+ const controlPlane = parseSchema ( readSchema ( CONTROL_PLANE_SCHEMA_PATH ) ) ;
157+ const dedicated = parseSchema ( readSchema ( DEDICATED_SCHEMA_PATH ) ) ;
158+
33159describe ( "dedicated run-ops schema parity" , ( ) => {
160+ it ( "includes all 14 run-subgraph models in both schemas" , ( ) => {
161+ for ( const model of RUN_SUBGRAPH_MODELS ) {
162+ expect ( Array . from ( dedicated . modelNames ) ) . toContain ( model ) ;
163+ expect ( Array . from ( controlPlane . modelNames ) ) . toContain ( model ) ;
164+ }
165+ } ) ;
166+
167+ it ( "fails on any content line the field parser doesn't recognise (no silent skips)" , ( ) => {
168+ // Scope the control-plane check to run-subgraph models so an unrelated control-plane schema
169+ // edit can't break this run-ops test; the dedicated schema contains only run-ops models.
170+ const inRunSubgraph = ( entry : string ) =>
171+ RUN_SUBGRAPH_MODELS . some ( ( model ) => entry . startsWith ( `${ model } : ` ) ) ;
172+ expect ( controlPlane . unparsed . filter ( inRunSubgraph ) ) . toEqual ( [ ] ) ;
173+ expect ( dedicated . unparsed ) . toEqual ( [ ] ) ;
174+ } ) ;
175+
176+ it ( "reproduces every scalar column of each run-subgraph model bidirectionally with matching type/nullability/array/default" , ( ) => {
177+ const mismatches : string [ ] = [ ] ;
178+
179+ for ( const modelName of RUN_SUBGRAPH_MODELS ) {
180+ const controlFields = controlPlane . models . get ( modelName ) ;
181+ const dedicatedFields = dedicated . models . get ( modelName ) ;
182+ if ( ! controlFields || ! dedicatedFields ) {
183+ mismatches . push ( `${ modelName } : model not found in one of the two schemas` ) ;
184+ continue ;
185+ }
186+
187+ const fieldNames = new Set ( [ ...controlFields . keys ( ) , ...dedicatedFields . keys ( ) ] ) ;
188+ for ( const fieldName of fieldNames ) {
189+ const controlField = controlFields . get ( fieldName ) ;
190+ const dedicatedField = dedicatedFields . get ( fieldName ) ;
191+
192+ if ( ! controlField ) {
193+ mismatches . push (
194+ `${ modelName } .${ fieldName } : dedicated has ${ describeField (
195+ dedicatedField
196+ ) } but the control-plane schema has no such scalar field`
197+ ) ;
198+ continue ;
199+ }
200+ if ( ! dedicatedField ) {
201+ mismatches . push (
202+ `${ modelName } .${ fieldName } : control-plane has ${ describeField (
203+ controlField
204+ ) } but the dedicated schema has no such scalar field`
205+ ) ;
206+ continue ;
207+ }
208+
209+ const matches =
210+ dedicatedField . type === controlField . type &&
211+ dedicatedField . array === controlField . array &&
212+ dedicatedField . optional === controlField . optional &&
213+ dedicatedField . default === controlField . default ;
214+
215+ if ( ! matches ) {
216+ mismatches . push (
217+ `${ modelName } .${ fieldName } : control-plane=${ describeField (
218+ controlField
219+ ) } dedicated=${ describeField ( dedicatedField ) } `
220+ ) ;
221+ }
222+ }
223+ }
224+
225+ expect ( mismatches ) . toEqual ( [ ] ) ;
226+ } ) ;
227+
34228 it ( "references no control-plane model as a relation target" , ( ) => {
35- const dedicated = stripComments ( readSchema ( "./schema.prisma" ) ) ;
36- for ( const model of CONTROL_PLANE_MODELS ) {
229+ const dedicatedText = stripComments ( readSchema ( DEDICATED_SCHEMA_PATH ) ) ;
230+ for ( const model of CONTROL_PLANE_ONLY_MODELS ) {
37231 // A relation target appears as ` fieldName Model @relation(...)`. A bare
38232 // scalar column like `projectId String` is fine; the model TYPE must be absent.
39233 const relationTarget = new RegExp (
40234 `@relation[^\\n]*\\b${ model } \\b|\\b${ model } \\b[^\\n]*@relation`
41235 ) ;
42- expect ( dedicated ) . not . toMatch ( relationTarget ) ;
43- expect ( dedicated ) . not . toMatch ( new RegExp ( `\\s${ model } (\\?|\\[\\])?\\s` ) ) ;
44- }
45- } ) ;
46-
47- it ( "includes all 14 run-subgraph models" , ( ) => {
48- const dedicated = readSchema ( "./schema.prisma" ) ;
49- for ( const m of [
50- "TaskRun" ,
51- "TaskRunAttempt" ,
52- "TaskRunExecutionSnapshot" ,
53- "TaskRunWaitpoint" ,
54- "TaskRunCheckpoint" ,
55- "CheckpointRestoreEvent" ,
56- "TaskRunTag" ,
57- "Waitpoint" ,
58- "WaitpointTag" ,
59- "BatchTaskRun" ,
60- "TaskRunDependency" ,
61- "BatchTaskRunItem" ,
62- "BatchTaskRunError" ,
63- "Checkpoint" ,
64- ] ) {
65- expect ( dedicated ) . toMatch ( new RegExp ( `model ${ m } \\{` ) ) ;
236+ expect ( dedicatedText ) . not . toMatch ( relationTarget ) ;
237+ expect ( dedicatedText ) . not . toMatch ( new RegExp ( `\\s${ model } (\\?|\\[\\])?\\s` ) ) ;
66238 }
67239 } ) ;
68240
69241 it ( "keeps the group-(A) waitpoint-block references FK-FREE (scalar columns / explicit FK-free join models)" , ( ) => {
70- const dedicated = stripComments ( readSchema ( "./schema.prisma" ) ) ;
242+ const dedicatedText = stripComments ( readSchema ( DEDICATED_SCHEMA_PATH ) ) ;
71243 // TaskRunWaitpoint must NOT carry a `@relation` to Waitpoint/TaskRun/BatchTaskRun.
72- const trw = dedicated . match ( / m o d e l T a s k R u n W a i t p o i n t \{ [ \s \S ] * ?\n \} / ) ! [ 0 ] ;
244+ const trw = dedicatedText . match ( / m o d e l T a s k R u n W a i t p o i n t \{ [ \s \S ] * ?\n \} / ) ! [ 0 ] ;
73245 expect ( trw ) . not . toMatch ( / @ r e l a t i o n / ) ;
74246 expect ( trw ) . toMatch ( / w a i t p o i n t I d \s + S t r i n g / ) ;
75247 expect ( trw ) . toMatch ( / t a s k R u n I d \s + S t r i n g / ) ;
76248 // The two implicit M2M sets are replaced by explicit FK-free join models.
77- expect ( dedicated ) . toMatch ( / m o d e l W a i t p o i n t R u n C o n n e c t i o n \{ / ) ;
78- expect ( dedicated ) . toMatch ( / m o d e l C o m p l e t e d W a i t p o i n t \{ / ) ;
79- const wrc = dedicated . match ( / m o d e l W a i t p o i n t R u n C o n n e c t i o n \{ [ \s \S ] * ?\n \} / ) ! [ 0 ] ;
249+ expect ( dedicatedText ) . toMatch ( / m o d e l W a i t p o i n t R u n C o n n e c t i o n \{ / ) ;
250+ expect ( dedicatedText ) . toMatch ( / m o d e l C o m p l e t e d W a i t p o i n t \{ / ) ;
251+ const wrc = dedicatedText . match ( / m o d e l W a i t p o i n t R u n C o n n e c t i o n \{ [ \s \S ] * ?\n \} / ) ! [ 0 ] ;
80252 expect ( wrc ) . not . toMatch ( / @ r e l a t i o n / ) ;
81253 // Waitpoint completion back-refs are scalar, not relations.
82- const wp = dedicated . match ( / m o d e l W a i t p o i n t \{ [ \s \S ] * ?\n \} / ) ! [ 0 ] ;
254+ const wp = dedicatedText . match ( / m o d e l W a i t p o i n t \{ [ \s \S ] * ?\n \} / ) ! [ 0 ] ;
83255 expect ( wp ) . not . toMatch ( / c o m p l e t e d B y T a s k R u n \s + T a s k R u n \s * \? \s * @ r e l a t i o n / ) ;
84256 } ) ;
85257
86258 it ( "keeps the group-(B) co-resident references as real FKs (e.g. TaskRunAttempt.taskRun)" , ( ) => {
87- const dedicated = stripComments ( readSchema ( "./schema.prisma" ) ) ;
88- const attempt = dedicated . match ( / m o d e l T a s k R u n A t t e m p t \{ [ \s \S ] * ?\n \} / ) ! [ 0 ] ;
259+ const dedicatedText = stripComments ( readSchema ( DEDICATED_SCHEMA_PATH ) ) ;
260+ const attempt = dedicatedText . match ( / m o d e l T a s k R u n A t t e m p t \{ [ \s \S ] * ?\n \} / ) ! [ 0 ] ;
89261 // The attempt->run relation stays a real FK (always co-resident).
90262 expect ( attempt ) . toMatch ( / t a s k R u n \s + T a s k R u n \s + @ r e l a t i o n / ) ;
91263 } ) ;
0 commit comments